Compare commits

..

18 commits

Author SHA1 Message Date
Lukas Brübach
734f91c8e9 [Replay] Build a compact event history index
Turn replay protocol events into short localized summaries with an actor, badge, and time so the history UI can render them without retaining the replay or duplicating the chat log.
2026-11-02 15:44:52 +01:00
Lukas Brübach
fecf10e2b1 [Replay] Seek to the exact time of an event
Timeline seeks only expose whole timer intervals, so jumping to an event could leave that event unprocessed until the next tick. Add an event-time seek that also applies events at the target timestamp.
2026-11-02 15:40:58 +01:00
sentry[bot]
9f30892dff
[Client] Fix crash when local game commands are sent after shutdown (#7714)
Some checks are pending
CodeQL / Analyze (cpp) (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
Build Desktop / Configure (push) Waiting to run
Build Desktop / vcpkg baseline (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 cover (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 26 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Desktop / Prune caches (push) Waiting to run
Build Docker / Servatrice (arm) (push) Waiting to run
Build Docker / Servatrice (x86) (push) Waiting to run
Build Docker / Publish multi-platform Servatrice image (push) Blocked by required conditions
* [Client] Fix crash when local game commands are sent after shutdown

* [Network] Fix formatting in local client debug log

* [CI] Keep the vcpkg submodule pointer on the manifest baseline

vcpkg.json asks for builtin-baseline 9eec002 (vcpkg 2026.10.16), but the
submodule gitlink was still at 9e593bb. The runners check submodules out
shallowly, so vcpkg-tool cannot read versions/baseline.json out of the baseline
commit and every vcpkg leg (Windows and all macOS) fails in CMake configure
with:

  failed to `git show` versions/baseline.json

git phrases that as "path ... exists on disk, but not in <sha>" even when the
commit is simply absent from the shallow clone, so it reads like a tooling or
network failure rather than a mismatched pointer. Point the submodule back at
9eec002, and add a cheap job that fails when the gitlink and builtin-baseline
disagree, before the five build legs pay for it.

---------

Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com>
Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-11-02 13:28:23 +01:00
sentry[bot]
8b0c980e85
[Client] Guard dragged card ghost from painting or dropping deleted cards (#7688)
Some checks are pending
CodeQL / Analyze (cpp) (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
Build Desktop / Configure (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 cover (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 26 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Desktop / Prune caches (push) Waiting to run
Build Docker / Servatrice (arm) (push) Waiting to run
Build Docker / Servatrice (x86) (push) Waiting to run
Build Docker / Publish multi-platform Servatrice image (push) Blocked by required conditions
* [Client] Guard dragged card ghost from painting or dropping deleted cards

* [Game] Revert handling for cards destroyed mid-drag

Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com>

* [DeckView] Access pointer through QPointer in DeckViewCardDragItem

---------

Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com>
Co-authored-by: zeldazach <7460172+zeldazach@users.noreply.github.com>
2026-11-02 08:39:07 +01:00
RickyRister
62e590d300
[ServerGame] Refactor out spectator/judge bools into RoleInfo (#7761) 2026-11-02 00:37:05 -07:00
BruebachL
a1a8a59f4a
[VisualDatabase] Draw the card grid with a model/view (#7701)
* [VisualDatabase] Draw the card grid with a model/view instead of a widget per card

The visual card database built one CardInfoPictureWithTextOverlayWidget per
card, 100 per page, and tore them down on every search, resize and scroll
page. It also looked each card up again by name from the model row, kept a
shadow QList<ExactCard>, and carried paging state plus a 5 s timer that only
existed to defer the first page.

Replace that with a real view on a flattened model:

- CardDatabaseDisplayModel gains CardInfoRole and cardInfoAt(), so a view can
  get the CardInfoPtr behind a proxy row without a name round trip.
- New CardDatabaseVisualDisplayModel (libcockatrice_models) flattens the
  filtered and sorted proxy into one entry per displayed card picture: the
  preferred printing normally, every printing of the selected sets while set
  filters are active. Proxy changes rebuild it on a 30 ms coalescing timer.
- New CardDatabaseVisualView (QListView IconMode) paints only visible rows
  through the new CardPictureDelegate, keeps the old interaction contract
  (left/right/middle press -> cardClicked, hover -> cardHovered, Ctrl+wheel
  resize, no selection, no focus stealing, scroll to top on a new result) and
  repaints when CardPictureLoader reports an async image.
- CardSizeWidget::enableCtrlScrollResize() also watches scroll-area viewports,
  which is what makes Ctrl+wheel work over a QListView.
- VisualDatabaseDisplayWidget drops the paging, the shadow list, the dead
  members and the 5 s timer.

Behaviour-preserving: no gameplay change, no new user-facing strings.

* [VisualDatabase] Fix the card grid wheel step

QListView hands the delegate's item height to the scroll bar as its single step under ScrollPerPixel, so QAbstractSlider moved wheelScrollLines() whole card rows per notch. Handle the wheel in the view instead: one line is an eighth of the grid cell, so a notch moves about a third of a card row at any card size and still follows the OS line count.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-11-02 08:35:58 +01:00
BruebachL
94e6281d87
[Logs] Browse room history without filters with paginated infinite scroll (#7531)
* [Logs] Browse room history without filters with paginated infinite scroll

* [Logs] Continue pagination when switching log tabs

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-11-02 07:36:49 +01:00
BruebachL
3cf8bdb00e
[Tests] Add a headless game logic regression suite (#7702)
* [Tests] Add headless game logic regression suite

Cover CardState, CardZoneLogic and the Command_MoveCard protobufs PlayerActions emits, against libcockatrice_game with a recording AbstractClient and no graphics. The old game_smoke_test cases are migrated into the new suite.

* [Tests] Cover game event application through GameEventHandler

Feeds GameEventContainers through the real dispatcher and asserts the resulting zone and card state: move (including face down), the set-card-attr attributes plus the all-cards and does-not-untap branches, card counters, attach/detach, zone reveal properties, and drawing named cards from the deck.

* [Tests] Replay a synthetic game state through the dispatcher

Builds a GameReplay in code, feeds its containers through the same GameEventHandler path the client uses, and asserts the materialized players, zones, cards and attachments plus event ordering for move, attributes and counters. A small headless zone view stands in for the graphics side where processPlayerInfo asks for card items.

* [Tests] Cover card flip, destroy, token and turn events

Adds the remaining state-mutating event handlers: flip (face down keeps the ref, face up adopts the printing), destroy (removes the card and un-attaches its attached cards), create token (database P/T unless provided or face down) and player properties changed under the concede/unconcede contexts, plus active player and phase.

* [Tests] Cover player lifecycle and counter events

Adds the player-level events that mutate session state: leave removes the player from the manager, kicked emits gameClosed, game closed flips the closed flag and clears the started flag, host changed moves the host role to another player, and create/set/delete update the player counters with unknown ids ignored.

* [Tests] Drive the zone view request through addCard

The test emitted createCardRequested itself and then asserted it was emitted. Registering a ZoneViewZoneLogic on the zone and adding a card exercises the real prepareAddCard -> signal path, so the assertion now fails if addCard stops asking the graphics side to build the card.

* [Tests] Skip the card database when listing tests

gtest_discover_tests runs the binary with --gtest_list_tests during the build. That process built the card database and replaced its default path provider, leaving allocations that the sanitizer leg's default LeakSanitizer check reports at exit, so discovery failed on Ubuntu 26.04. No test runs in list mode, so return before the database setup.

* [Tests] Fix fixture comment nit

Wraps the RecordingClient::sent member name in backticks in the comment.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-11-02 07:35:41 +01:00
BruebachL
5134e72eb4
[Settings] Store server profiles with QSettings arrays (#7629)
The server list used hand-numbered saveName%1/server%1 keys and an O(n)
shift loop to delete entries. Store the profiles with beginReadArray and
beginWriteArray instead, and migrate the legacy keys once in the
constructor so existing profiles and saved passwords survive.

SettingsManager::batchWrite no longer disables atomic sync, so a batch is
still written through QSettings' atomic save. Add tests for add, update,
remove, password updates and the legacy migration.

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-11-02 07:30:29 +01:00
BruebachL
b37c8b828f
[Utility] Budget scrypt's XY array in the derivation memory limit (#7712)
deriveKey passes maxmem = 128nr + 128rp + 4096 to EVP_PBE_scrypt, but scrypt
also allocates XY at 256r bytes. costParamsAreSane accepts r up to 32, so any
server asking for r >= 17 made derivation fail and the client report a login
abort it could never recover from. Cover the XY array and add a regression test
at the accepted-range boundary.

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-11-02 07:29:15 +01:00
BruebachL
719863e43d
[CI] Lint only .sh files in the shell formatting pass (#7715)
The changed-file matcher reused the `*.sh` glob as a regular expression,
where the leading `*` applies to the `(^|/)` group instead of the file
name. Every path ending in "sh" matched, so NSIS include files (.nsh) were
handed to shellcheck and failed the format leg.

Match the suffix explicitly and keep the glob for the find(1) branch.

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-11-02 07:29:05 +01:00
BruebachL
d021f20d14
[Version] Bump to 3.2.0 (#7762)
Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-11-02 07:28:14 +01:00
BruebachL
ce543896f5
[Tests] Use QtTest for model contracts and signal spies (#7630)
Qt6::Test was linked into every test but never used. Add a small RAII
QAbstractItemModelTester helper and apply it to the deck list model, the card
database model and display proxy, and the sets model. Add QSignalSpy coverage
for the card-added and download-settings signals, and assert explicitly that a
card row is a leaf with no child columns (the tester does not check that).

The contract checker immediately found four model bugs, fixed here:
- DeckListModel allowed children below non-zero columns.
- CardDatabaseModel reported every card as a child of every card row.
- CardDatabaseModel reported six columns for a valid (leaf) card index.
- CardDatabaseDisplayModel claimed more rows were fetchable while rowCount
  already exposed them all, so fetchMore() inserted duplicates.

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-11-02 07:27:43 +01:00
BruebachL
429eb2a1fd
[Servatrice] Use compile-time checked connections (#7631)
* [Servatrice] Use compile-time checked connections

Replace the remaining SIGNAL/SLOT string connects in servatrice.cpp,
smtpclient.cpp, serversocketinterface.cpp and isl_interface.cpp with
pointer-to-member or lambda connects, and convert the string invokeMethod
calls to the functor overload.

QxtSmtp has overloaded signals, so those need qOverload. Slots inherited as
protected members and the QWebSocket error connection owned by the ISL fix
are left on lambdas/strings as appropriate; the vendored qxtsmtp.cpp is
untouched.

* Fix remaining review threads on the connection syntax

- Connect the IslInterface signals straight to the inherited Server slots
  instead of forwarding lambdas.
- Capture servatrice explicitly in the queued admin lambdas so they do not
  read this->servatrice after teardown.
- Move the websocket newConnection connect out of the per-pool loop.
- Terminate the logging category and restore pendingKey's indentation.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-11-02 07:13:21 +01:00
BruebachL
a74dfeef6e
[Build] Configure AUTOMOC, AUTOUIC and AUTORCC once (#7637)
The root already set CMAKE_AUTOMOC; add CMAKE_AUTOUIC and CMAKE_AUTORCC
next to it and drop the per-directory copies from 18 CMakeLists files.
These variables are inherited by the subdirectories, so the duplicates
never changed anything.

qt_standard_project_setup is not used: besides the AUTO* variables it
sets CMAKE_RUNTIME_OUTPUT_DIRECTORY on Windows, which breaks the
install(DIRECTORY "$<TARGET_FILE_DIR:...>") copies and the per-app
symbol paths, and appends to CMAKE_INSTALL_RPATH on Linux.

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-11-02 07:06:47 +01:00
BruebachL
5fe7f186d3
[CI] Make the hdiutil retry wrapper report failure (#7643)
* [CI] Make the hdiutil retry wrapper report failure

CPack reads the wrapper's exit status to decide whether the disk image was
created, so a zero exit turns a failed hdiutil into a later "Problem copying
the package" error that hides hdiutil's own message.

- Propagate hdiutil's real status instead of collapsing every failure to 1.
- Name the failing subcommand (create/attach/detach/convert) so the message
  says which CPack step gave up.
- Count attempts rather than failures in the retry line.

* Update .ci/compile.sh

Co-authored-by: tooomm <tooomm@users.noreply.github.com>

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
Co-authored-by: tooomm <tooomm@users.noreply.github.com>
2026-11-02 07:01:55 +01:00
BruebachL
50a2ae9cc8
[Server/Client] Enforce and complete the forced password change at login (#7680)
checkUserPassword ignored force_password_change on the challenge-response
path, so a reset password stayed valid. Returning PasswordChangeRequired
was not enough on its own: the session was dropped and the client told the
user to ask the administrator for the temporary password they had just used.

The pending session now keeps its identity without registering a user
session or announcing a join, may only ping and change its password, and
the client completes the change in-session before reconnecting.

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-11-02 07:01:29 +01:00
BruebachL
ef4574d2b9
[Server] Store a scrypt verifier when an admin resets a password (#7679)
The temporary password was written as a legacy salt+hash row, which makes the
next login look like an authentication downgrade. A client holding a saved
verifier refuses that downgrade, so the reset locked the account out of every
modern client.

Store the verifier and, where security/legacy_backup_key is configured, keep a
sealed legacy hash of the same temporary password as the downgrade backup, so
pre-challenge clients keep the credential path the reset used to provide.

Without the key no row shape serves both: the verifier and the legacy hash
cannot share one column, so those clients have no path at all. Warn at reset
time that the new password has no pre-challenge path, and say so in
servatrice.ini.example instead of promising that a reset gets them back in.

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-11-02 07:00:48 +01:00
116 changed files with 4829 additions and 1044 deletions

View file

@ -311,16 +311,24 @@ if [[ $RUNNER_OS == macOS ]]; then
if [[ $MAKE_PACKAGE ]]; then
# Workaround https://github.com/actions/runner-images/issues/7522
# have hdiutil repeat the command 10 times in hope of success
# have hdiutil repeat the command 10 times in hope of success.
# The wrapper has to exit non-zero once it gives up: CPack reads that status to
# decide whether the disk image was created, so a zero exit turns a failed hdiutil
# into a much later, misleading "Problem copying the package" error that hides
# hdiutil's own message.
hdiutil_script="/tmp/hdiutil.sh"
# shellcheck disable=SC2016
echo '#!/bin/bash
attempts=10
i=0
while ! hdiutil "$@"; do
if (( ++i >= 10 )); then
echo "Error: hdiutil failed $i times!" >&2
break
while true; do
hdiutil "$@" && exit 0
rc=$?
if (( ++i >= attempts )); then
echo "::error::hdiutil $1 failed $i times (last status $rc), giving up" >&2
exit "$rc"
fi
echo "hdiutil $1 attempt $i/$attempts failed (status $rc), retrying" >&2
sleep 1
done' >"$hdiutil_script"
chmod +x "$hdiutil_script"

View file

@ -101,6 +101,36 @@ jobs:
--title "$release_name" \
--notes-file "$body_path"
vcpkg-baseline:
name: vcpkg baseline
runs-on: ubuntu-slim
steps:
- name: "Checkout"
uses: actions/checkout@v7
# vcpkg-tool reads versions/baseline.json out of the commit named by
# `builtin-baseline`, through the `vcpkg` submodule's git history. A submodule
# pointer that lags the manifest - e.g. a stale `vcpkg/` worktree captured by
# `git add` in a PR - makes every vcpkg leg fail in configure with "failed to
# `git show` versions/baseline.json". That reads like a baseline or network
# problem, when the pointer is what has to move. Check the two agree here in
# seconds instead of on five build legs.
- name: "Check the submodule pointer against the manifest baseline"
shell: bash
run: |
pointer="$(git rev-parse HEAD:vcpkg 2>/dev/null || true)"
baseline="$(sed -nE 's/.*"builtin-baseline"[[:space:]]*:[[:space:]]*"([0-9a-f]{40})".*/\1/p' vcpkg.json)"
if [[ -z "$pointer" || -z "$baseline" ]]; then
echo "::error file=vcpkg.json::could not read the vcpkg submodule pointer or the builtin-baseline"
exit 1
fi
if [[ "$pointer" != "$baseline" ]]; then
echo "::error file=vcpkg.json::vcpkg submodule is at $pointer but builtin-baseline is $baseline"
echo "Move them together: git -C vcpkg checkout $baseline && git add vcpkg"
exit 1
fi
echo "vcpkg submodule and builtin-baseline agree on $baseline"
build-linux:
strategy:
fail-fast: false
@ -466,7 +496,7 @@ jobs:
vcpkg_upload: 1 # only one leg per vcpkg_scope hands its archives over
name: ${{ matrix.os }} ${{ matrix.target }}${{ matrix.soc == 'Intel' && ' Intel' || '' }}${{ matrix.type == 'Debug' && ' Debug' || '' }}
needs: configure
needs: [configure, vcpkg-baseline]
runs-on: ${{ matrix.runner }}
timeout-minutes: 100
env:

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.2.0)
# Set release name if not provided via env/cmake var
if(NOT DEFINED GIT_TAG_RELEASENAME)
@ -259,7 +259,13 @@ else()
add_compile_definitions(QT_DISABLE_DEPRECATED_BEFORE=0x060400)
endif()
# Set once here for every target: these variables are inherited by the
# subdirectories, so the per-directory copies were redundant. Only the
# variables are set; qt_standard_project_setup would also change the runtime
# output directories and the install RPATH.
set(CMAKE_AUTOMOC TRUE)
set(CMAKE_AUTOUIC TRUE)
set(CMAKE_AUTORCC TRUE)
# Find other needed libraries
find_package(Protobuf CONFIG)

View file

@ -171,6 +171,7 @@ set(cockatrice_SOURCES
src/interface/widgets/cards/card_info_picture_widget.cpp
src/interface/widgets/cards/card_info_picture_with_text_overlay_widget.cpp
src/interface/widgets/cards/card_info_text_widget.cpp
src/interface/widgets/cards/card_picture_delegate.cpp
src/interface/widgets/cards/card_size_widget.cpp
src/interface/widgets/cards/deck_card_zone_display_widget.cpp
src/interface/widgets/cards/deck_preview_card_picture_widget.cpp
@ -291,6 +292,7 @@ set(cockatrice_SOURCES
src/interface/widgets/utility/sequence_edit.cpp
src/interface/widgets/utility/visibility_change_listener.cpp
src/interface/widgets/utility/visibility_change_listener.h
src/interface/widgets/visual_database_display/card_database_visual_view.cpp
src/interface/widgets/visual_database_display/visual_database_display_color_filter_widget.cpp
src/interface/widgets/visual_database_display/visual_database_display_filter_save_load_widget.cpp
src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.cpp

View file

@ -3,6 +3,7 @@
#include "../../../interface/pixel_map_generator.h"
#include "../../settings/cache_settings.h"
#include "../interface/widgets/dialogs/dlg_connect.h"
#include "../interface/widgets/dialogs/dlg_edit_password.h"
#include "../interface/widgets/dialogs/dlg_forgot_password_challenge.h"
#include "../interface/widgets/dialogs/dlg_forgot_password_request.h"
#include "../interface/widgets/dialogs/dlg_forgot_password_reset.h"
@ -99,6 +100,10 @@ void ConnectionController::wireClientSignals()
connect(remoteClient, &RemoteClient::sigPasswordVerifierReady, this,
&ConnectionController::onPasswordVerifierReady);
connect(remoteClient, &RemoteClient::sigPasswordChangeSuccess, this,
&ConnectionController::onPasswordChangeSuccess);
connect(remoteClient, &RemoteClient::sigPasswordChangeError, this, &ConnectionController::onPasswordChangeError);
}
void ConnectionController::connectToServer()
@ -351,10 +356,16 @@ void ConnectionController::onLoginError(int r,
}
case Response::RespPasswordChangeRequired: {
QMessageBox::information(
dialogParent, tr("Password Change Required"),
tr("An administrator has reset your password. Please contact your server administrator to obtain "
"your temporary password, then log in and change it via Account -> Change Password."));
// The credentials were right, but the account may not be used until a new password
// is chosen. Let the user complete the change here: sending them off to "ask the
// administrator for your temporary password" sends them in circles, since they just
// logged in with it and the session ends without a way to comply.
DlgEditPassword dlg(dialogParent);
dlg.setWindowTitle(tr("Password Change Required"));
if (dlg.exec()) {
remoteClient->changePasswordToServer(dlg.getOldPassword(), dlg.getNewPassword());
return; // onPasswordChangeSuccess/onPasswordChangeError decides the next step
}
remoteClient->disconnectFromServer();
return;
}
@ -517,6 +528,43 @@ void ConnectionController::onNotifyUserAboutUpdate()
"To update your client, go to Help -> Check for Updates."));
}
void ConnectionController::onPasswordChangeSuccess()
{
// The credential saved for this profile is stale now: drop it so the next login asks for
// the new password instead of replaying the old verifier.
if (!pendingSaveName.isEmpty()) {
SettingsCache::instance().servers().setServerPassword(pendingSaveName, "");
}
QMessageBox::information(dialogParent, tr("Password Changed"),
tr("Your password has been changed. Please log in again with your new password."));
remoteClient->disconnectFromServer();
connectToServer();
}
void ConnectionController::onPasswordChangeError(int responseCode)
{
QString reason;
switch (static_cast<Response::ResponseCode>(responseCode)) {
case Response::RespWrongPassword:
reason = tr("The current password is incorrect.");
break;
case Response::RespPasswordTooShort:
reason = tr("The new password is too short.");
break;
case Response::RespClientUpdateRequired:
reason = tr("The server does not accept the new password format.");
break;
default:
reason = tr("The password could not be changed (error %1).").arg(responseCode);
break;
}
QMessageBox::critical(dialogParent, tr("Password Change Failed"), reason);
remoteClient->disconnectFromServer();
connectToServer();
}
void ConnectionController::onForgotPasswordSuccess()
{
QMessageBox::information(

View file

@ -94,6 +94,10 @@ private slots:
// Persists the derived scrypt verifier after a successful challenge-response login
void onPasswordVerifierReady(const QString &verifier);
// Completes the forced password change the server demanded at login
void onPasswordChangeSuccess();
void onPasswordChangeError(int responseCode);
private:
void wireClientSignals();
void updateWindowTitle();

View file

@ -11,6 +11,27 @@
class QObject;
namespace
{
/**
* @brief Visits every filter item below @p node, depth first.
*/
template <typename Callback> void forEachFilter(const FilterTreeNode *node, const Callback &callback)
{
if (node == nullptr) {
return;
}
if (const auto *filterItem = dynamic_cast<const FilterItem *>(node)) {
callback(filterItem);
}
for (int i = 0; i < node->childCount(); ++i) {
forEachFilter(node->nodeAt(i), callback);
}
}
} // namespace
FilterTreeModel::FilterTreeModel(QObject *parent) : QAbstractItemModel(parent)
{
fTree = new FilterTree;
@ -110,46 +131,31 @@ void FilterTreeModel::clearFiltersOfType(CardFilter::Attr filterType)
QList<const CardFilter *> FilterTreeModel::getFiltersOfType(CardFilter::Attr filterType) const
{
QList<const CardFilter *> filters;
if (!fTree) {
return filters;
}
std::function<void(const FilterTreeNode *)> traverse;
traverse = [&](const FilterTreeNode *node) {
if (const auto *filterItem = dynamic_cast<const FilterItem *>(node)) {
if (filterItem->attr() == filterType) {
QString text = filterItem->text();
filters.append(new CardFilter(text, filterItem->type(), filterItem->attr()));
}
forEachFilter(fTree, [&](const FilterItem *filterItem) {
if (filterItem->attr() == filterType) {
filters.append(new CardFilter(filterItem->text(), filterItem->type(), filterItem->attr()));
}
for (int i = 0; i < node->childCount(); ++i) {
traverse(node->nodeAt(i));
}
};
traverse(fTree);
});
return filters;
}
QStringList FilterTreeModel::getFilterTermsOfType(CardFilter::Attr filterType) const
{
QStringList terms;
forEachFilter(fTree, [&](const FilterItem *filterItem) {
if (filterItem->attr() == filterType) {
terms.append(filterItem->text());
}
});
return terms;
}
QList<const CardFilter *> FilterTreeModel::allFilters() const
{
QList<const CardFilter *> filters;
if (!fTree) {
return filters;
}
std::function<void(const FilterTreeNode *)> traverse;
traverse = [&](const FilterTreeNode *node) {
if (const auto *filterItem = dynamic_cast<const FilterItem *>(node)) {
QString text = filterItem->text();
filters.append(new CardFilter(text, filterItem->type(), filterItem->attr()));
}
for (int i = 0; i < node->childCount(); ++i) {
traverse(node->nodeAt(i));
}
};
traverse(fTree);
forEachFilter(fTree, [&](const FilterItem *filterItem) {
filters.append(new CardFilter(filterItem->text(), filterItem->type(), filterItem->attr()));
});
return filters;
}

View file

@ -28,7 +28,14 @@ public slots:
void addFilter(const CardFilter *f);
void removeFilter(const CardFilter *f);
void clearFiltersOfType(CardFilter::Attr filterType);
/**
* @brief Copies every active filter of one attribute into new CardFilter objects.
*
* The caller owns the returned filters. Prefer getFilterTermsOfType() when only
* the terms are needed.
*/
[[nodiscard]] QList<const CardFilter *> getFiltersOfType(CardFilter::Attr filterType) const;
/// @brief Copies every active filter into new CardFilter objects. The caller owns them.
[[nodiscard]] QList<const CardFilter *> allFilters() const;
private slots:
@ -48,6 +55,13 @@ public:
{
return fTree;
}
/**
* @brief The texts of every active filter of one attribute, without allocating filters.
*
* Prefer this over getFiltersOfType() when only the filter terms are needed.
*/
[[nodiscard]] QStringList getFilterTermsOfType(CardFilter::Attr filterType) const;
[[nodiscard]] int rowCount(const QModelIndex &parent = QModelIndex()) const override;
[[nodiscard]] int columnCount(const QModelIndex & /*parent*/ = QModelIndex()) const override;
[[nodiscard]] QVariant data(const QModelIndex &index, int role) const override;

View file

@ -7,6 +7,7 @@
#include <QBrush>
#include <QColor>
#include <QCursor>
#include <QGraphicsScene>
#include <QGraphicsSceneMouseEvent>
#include <QPainter>
#include <QPainterPath>
@ -51,7 +52,20 @@ AbstractCardDragItem::AbstractCardDragItem(AbstractCardItem *_item,
update();
});
connect(item, &QObject::destroyed, this, &AbstractCardDragItem::deleteLater);
// The source card can be destroyed mid-drag (e.g. moved by a server event). deleteLater() alone is not enough:
// a scene repaint can run before the deferred delete and paint the dead card. Detach from the scene immediately.
connect(_item, &QObject::destroyed, this, [this]() {
hide();
if (QGraphicsScene *sc = scene()) {
sc->removeItem(this);
}
deleteLater();
});
}
AbstractCardItem *AbstractCardDragItem::getItem() const
{
return item.data();
}
QPainterPath AbstractCardDragItem::shape() const
@ -65,6 +79,10 @@ QPainterPath AbstractCardDragItem::shape() const
void AbstractCardDragItem::paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget)
{
if (item.isNull()) {
return;
}
item->paint(painter, option, widget);
// adds a mask to the card so it looks like the card hasnt been placed yet

View file

@ -14,6 +14,7 @@
#include <QList>
#include <QObject>
#include <QPointF>
#include <QPointer>
#include <QRectF>
#include <qtmetamacros.h>
@ -24,7 +25,8 @@ class AbstractCardDragItem : public QObject, public QGraphicsItem
Q_OBJECT
Q_INTERFACES(QGraphicsItem)
protected:
AbstractCardItem *item;
/// Guarded pointer: the source card may be destroyed while this drag item still exists.
QPointer<AbstractCardItem> item;
QPointF hotSpot;
QList<AbstractCardDragItem *> childDrags;
@ -44,10 +46,8 @@ public:
}
[[nodiscard]] QPainterPath shape() const override;
void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) override;
[[nodiscard]] AbstractCardItem *getItem() const
{
return item;
}
/// @return the source card, or nullptr if it has already been destroyed.
[[nodiscard]] AbstractCardItem *getItem() const;
[[nodiscard]] QPointF getHotSpot() const
{
return hotSpot;

View file

@ -130,8 +130,9 @@ void CardDragItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event)
sc->removeItem(this);
QList<CardDragItem *> dragItemList;
CardZoneLogic *startZone = static_cast<CardItem *>(item)->getZone();
if (currentZone && !(static_cast<CardItem *>(item)->getAttachedTo() && (startZone == currentZone->getLogic()))) {
CardZoneLogic *startZone = static_cast<CardItem *>(item.data())->getZone();
if (currentZone &&
!(static_cast<CardItem *>(item.data())->getAttachedTo() && (startZone == currentZone->getLogic()))) {
if (!occupied) {
dragItemList.append(this);
}
@ -139,7 +140,7 @@ void CardDragItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event)
for (int i = 0; i < childDrags.size(); i++) {
CardDragItem *c = static_cast<CardDragItem *>(childDrags[i]);
if (!occupied &&
!(static_cast<CardItem *>(c->item)->getAttachedTo() && (startZone == currentZone->getLogic())) &&
!(static_cast<CardItem *>(c->item.data())->getAttachedTo() && (startZone == currentZone->getLogic())) &&
!c->occupied) {
dragItemList.append(c);
}

View file

@ -77,7 +77,7 @@ void DeckViewCardDragItem::updatePosition(const QPointF &cursorScenePos)
void DeckViewCardDragItem::handleDrop(DeckViewCardContainer *target)
{
auto *card = static_cast<DeckViewCard *>(item);
auto *card = static_cast<DeckViewCard *>(item.data());
auto *start = static_cast<DeckViewCardContainer *>(item->parentItem());
start->removeCard(card);
target->addCard(card);

View file

@ -238,6 +238,7 @@ void CardPictureLoader::imageLoaded(const ExactCard &card, const QImage &image)
});
card.emitPixmapUpdated();
emit getInstance().pixmapLoaded(card);
}
void CardPictureLoader::deleteAllLocalOverrides(const ExactCard &card)

View file

@ -146,6 +146,14 @@ signals:
/** @brief Emitted after the worker has finished clearing the network and redirect caches. */
void networkCacheCleared();
/**
* @brief Emitted once an image has been loaded and put into the pixmap cache.
*
* Views that paint cards from the loader without owning a widget per card can
* listen to this to repaint the rows whose picture just became available.
*/
void pixmapLoaded(const ExactCard &card);
private slots:
/**
* @brief Triggered when the user changes the picture download settings.

View file

@ -27,9 +27,9 @@ void IntentGetLoginCredentials::onPreconditionSatisfied()
ServersSettings &servers = SettingsCache::instance().servers();
const int index = servers.findServerIndex(context->hostname, context->port);
if (index >= 0) {
context->username = servers.getValue(QString("username%1").arg(index), "server", "server_details").toString();
context->password = servers.getValue(QString("password%1").arg(index), "server", "server_details").toString();
if (const auto server = servers.getServer(index)) {
context->username = server->username;
context->password = server->password;
emitFinished();
} else {
emitFailed(tr("No saved credentials for this server"));
@ -69,9 +69,8 @@ void IntentGetLoginCredentials::onPreconditionNotSatisfied()
// it with the raw hostname when addNewServer updates the entry in place.
QString saveName = context->hostname;
const int existingIndex = servers.findServerIndex(context->hostname, context->port);
if (existingIndex >= 0) {
saveName =
servers.getValue(QString("saveName%1").arg(existingIndex), "server", "server_details").toString();
if (const auto existing = servers.getServer(existingIndex)) {
saveName = existing->saveName;
if (saveName.isEmpty()) {
saveName = context->hostname;
}

View file

@ -418,12 +418,10 @@ void IntentUrlParser::restoreToPreviousServer(const PendingIntentChain &chain)
ServersSettings &servers = SettingsCache::instance().servers();
const int index = servers.findServerIndex(chain.previousServerHost, chain.previousServerPort);
if (index >= 0 && servers.hasLoginData(chain.previousServerHost, chain.previousServerPort)) {
const QString username =
servers.getValue(QString("username%1").arg(index), "server", "server_details").toString();
const QString password =
servers.getValue(QString("password%1").arg(index), "server", "server_details").toString();
client->connectToServer(chain.previousServerHost, chain.previousServerPort.toUInt(), username, password);
const auto server = servers.getServer(index);
if (server && servers.hasLoginData(chain.previousServerHost, chain.previousServerPort)) {
client->connectToServer(chain.previousServerHost, chain.previousServerPort.toUInt(), server->username,
server->password);
return;
}
client->disconnectFromServer();
@ -438,11 +436,9 @@ void IntentUrlParser::restoreToPreviousServer(const PendingIntentChain &chain)
// when credentials are saved, otherwise stay offline.
ServersSettings &servers = SettingsCache::instance().servers();
const int index = servers.findServerIndex(chain.previousServerHost, chain.previousServerPort);
if (index >= 0 && servers.hasLoginData(chain.previousServerHost, chain.previousServerPort)) {
const QString username =
servers.getValue(QString("username%1").arg(index), "server", "server_details").toString();
const QString password =
servers.getValue(QString("password%1").arg(index), "server", "server_details").toString();
client->connectToServer(chain.previousServerHost, chain.previousServerPort.toUInt(), username, password);
const auto server = servers.getServer(index);
if (server && servers.hasLoginData(chain.previousServerHost, chain.previousServerPort)) {
client->connectToServer(chain.previousServerHost, chain.previousServerPort.toUInt(), server->username,
server->password);
}
}

View file

@ -43,11 +43,6 @@
#include <qlogging.h>
#include <qnamespace.h>
static constexpr qreal MTG_CARD_ASPECT_RATIO = 1.396;
// static constexpr qreal YUGIOH_CARD_ASPECT_RATIO = 1.457;
static constexpr qreal ASPECT_RATIO = MTG_CARD_ASPECT_RATIO;
static constexpr int BASE_WIDTH = 200;
static constexpr int BASE_HEIGHT = 200;
static constexpr int ENLARGED_PIXMAP_OFFSET = 10;
@ -162,12 +157,11 @@ void CardInfoPictureWidget::resizeEvent(QResizeEvent *event)
*/
void CardInfoPictureWidget::setScaleFactor(const int scale)
{
const int newWidth = BASE_WIDTH * scale / 100;
const int newHeight = static_cast<int>(newWidth * ASPECT_RATIO);
const QSize newSize = sizeForScaleFactor(scale);
scaleFactor = scale;
setFixedSize(newWidth, newHeight);
setFixedSize(newSize);
updatePixmap();
emit cardScaleFactorChanged(scale);
@ -266,8 +260,13 @@ void CardInfoPictureWidget::paintEvent(QPaintEvent *event)
*/
QSize CardInfoPictureWidget::sizeHint() const
{
return {static_cast<int>(BASE_WIDTH * scaleFactor / 100.0),
static_cast<int>(BASE_WIDTH * scaleFactor / 100.0 * ASPECT_RATIO)};
return sizeForScaleFactor(scaleFactor);
}
QSize CardInfoPictureWidget::sizeForScaleFactor(int scaleFactor)
{
const int width = BASE_WIDTH * scaleFactor / 100;
return {width, static_cast<int>(width * ASPECT_RATIO)};
}
/**

View file

@ -28,6 +28,11 @@ class CardInfoPictureWidget : public QWidget
Q_OBJECT
public:
/// Width of a card picture at 100 % scale, in pixels.
static constexpr int BASE_WIDTH = 200;
/// Height / width of a card picture.
static constexpr qreal ASPECT_RATIO = 1.396;
explicit CardInfoPictureWidget(QWidget *parent = nullptr,
bool hoverToZoomEnabled = false,
bool raiseOnEnter = false);
@ -37,6 +42,12 @@ public:
}
[[nodiscard]] QSize sizeHint() const override;
/**
* @brief The size a card picture has at @p scaleFactor percent.
* @param scaleFactor Percentage of BASE_WIDTH, as set by the card size slider.
*/
[[nodiscard]] static QSize sizeForScaleFactor(int scaleFactor);
public slots:
void setCard(const ExactCard &card);
void setScaleFactor(int scale); // New slot for scaling

View file

@ -0,0 +1,83 @@
#include "card_picture_delegate.h"
#include "../../../client/settings/cache_settings.h"
#include "../../card_picture_loader/card_picture_loader.h"
#include "card_art_utils.h"
#include "card_database_visual_display_model.h"
#include "libcockatrice/card/printing/exact_card.h"
#include "libcockatrice/settings/cards_display_settings.h"
#include <QPainter>
#include <QPainterPath>
#include <QPixmap>
#include <QRect>
#include <QStyleOptionViewItem>
#include <libcockatrice/card/printing/printing_info.h>
CardPictureDelegate::CardPictureDelegate(CardDatabaseVisualDisplayModel *_model, QObject *parent)
: QStyledItemDelegate(parent), model(_model)
{
}
void CardPictureDelegate::setCardSize(const QSize &size)
{
if (cardSize == size) {
return;
}
cardSize = size;
}
void CardPictureDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const
{
if (model == nullptr) {
return;
}
const ExactCard card = model->cardAt(index.row());
if (!card) {
return;
}
// The grid cell is the card plus SPACING; give the picture the rest.
const int inset = SPACING / 2;
const QRect cardArea = option.rect.adjusted(inset, inset, -inset, -inset);
QPixmap pixmap;
CardPictureLoader::getCardBackLoadingInProgressPixmap(pixmap, cardArea.size());
CardPictureLoader::getPixmap(pixmap, card, cardArea.size());
if (pixmap.isNull()) {
return;
}
if (SettingsCache::instance().cardsDisplay().getAutoRotateSidewaysLayoutCards()) {
pixmap = CardArtUtils::rotateSidewaysLayoutArt(pixmap, card);
}
// Fit the picture into the cell and center it, like CardInfoPictureWidget does.
const QSize pictureSize = pixmap.deviceIndependentSize().toSize().scaled(cardArea.size(), Qt::KeepAspectRatio);
const QRect pictureRect(cardArea.center().x() - pictureSize.width() / 2,
cardArea.center().y() - pictureSize.height() / 2, pictureSize.width(),
pictureSize.height());
const qreal radius = SettingsCache::instance().cardsDisplay().getRoundCardCorners()
? 0.05 * static_cast<qreal>(pictureRect.width())
: 0.0;
painter->save();
painter->setRenderHint(QPainter::Antialiasing);
painter->setRenderHint(QPainter::SmoothPixmapTransform);
QPainterPath shape;
shape.addRoundedRect(pictureRect, radius, radius);
painter->setClipPath(shape);
// Source rect is in device pixels, target in logical ones: drawPixmap scales
// between them without the device pixel ratio being applied twice.
painter->drawPixmap(pictureRect, pixmap, QRectF(pixmap.rect()));
painter->restore();
}
QSize CardPictureDelegate::sizeHint(const QStyleOptionViewItem & /*option*/, const QModelIndex & /*index*/) const
{
return cardSize + QSize(SPACING, SPACING);
}

View file

@ -0,0 +1,45 @@
/**
* @file card_picture_delegate.h
* @ingroup CardWidgets
* @brief Paints a card picture for a visual card grid item.
*/
#ifndef CARD_PICTURE_DELEGATE_H
#define CARD_PICTURE_DELEGATE_H
#include <QSize>
#include <QStyledItemDelegate>
#include <qtmetamacros.h>
class CardDatabaseVisualDisplayModel;
/**
* @brief Draws the card of a CardDatabaseVisualDisplayModel row as a picture.
*
* The delegate only paints rows the view asks for, so a grid of the whole card
* database stays cheap: no widget per card and no pixmap loaded for items that
* are off screen. Card images come from CardPictureLoader, which caches them and
* loads missing ones asynchronously.
*/
class CardPictureDelegate : public QStyledItemDelegate
{
Q_OBJECT
public:
/// Gap left around a card picture inside its grid cell, in pixels.
static constexpr int SPACING = 4;
explicit CardPictureDelegate(CardDatabaseVisualDisplayModel *_model, QObject *parent = nullptr);
/// The picture size inside a grid cell. The cell is this size plus SPACING.
void setCardSize(const QSize &size);
void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override;
[[nodiscard]] QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const override;
private:
CardDatabaseVisualDisplayModel *model;
QSize cardSize;
};
#endif // CARD_PICTURE_DELEGATE_H

View file

@ -2,6 +2,7 @@
#include "../general/layout_containers/flow_widget.h"
#include <QAbstractScrollArea>
#include <QEvent>
#include <QFlags>
#include <QHBoxLayout>
@ -33,8 +34,8 @@ CardSizeWidget::CardSizeWidget(QWidget *parent, FlowWidget *_flowWidget, int def
cardSizeLabel->setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Minimum);
cardSizeSlider = new QSlider(Qt::Horizontal, this);
cardSizeSlider->setRange(50, 250); ///< Slider range for card size adjustment.
cardSizeSlider->setValue(defaultValue); ///< Initial slider value.
cardSizeSlider->setRange(MIN_CARD_SIZE, MAX_CARD_SIZE); ///< Slider range for card size adjustment.
cardSizeSlider->setValue(defaultValue); ///< Initial slider value.
cardSizeLayout->addWidget(cardSizeLabel);
cardSizeLayout->addWidget(cardSizeSlider);
@ -73,13 +74,26 @@ QSlider *CardSizeWidget::getSlider() const
void CardSizeWidget::enableCtrlScrollResize(QWidget *host)
{
host->installEventFilter(this);
const auto scrollAreas = host->findChildren<QScrollArea *>();
for (QScrollArea *scrollArea : scrollAreas) {
if (QWidget *content = scrollArea->widget()) {
content->installEventFilter(this);
const auto watchScrollArea = [this](QAbstractScrollArea *scrollArea) {
if (scrollArea == nullptr) {
return;
}
if (QWidget *viewport = scrollArea->viewport()) {
viewport->installEventFilter(this);
}
if (auto *contentScrollArea = qobject_cast<QScrollArea *>(scrollArea)) {
if (QWidget *content = contentScrollArea->widget()) {
content->installEventFilter(this);
}
}
};
host->installEventFilter(this);
watchScrollArea(qobject_cast<QAbstractScrollArea *>(host));
const auto scrollAreas = host->findChildren<QAbstractScrollArea *>();
for (QAbstractScrollArea *scrollArea : scrollAreas) {
watchScrollArea(scrollArea);
}
}

View file

@ -24,6 +24,10 @@ class CardSizeWidget : public QWidget
Q_OBJECT
public:
/// Range of the card size slider, as a percentage of the base card width.
static constexpr int MIN_CARD_SIZE = 50;
static constexpr int MAX_CARD_SIZE = 250;
explicit CardSizeWidget(QWidget *parent, FlowWidget *flowWidget = nullptr, int defaultValue = 100);
[[nodiscard]] QSlider *getSlider() const;
@ -31,8 +35,9 @@ public:
* @brief Resizes the cards when the user Ctrl + scrolls over @p host or any of its descendants.
*
* Installs this widget as an event filter on the given host widget. If the host contains a
* scroll area the filter is also installed on the scroll area's content widget, so the resize
* intercepts the wheel event before the scroll area would scroll the view.
* scroll area the filter is also installed on the scroll area's content widget, and on the
* viewport of every scroll area base (QListView, QTableView, ...), so the resize intercepts
* the wheel event before the scroll area would scroll the view.
*/
void enableCtrlScrollResize(QWidget *host);

View file

@ -1,6 +1,6 @@
#include "replay_event_index.h"
#include "../../../game/phase.h"
#include <libcockatrice/game/phase.h>
#include "libcockatrice/protocol/get_pb_extension.h"
#include "libcockatrice/protocol/pb/card_attributes.pb.h"
#include "libcockatrice/protocol/pb/context_concede.pb.h"

View file

@ -32,7 +32,6 @@
#include <libcockatrice/protocol/pb/serverinfo_user.pb.h>
#include <libcockatrice/protocol/pb/session_commands.pb.h>
#include <libcockatrice/protocol/pending_command.h>
#include <libcockatrice/utility/passwordhasher.h>
#include <libcockatrice/utility/qt_utils.h>
#include <string>
#include <sys/types.h>
@ -299,17 +298,7 @@ void UserInfoBox::actPassword()
void UserInfoBox::changePassword(const QString &oldPassword, const QString &newPassword)
{
Command_AccountPassword cmd;
cmd.set_old_password(oldPassword.toStdString());
if (client->getServerSupportsChallengeResponse()) {
cmd.set_hashed_new_password(PasswordHasher::generatePasswordVerifier(newPassword).toStdString());
} else if (client->getServerSupportsPasswordHash()) {
auto passwordSalt = PasswordHasher::generateRandomSalt();
QString hashedPassword = PasswordHasher::computeHash(newPassword, passwordSalt);
cmd.set_hashed_new_password(hashedPassword.toStdString());
} else {
cmd.set_new_password(newPassword.toStdString());
}
const Command_AccountPassword cmd = client->makeAccountPasswordCommand(oldPassword, newPassword);
PendingCommand *pend = client->prepareSessionCommand(cmd);
connect(pend, &PendingCommand::finished, this, &UserInfoBox::processPasswordResponse);

View file

@ -29,19 +29,10 @@ QMap<QString, std::pair<QString, UserConnection_Information>> UserConnection_Inf
ServersSettings &servers = SettingsCache::instance().servers();
int size = servers.getValue("totalServers", "server", "server_details").toInt() + 1;
for (int i = 0; i < size; i++) {
QString _saveName = servers.getValue(QString("saveName%1").arg(i), "server", "server_details").toString();
QString serverName = servers.getValue(QString("server%1").arg(i), "server", "server_details").toString();
QString portNum = servers.getValue(QString("port%1").arg(i), "server", "server_details").toString();
QString userName = servers.getValue(QString("username%1").arg(i), "server", "server_details").toString();
QString pass = servers.getValue(QString("password%1").arg(i), "server", "server_details").toString();
bool savePass = servers.getValue(QString("savePassword%1").arg(i), "server", "server_details").toBool();
QString _site = servers.getValue(QString("site%1").arg(i), "server", "server_details").toString();
UserConnection_Information userInfo(_saveName, serverName, portNum, userName, pass, savePass, _site);
serverList.insert(_saveName, std::make_pair(serverName, userInfo));
for (const auto &entry : servers.getServers()) {
UserConnection_Information userInfo(entry.saveName, entry.host, entry.port, entry.username, entry.password,
entry.savePassword, entry.site);
serverList.insert(entry.saveName, std::make_pair(entry.host, userInfo));
}
return serverList;
@ -53,28 +44,18 @@ QStringList UserConnection_Information::getServerInfo(const QString &find)
ServersSettings &servers = SettingsCache::instance().servers();
int size = servers.getValue("totalServers", "server", "server_details").toInt() + 1;
for (int i = 0; i < size; i++) {
QString _saveName = servers.getValue(QString("saveName%1").arg(i), "server", "server_details").toString();
if (find != _saveName) {
for (const auto &entry : servers.getServers()) {
if (find != entry.saveName) {
continue;
}
QString serverName = servers.getValue(QString("server%1").arg(i), "server", "server_details").toString();
QString portNum = servers.getValue(QString("port%1").arg(i), "server", "server_details").toString();
QString userName = servers.getValue(QString("username%1").arg(i), "server", "server_details").toString();
QString pass = servers.getValue(QString("password%1").arg(i), "server", "server_details").toString();
bool savePass = servers.getValue(QString("savePassword%1").arg(i), "server", "server_details").toBool();
QString _site = servers.getValue(QString("site%1").arg(i), "server", "server_details").toString();
_server.append(_saveName);
_server.append(serverName);
_server.append(portNum);
_server.append(userName);
_server.append(pass);
_server.append(savePass ? "1" : "0");
_server.append(_site);
_server.append(entry.saveName);
_server.append(entry.host);
_server.append(entry.port);
_server.append(entry.username);
_server.append(entry.password);
_server.append(entry.savePassword ? "1" : "0");
_server.append(entry.site);
break;
}

View file

@ -13,6 +13,7 @@
#include <QMessageBox>
#include <QPushButton>
#include <QRadioButton>
#include <QScrollBar>
#include <QSharedPointer>
#include <QSizePolicy>
#include <QSpinBox>
@ -21,6 +22,7 @@
#include <QTableWidgetItem>
#include <QVBoxLayout>
#include <QWidget>
#include <QtGlobal>
#include <libcockatrice/network/client/abstract/abstract_client.h>
#include <libcockatrice/protocol/pb/moderator_commands.pb.h>
#include <libcockatrice/protocol/pb/response_viewlog_history.pb.h>
@ -31,6 +33,17 @@
class TabSupervisor;
namespace
{
/** @brief Identifies the newest row of a page well enough to recognise a repeated page. */
QString pageStartKey(const Response_ViewLogHistory &response)
{
const ServerInfo_ChatMessage &first = response.log_message(0);
return QString::fromStdString(first.time()) + QChar(0x1f) + QString::fromStdString(first.sender_name()) +
QChar(0x1f) + QString::fromStdString(first.message());
}
} // namespace
TabLog::TabLog(TabSupervisor *_tabSupervisor, AbstractClient *_client, bool _canUseDeveloperCommands)
: Tab(_tabSupervisor), client(_client), canUseDeveloperCommands(_canUseDeveloperCommands)
{
@ -52,12 +65,20 @@ TabLog::TabLog(TabSupervisor *_tabSupervisor, AbstractClient *_client, bool _can
chatTable->setHorizontalHeaderLabels(
QString(tr("Time;SenderName;SenderIP;Message;TargetID;TargetName")).split(";"));
QTabWidget *tabManager = new QTabWidget();
tabManager = new QTabWidget();
tabManager->addTab(roomTable, tr("Room Logs"));
tabManager->addTab(gameTable, tr("Game Logs"));
tabManager->addTab(chatTable, tr("Chat Logs"));
setCentralWidget(tabManager);
connect(roomTable->verticalScrollBar(), &QScrollBar::valueChanged, this, &TabLog::checkFetchMore);
connect(gameTable->verticalScrollBar(), &QScrollBar::valueChanged, this, &TabLog::checkFetchMore);
connect(chatTable->verticalScrollBar(), &QScrollBar::valueChanged, this, &TabLog::checkFetchMore);
// A tab that gains only a few rows has nothing to scroll, so its scrollbar never fires:
// switching to it would leave its next page unfetched until the moderator scrolled
// another tab. Re-check on every tab switch so the visible table can continue the browse.
connect(tabManager, &QTabWidget::currentChanged, this, &TabLog::checkFetchMore);
createDock();
restartLayout();
clearClicked();
@ -74,12 +95,9 @@ void TabLog::retranslateUi()
void TabLog::getClicked()
{
if (findUsername->text().isEmpty() && findIPAddress->text().isEmpty() && findGameName->text().isEmpty() &&
findGameID->text().isEmpty() && findMessage->text().isEmpty()) {
QMessageBox::critical(this, tr("Error"), tr("You must select at least one filter."));
return;
}
// Empty filters are a valid browse: room/game history going back from
// today, paginated. Only require a date range + location, both of which
// get sensible defaults below so a bare "Get" just works.
if (!lastHour->isChecked() && !today->isChecked() && !pastDays->isChecked()) {
pastDays->setChecked(true);
pastXDays->setValue(20);
@ -99,39 +117,71 @@ void TabLog::getClicked()
}
if (maximumResults->value() == 0) {
maximumResults->setValue(1000);
maximumResults->setValue(200);
}
pageSize = qBound(1, maximumResults->value(), 1000);
int dateRange = 0;
if (lastHour->isChecked()) {
dateRange = 1;
}
if (today->isChecked()) {
dateRange = 24;
}
if (pastDays->isChecked()) {
dateRange = pastXDays->value() * 24;
}
Command_ViewLogHistory cmd;
cmd.set_user_name(findUsername->text().toStdString());
cmd.set_ip_address(findIPAddress->text().toStdString());
cmd.set_game_name(findGameName->text().toStdString());
cmd.set_game_id(findGameID->text().toStdString());
cmd.set_message(findMessage->text().toStdString());
// Build the command once, here, and let every page copy it: rebuilding it from the
// widgets per page would run page 2+ against filters edited after Get was clicked,
// appending rows selected by a different query under the first page's results.
currentQuery.Clear();
currentQuery.set_user_name(findUsername->text().toStdString());
currentQuery.set_ip_address(findIPAddress->text().toStdString());
currentQuery.set_game_name(findGameName->text().toStdString());
currentQuery.set_game_id(findGameID->text().toStdString());
currentQuery.set_message(findMessage->text().toStdString());
if (mainRoom->isChecked()) {
cmd.add_log_location("room");
};
currentQuery.add_log_location("room");
}
if (gameRoom->isChecked()) {
cmd.add_log_location("game");
};
currentQuery.add_log_location("game");
}
if (privateChat->isChecked()) {
cmd.add_log_location("chat");
};
cmd.set_date_range(dateRange);
cmd.set_maximum_results(maximumResults->value());
currentQuery.add_log_location("chat");
}
currentQuery.set_date_range(dateRangeForSelection());
currentQuery.set_maximum_results(pageSize);
hasCurrentQuery = true;
snapshotBefore.clear();
lastPageStartKey.clear();
// Fresh search: reset pagination state and clear previous pages.
currentOffset = 0;
roomCounter = 0;
gameCounter = 0;
chatCounter = 0;
roomTable->setRowCount(0);
gameTable->setRowCount(0);
chatTable->setRowCount(0);
fetchPage(0);
}
int TabLog::dateRangeForSelection() const
{
if (lastHour->isChecked()) {
return 1;
}
if (today->isChecked()) {
return 24;
}
if (pastDays->isChecked()) {
return pastXDays->value() * 24;
}
return 0;
}
void TabLog::fetchPage(int offset)
{
if (isFetching || !hasCurrentQuery) {
return;
}
Command_ViewLogHistory cmd = currentQuery;
cmd.set_offset(offset);
if (!snapshotBefore.isEmpty()) {
cmd.set_before_time(snapshotBefore.toStdString());
}
PendingCommand *pend;
if (canUseDeveloperCommands) {
@ -141,10 +191,42 @@ void TabLog::getClicked()
pend = client->prepareModeratorCommand(cmd);
}
isFetching = true;
getButton->setEnabled(false);
connect(pend, &PendingCommand::finished, this, &TabLog::viewLogHistory_processResponse);
client->sendCommand(pend);
}
void TabLog::checkFetchMore()
{
if (isFetching || !hasMoreResults) {
return;
}
// Only the table the moderator is looking at may pull the next page. Checking all
// three would make an empty or hidden table nearBottom by definition (maximum() and
// value() are both zero), so a room-only browse chained page after page without any
// scrolling - pulling the whole range with a growing OFFSET scan per page, and
// looping forever against a server that ignores `offset`.
auto *table = qobject_cast<QTableWidget *>(tabManager->currentWidget());
if (table == nullptr || table->rowCount() == 0) {
return;
}
// ScrollPerItem (the default) counts rows, not pixels: `value` is the top visible row
// and `maximum` the last one that can be scrolled to. Prefetch half a page ahead so
// scrolling never visibly stalls, without the fixed 300-row threshold firing the next
// page the moment a page loads.
const QScrollBar *bar = table->verticalScrollBar();
const int prefetchRows = qMax(1, pageSize / 2);
if (bar->maximum() - bar->value() > prefetchRows) {
return;
}
fetchPage(currentOffset);
}
void TabLog::clearClicked()
{
findUsername->clear();
@ -166,6 +248,10 @@ void TabLog::clearClicked()
pastDays->setAutoExclusive(true);
today->setAutoExclusive(true);
lastHour->setAutoExclusive(true);
currentOffset = 0;
hasMoreResults = false;
hasCurrentQuery = false;
snapshotBefore.clear();
}
void TabLog::createDock()
@ -209,14 +295,15 @@ void TabLog::createDock()
pastXDays = new QSpinBox;
pastXDays->setMaximum(20);
labelMaximum = new QLabel(tr("Maximum Results: "));
labelMaximum = new QLabel(tr("Results per page: "));
maximumResults = new QSpinBox;
maximumResults->setMaximum(1000);
maximumResults->setToolTip(tr("Page size for smooth scrolling: more rows load automatically as you scroll."));
labelDescription = new QLabel(tr(
"At least one filter is required.\nThe more information you put in, the more specific your results will be."));
labelDescription = new QLabel(tr("Leave filters empty to browse recent room and game messages.\n"
"More rows load automatically as you scroll."));
getButton = new QPushButton(tr("Get User Logs"));
getButton = new QPushButton(tr("Get Logs"));
getButton->setAutoDefault(true);
connect(getButton, &QPushButton::clicked, this, &TabLog::getClicked);
@ -262,7 +349,7 @@ void TabLog::createDock()
maxResultsGrid->addWidget(labelMaximum, 0, 0);
maxResultsGrid->addWidget(maximumResults, 0, 1);
maxResultsGroupBox = new QGroupBox(tr("Maximum Results"));
maxResultsGroupBox = new QGroupBox(tr("Results per page"));
maxResultsGroupBox->setLayout(maxResultsGrid);
descriptionGrid = new QGridLayout;
@ -295,84 +382,134 @@ void TabLog::createDock()
searchDock->setWidget(searchDockContents);
}
void TabLog::appendChatMessage(QTableWidget *table,
int &counter,
const QString &time,
const QString &senderName,
const QString &senderIp,
const QString &messageText,
const QString &targetId,
const QString &targetName,
const QString &channel)
{
table->insertRow(counter);
table->setItem(counter, 0, new QTableWidgetItem(time));
table->setItem(counter, 1, new QTableWidgetItem(senderName));
table->setItem(counter, 2, new QTableWidgetItem(senderIp));
table->setItem(counter, 3, new QTableWidgetItem(messageText));
table->setItem(counter, 4, new QTableWidgetItem(targetId));
table->setItem(counter, 5, new QTableWidgetItem(targetName));
if (table->columnCount() > 6) {
table->setItem(counter, 6, new QTableWidgetItem(channel));
}
++counter;
}
void TabLog::viewLogHistory_processResponse(const Response &resp)
{
const Response_ViewLogHistory &response = resp.GetExtension(Response_ViewLogHistory::ext);
const bool isFirstPage = (currentOffset == 0);
isFetching = false;
getButton->setEnabled(true);
if (resp.response_code() != Response::RespOk) {
QMessageBox::critical(static_cast<QWidget *>(parent()), tr("Message History"),
tr("Failed to collect message history information."));
return;
}
if (response.log_message_size() == 0) {
if (isFirstPage && response.log_message_size() == 0) {
QMessageBox::information(static_cast<QWidget *>(parent()), tr("Message History"),
tr("There are no messages for the selected filters."));
hasMoreResults = false;
return;
}
int roomCounter = 0, gameCounter = 0, chatCounter = 0;
roomTable->setRowCount(roomCounter);
gameTable->setRowCount(gameCounter);
chatTable->setRowCount(chatCounter);
const QString thisPageStart = response.log_message_size() > 0 ? pageStartKey(response) : QString();
if (isFirstPage) {
// Rows come newest first, so the first one fixes the snapshot every later page is
// bounded by. Without it, each message logged between two page fetches pushes the
// window down by one and repeats a row the moderator already has.
snapshotBefore = QString::fromStdString(response.log_message(0).time());
} else if (!response.has_has_more() && !thisPageStart.isEmpty() && thisPageStart == lastPageStartKey) {
// A server that predates `offset` ignores it and returns the same page again. It
// also predates `has_more`, so the full-page heuristic below can never prove
// exhaustion and every scroll would append the same rows forever. The identical
// first row is the proof; stop instead of looping.
hasMoreResults = false;
return;
}
lastPageStartKey = thisPageStart;
for (int i = 0; i < response.log_message_size(); ++i) {
ServerInfo_ChatMessage message = response.log_message(i);
const QString targetType = QString::fromStdString(message.target_type());
if (targetType == "room" || targetType == "room_channel") {
roomTable->insertRow(roomCounter);
roomTable->setItem(roomCounter, 0, new QTableWidgetItem(QString::fromStdString(message.time())));
roomTable->setItem(roomCounter, 1, new QTableWidgetItem(QString::fromStdString(message.sender_name())));
roomTable->setItem(roomCounter, 2, new QTableWidgetItem(QString::fromStdString(message.sender_ip())));
roomTable->setItem(roomCounter, 3, new QTableWidgetItem(QString::fromStdString(message.message())));
roomTable->setItem(roomCounter, 4, new QTableWidgetItem(QString::fromStdString(message.target_id())));
roomTable->setItem(roomCounter, 5, new QTableWidgetItem(QString::fromStdString(message.target_name())));
roomTable->setItem(roomCounter, 6, new QTableWidgetItem(QString::fromStdString(message.channel())));
++roomCounter;
appendChatMessage(roomTable, roomCounter, QString::fromStdString(message.time()),
QString::fromStdString(message.sender_name()),
QString::fromStdString(message.sender_ip()), QString::fromStdString(message.message()),
QString::fromStdString(message.target_id()),
QString::fromStdString(message.target_name()), QString::fromStdString(message.channel()));
}
if (targetType == "game") {
gameTable->insertRow(gameCounter);
gameTable->setItem(gameCounter, 0, new QTableWidgetItem(QString::fromStdString(message.time())));
gameTable->setItem(gameCounter, 1, new QTableWidgetItem(QString::fromStdString(message.sender_name())));
gameTable->setItem(gameCounter, 2, new QTableWidgetItem(QString::fromStdString(message.sender_ip())));
gameTable->setItem(gameCounter, 3, new QTableWidgetItem(QString::fromStdString(message.message())));
gameTable->setItem(gameCounter, 4, new QTableWidgetItem(QString::fromStdString(message.target_id())));
gameTable->setItem(gameCounter, 5, new QTableWidgetItem(QString::fromStdString(message.target_name())));
++gameCounter;
appendChatMessage(gameTable, gameCounter, QString::fromStdString(message.time()),
QString::fromStdString(message.sender_name()),
QString::fromStdString(message.sender_ip()), QString::fromStdString(message.message()),
QString::fromStdString(message.target_id()),
QString::fromStdString(message.target_name()));
}
if (targetType == "chat") {
chatTable->insertRow(chatCounter);
chatTable->setItem(chatCounter, 0, new QTableWidgetItem(QString::fromStdString(message.time())));
chatTable->setItem(chatCounter, 1, new QTableWidgetItem(QString::fromStdString(message.sender_name())));
chatTable->setItem(chatCounter, 2, new QTableWidgetItem(QString::fromStdString(message.sender_ip())));
chatTable->setItem(chatCounter, 3, new QTableWidgetItem(QString::fromStdString(message.message())));
chatTable->setItem(chatCounter, 4, new QTableWidgetItem(QString::fromStdString(message.target_id())));
chatTable->setItem(chatCounter, 5, new QTableWidgetItem(QString::fromStdString(message.target_name())));
++chatCounter;
appendChatMessage(chatTable, chatCounter, QString::fromStdString(message.time()),
QString::fromStdString(message.sender_name()),
QString::fromStdString(message.sender_ip()), QString::fromStdString(message.message()),
QString::fromStdString(message.target_id()),
QString::fromStdString(message.target_name()));
}
}
currentOffset += response.log_message_size();
if (response.has_has_more()) {
hasMoreResults = response.has_more();
} else {
// Legacy server without has_more: a full page implies more may exist.
hasMoreResults = (response.log_message_size() >= pageSize);
}
if (roomCounter) {
roomTable->show();
roomTable->resizeColumnsToContents();
if (isFirstPage) {
roomTable->resizeColumnsToContents();
}
} else {
roomTable->hide();
}
if (gameCounter) {
gameTable->resizeColumnsToContents();
if (isFirstPage) {
gameTable->resizeColumnsToContents();
}
gameTable->show();
} else {
gameTable->hide();
}
if (chatCounter) {
chatTable->resizeColumnsToContents();
if (isFirstPage) {
chatTable->resizeColumnsToContents();
}
chatTable->show();
} else {
chatTable->hide();
}
// The new rows may already fill less than a viewport (or the user is
// still parked at the bottom), so chain the next page without waiting
// for another scroll event. The prefetch stays invisible: rows simply
// keep appearing as you scroll.
checkFetchMore();
}
void TabLog::restartLayout()

View file

@ -10,6 +10,7 @@
#include "tab.h"
#include <QString>
#include <libcockatrice/protocol/pb/moderator_commands.pb.h>
#include <qtmetamacros.h>
class AbstractClient;
@ -25,6 +26,7 @@ class QWidget;
class QGridLayout;
class QVBoxLayout;
class QTableWidget;
class QTabWidget;
class Response;
class TabSupervisor;
@ -48,8 +50,37 @@ private:
*buttonGroupBox;
QVBoxLayout *mainLayout;
QTableWidget *roomTable, *gameTable, *chatTable;
QTabWidget *tabManager = nullptr;
//! The filters of the search in flight, captured when Get was clicked so that
//! later pages cannot pick up edits the moderator made after starting it.
Command_ViewLogHistory currentQuery;
bool hasCurrentQuery = false;
//! Newest `log_time` of the first page, sent back as the upper bound of every
//! later page so messages logged in the meantime do not shift the window.
QString snapshotBefore;
//! Identity of the previous page's first row, used to spot a server that ignores
//! `offset` and hands back the same page forever.
QString lastPageStartKey;
int currentOffset = 0;
int pageSize = 200;
int roomCounter = 0, gameCounter = 0, chatCounter = 0;
bool isFetching = false;
bool hasMoreResults = false;
void createDock();
[[nodiscard]] int dateRangeForSelection() const;
void fetchPage(int offset);
void appendChatMessage(QTableWidget *table,
int &counter,
const QString &time,
const QString &senderName,
const QString &senderIp,
const QString &messageText,
const QString &targetId,
const QString &targetName,
const QString &channel = QString());
void checkFetchMore();
signals:
private slots:

View file

@ -0,0 +1,203 @@
#include "card_database_visual_view.h"
#include "../../card_picture_loader/card_picture_loader.h"
#include "../cards/card_info_picture_widget.h"
#include "../cards/card_picture_delegate.h"
#include "../cards/card_size_widget.h"
#include "card_database_visual_display_model.h"
#include "libcockatrice/card/printing/exact_card.h"
#include <QAbstractItemModel>
#include <QAbstractItemView>
#include <QApplication>
#include <QCursor>
#include <QEvent>
#include <QFrame>
#include <QModelIndex>
#include <QMouseEvent>
#include <QScrollBar>
#include <QSize>
#include <QWheelEvent>
#include <QWidget>
#include <QtGlobal>
#include <qnamespace.h>
CardDatabaseVisualView::CardDatabaseVisualView(CardDatabaseVisualDisplayModel *_model, QWidget *parent)
: QListView(parent), model(_model)
{
auto *cardDelegate = new CardPictureDelegate(model, this);
setItemDelegate(cardDelegate);
setModel(model);
setViewMode(QListView::IconMode);
setFlow(QListView::LeftToRight);
setWrapping(true);
setResizeMode(QListView::Adjust);
setMovement(QListView::Static);
setUniformItemSizes(true);
setLayoutMode(QListView::SinglePass);
setVerticalScrollMode(QAbstractItemView::ScrollPerPixel);
setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
setFrameShape(QFrame::NoFrame);
setWordWrap(false);
// QListView hands the delegate's item height to the scroll bar as its single
// step under ScrollPerPixel, so one wheel notch would jump wheelScrollLines()
// whole card rows. Give the bar a text line instead; the wheel handler below
// scales that line to the grid. A positive step also stops Qt from taking the
// item height back on the next layout.
verticalScrollBar()->setSingleStep(qMax(1, fontMetrics().height()));
// The grid is a read-only surface: clicks are actions on a card, not a
// selection, and they must not steal focus from the search field.
setSelectionMode(QAbstractItemView::NoSelection);
setEditTriggers(QAbstractItemView::NoEditTriggers);
setFocusPolicy(Qt::NoFocus);
// Hover drives the card preview.
setMouseTracking(true);
viewport()->setMouseTracking(true);
connect(&CardPictureLoader::getInstance(), &CardPictureLoader::pixmapLoaded, this,
[this] { viewport()->update(); });
// A new search result starts at the top, like the widget-per-card grid did.
connect(model, &QAbstractItemModel::modelReset, this, [this] {
verticalScrollBar()->setValue(0);
// The rows under a stationary cursor changed with the reset: re-resolve the
// hover so the preview follows the grid, as enterEvent did per card widget.
hoveredRow = -1;
updateHoveredRow(viewport()->mapFromGlobal(QCursor::pos()));
});
setCardSizeFactor(cardSizeFactor);
}
void CardDatabaseVisualView::setCardSizeFactor(int factor)
{
cardSizeFactor = qBound(CardSizeWidget::MIN_CARD_SIZE, factor, CardSizeWidget::MAX_CARD_SIZE);
const QSize cardSize = CardInfoPictureWidget::sizeForScaleFactor(cardSizeFactor);
if (auto *cardDelegate = qobject_cast<CardPictureDelegate *>(itemDelegate())) {
cardDelegate->setCardSize(cardSize);
}
// The delegate's sizeHint does not emit sizeHintChanged, so the grid size is what
// makes a resize take effect. Keep the two in step.
setGridSize(cardSize + QSize(CardPictureDelegate::SPACING, CardPictureDelegate::SPACING));
}
void CardDatabaseVisualView::mousePressEvent(QMouseEvent *event)
{
const QModelIndex index = indexAt(event->pos());
if (index.isValid() && model != nullptr) {
const ExactCard card = model->cardAt(index.row());
if (card) {
emit cardClicked(event, card);
event->accept();
return;
}
}
QListView::mousePressEvent(event);
}
void CardDatabaseVisualView::mouseMoveEvent(QMouseEvent *event)
{
QListView::mouseMoveEvent(event);
updateHoveredRow(event->pos());
}
bool CardDatabaseVisualView::viewportEvent(QEvent *event)
{
// A per-card widget reported its hover from enterEvent, which also fires when the
// grid changes under a stationary cursor. A QListView only reports moves, so
// re-resolve the hover on enter and clear it on leave.
if (event->type() == QEvent::Enter) {
updateHoveredRow(viewport()->mapFromGlobal(QCursor::pos()));
} else if (event->type() == QEvent::Leave) {
hoveredRow = -1;
}
return QListView::viewportEvent(event);
}
void CardDatabaseVisualView::wheelEvent(QWheelEvent *event)
{
// Ctrl+wheel is the card-size gesture and is filtered out before it reaches
// the view; any other modifier keeps QAbstractSlider's own page scrolling.
if (event->modifiers() != Qt::NoModifier) {
QListView::wheelEvent(event);
return;
}
QScrollBar *bar = verticalScrollBar();
if (!bar->isVisible()) {
event->ignore();
return;
}
// Touchpads and high-resolution wheels already report pixels: follow them.
if (!event->pixelDelta().isNull()) {
bar->setValue(bar->value() - event->pixelDelta().y());
event->accept();
return;
}
if (event->angleDelta().y() == 0) {
QListView::wheelEvent(event);
return;
}
// Accumulate wheel deltas smaller than a notch, like QAbstractSlider does, so
// a fine-grained wheel still moves.
wheelRemainder += static_cast<qreal>(event->angleDelta().y()) / 120.0;
const int notches = static_cast<int>(wheelRemainder);
if (notches == 0) {
event->accept();
return;
}
wheelRemainder -= notches;
const int pixels = -notches * wheelStep();
bar->setValue(qBound(bar->minimum(), bar->value() + pixels, bar->maximum()));
event->accept();
}
int CardDatabaseVisualView::wheelStep() const
{
// A card row is far too coarse for one wheel line, and a text line is too
// fine, so a line is an eighth of the grid cell. A notch then moves about a
// third of a card row at any card size, and the OS line count still applies.
const int lineHeight = qMax(1, gridSize().height() / 8);
return qMax(1, QApplication::wheelScrollLines()) * lineHeight;
}
void CardDatabaseVisualView::updateHoveredRow(const QPoint &position)
{
// indexAt() also answers for points outside the viewport, so a cursor parked
// below the grid (on the size slider, say) would resolve to the row just under
// the visible area. Only a point inside the viewport can hover a card.
if (model == nullptr || !viewport()->rect().contains(position)) {
return;
}
const QModelIndex index = indexAt(position);
const int row = index.isValid() ? index.row() : -1;
if (row == hoveredRow) {
return;
}
hoveredRow = row;
if (row >= 0) {
const ExactCard card = model->cardAt(row);
if (card) {
emit cardHovered(card);
}
}
}
void CardDatabaseVisualView::leaveEvent(QEvent *event)
{
hoveredRow = -1;
QListView::leaveEvent(event);
}

View file

@ -0,0 +1,67 @@
/**
* @file card_database_visual_view.h
* @ingroup VisualCardDatabaseWidgets
* @brief Icon-mode view of the card database, one card picture per item.
*/
#ifndef CARD_DATABASE_VISUAL_VIEW_H
#define CARD_DATABASE_VISUAL_VIEW_H
#include <QListView>
#include <QSize>
#include <qtmetamacros.h>
class CardDatabaseVisualDisplayModel;
class ExactCard;
class QEvent;
class QMouseEvent;
class QPoint;
class QWheelEvent;
/**
* @brief A wrapping grid of card pictures.
*
* The view virtualizes painting: it only asks the delegate for the items that are
* on screen, so the whole card database can sit in the model without a widget (or
* pixmap) per card. Clicks and hover are reported with the exact card under the
* cursor, which is what the deck editor needs to add, decrement or preview a card.
*/
class CardDatabaseVisualView : public QListView
{
Q_OBJECT
public:
explicit CardDatabaseVisualView(CardDatabaseVisualDisplayModel *_model, QWidget *parent = nullptr);
/**
* @brief Sets the card size as a percentage of the base card width.
* @param factor Percentage in the range of the card size slider.
*/
void setCardSizeFactor(int factor);
signals:
/** @brief A card was pressed. The event is valid for the duration of the emission. */
void cardClicked(QMouseEvent *event, const ExactCard &card);
/** @brief The cursor moved onto a different card. */
void cardHovered(const ExactCard &card);
protected:
void mousePressEvent(QMouseEvent *event) override;
void mouseMoveEvent(QMouseEvent *event) override;
void leaveEvent(QEvent *event) override;
bool viewportEvent(QEvent *event) override;
void wheelEvent(QWheelEvent *event) override;
private:
void updateHoveredRow(const QPoint &position);
/// Pixels one wheel notch moves, scaled to the grid and the OS line count.
[[nodiscard]] int wheelStep() const;
CardDatabaseVisualDisplayModel *model;
/// Fraction of a line left over from a wheel event smaller than one notch.
qreal wheelRemainder = 0.0;
int cardSizeFactor = 100;
int hoveredRow = -1;
};
#endif // CARD_DATABASE_VISUAL_VIEW_H

View file

@ -5,40 +5,29 @@
#include "../../../filters/syntax_help.h"
#include "../../key_signals.h"
#include "../../pixel_map_generator.h"
#include "../cards/card_info_picture_widget.h"
#include "../cards/card_info_picture_with_text_overlay_widget.h"
#include "../cards/card_size_widget.h"
#include "../deck_editor/card_database_view.h"
#include "../general/layout_containers/flow_widget.h"
#include "../utility/custom_line_edit.h"
#include "card_database_display_model.h"
#include "card_database_model.h"
#include "card_database_visual_display_model.h"
#include "card_database_visual_view.h"
#include "deck_list_model.h"
#include "libcockatrice/card/card_localization.h"
#include "libcockatrice/card/database/card_database_loader.h"
#include "libcockatrice/card/database/card_database_querier.h"
#include "libcockatrice/card/printing/exact_card.h"
#include "libcockatrice/card/printing/printing_info.h"
#include "libcockatrice/filters/filter_card.h"
#include "libcockatrice/filters/filter_tree.h"
#include "visual_database_display_color_filter_widget.h"
#include "visual_database_display_filter_toolbar_widget.h"
#include <QAbstractItemModel>
#include <QAction>
#include <QDebug>
#include <QIcon>
#include <QLabel>
#include <QLineEdit>
#include <QList>
#include <QMessageLogger>
#include <QModelIndex>
#include <QPair>
#include <QPersistentModelIndex>
#include <QPushButton>
#include <QScrollArea>
#include <QScrollBar>
#include <QSharedPointer>
#include <QSizePolicy>
#include <QSlider>
#include <QStringLiteral>
@ -48,14 +37,11 @@
#include <QVBoxLayout>
#include <QVariant>
#include <QtGlobal>
#include <algorithm>
#include <libcockatrice/card/card_info_comparator.h>
#include <libcockatrice/card/database/card_database.h>
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/deck_list/tree/inner_deck_list_node.h>
#include <libcockatrice/settings/cards_display_settings.h>
#include <libcockatrice/utility/qt_utils.h>
#include <utility>
class QMouseEvent;
@ -64,11 +50,6 @@ VisualDatabaseDisplayWidget::VisualDatabaseDisplayWidget(QWidget *parent,
DeckListModel *deckListModel)
: QWidget(parent)
{
debounceTimer = new QTimer(this);
debounceTimer->setSingleShot(true); // Ensure it only fires once after the timeout
connect(debounceTimer, &QTimer::timeout, this, &VisualDatabaseDisplayWidget::onSearchModelChanged);
// Create display model
databaseDisplayModel = new CardDatabaseDisplayModel(this);
databaseDisplayModel->setObjectName("databaseDisplayModel");
@ -86,23 +67,12 @@ VisualDatabaseDisplayWidget::VisualDatabaseDisplayWidget(QWidget *parent,
connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardSearchLanguageChanged, this,
applyCardSearchLanguage);
cards = new QList<ExactCard>;
connect(databaseDisplayModel, &CardDatabaseDisplayModel::modelDirty, this,
&VisualDatabaseDisplayWidget::modelDirty);
// Set up main layout and widgets
setMinimumSize(0, 0);
setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
mainLayout = QtUtils::makeLayout<QVBoxLayout>(this, 0, 1);
setLayout(mainLayout);
flowWidget = new FlowWidget(this, Qt::Horizontal, Qt::ScrollBarAlwaysOff, Qt::ScrollBarPolicy::ScrollBarAsNeeded);
cardSizeWidget = new CardSizeWidget(this, flowWidget,
SettingsCache::instance().cardsDisplay().getVisualDatabaseDisplayCardSize());
connect(cardSizeWidget, &CardSizeWidget::cardSizeSettingUpdated, &SettingsCache::instance().cardsDisplay(),
&CardsDisplaySettings::setVisualDatabaseDisplayCardSize);
cardSizeWidget->enableCtrlScrollResize(flowWidget);
searchContainer = new FlowWidget(this, Qt::Horizontal, Qt::ScrollBarAlwaysOff, Qt::ScrollBarAlwaysOff);
searchEdit = new SearchLineEdit();
@ -159,6 +129,25 @@ VisualDatabaseDisplayWidget::VisualDatabaseDisplayWidget(QWidget *parent,
connect(databaseView, &CardDatabaseView::relatedCardClicked, this,
&VisualDatabaseDisplayWidget::onRelatedCardClicked);
// The visual grid is a real view on a flattened model: one row per displayed
// card picture, painted on demand.
visualDisplayModel = new CardDatabaseVisualDisplayModel(databaseDisplayModel, CardDatabaseManager::query(), this);
cardView = new CardDatabaseVisualView(visualDisplayModel, this);
cardView->setObjectName("visualCardView");
connect(cardView, &CardDatabaseVisualView::cardClicked, this,
&VisualDatabaseDisplayWidget::cardClickedDatabaseDisplay);
connect(cardView, &CardDatabaseVisualView::cardHovered, this,
&VisualDatabaseDisplayWidget::cardHoveredDatabaseDisplay);
const int cardSize = SettingsCache::instance().cardsDisplay().getVisualDatabaseDisplayCardSize();
cardSizeWidget = new CardSizeWidget(this, nullptr, cardSize);
connect(cardSizeWidget, &CardSizeWidget::cardSizeSettingUpdated, &SettingsCache::instance().cardsDisplay(),
&CardsDisplaySettings::setVisualDatabaseDisplayCardSize);
connect(cardSizeWidget->getSlider(), &QSlider::valueChanged, cardView, &CardDatabaseVisualView::setCardSizeFactor);
cardView->setCardSizeFactor(cardSize);
cardSizeWidget->enableCtrlScrollResize(cardView);
colorFilterWidget = new VisualDatabaseDisplayColorFilterWidget(this, filterModel);
filterContainer = new VisualDatabaseDisplayFilterToolbarWidget(this, deckListModel);
@ -190,8 +179,6 @@ VisualDatabaseDisplayWidget::VisualDatabaseDisplayWidget(QWidget *parent,
databaseLoadIndicator->setVisible(false);
}
QScrollBar *scrollBar = flowWidget->scrollArea->verticalScrollBar();
connect(scrollBar, &QScrollBar::valueChanged, [this](int /*value*/) { loadCurrentPage(); });
retranslateUi();
}
@ -210,7 +197,7 @@ void VisualDatabaseDisplayWidget::initialize()
mainLayout->addWidget(databaseView);
mainLayout->addWidget(flowWidget);
mainLayout->addWidget(cardView);
mainLayout->addWidget(cardSizeWidget);
@ -241,8 +228,6 @@ void VisualDatabaseDisplayWidget::initializeFilters()
databaseDisplayModel->setFilterTree(filterModel->filterTree());
QTimer::singleShot(5000, this, [this] { loadCurrentPage(); });
retranslateUi();
});
}
@ -278,71 +263,36 @@ void VisualDatabaseDisplayWidget::updateDisplayModeButton()
displayModeButton->setText(displayModeButton->isChecked() ? tr("Table") : tr("Visual"));
}
void VisualDatabaseDisplayWidget::resizeEvent(QResizeEvent *event)
{
QWidget::resizeEvent(event);
loadCurrentPage();
}
void VisualDatabaseDisplayWidget::onDisplayModeChanged(bool checked)
{
updateDisplayModeButton();
if (checked) {
// Table mode
flowWidget->setVisible(false);
cardView->setVisible(false);
cardSizeWidget->setVisible(false);
databaseView->setItemDelegate(new QStyledItemDelegate(databaseView));
databaseView->setVisible(true);
} else {
// Visual mode
flowWidget->setVisible(true);
cardView->setVisible(true);
cardSizeWidget->setVisible(true);
databaseView->setVisible(false);
populateCards();
}
}
void VisualDatabaseDisplayWidget::onClick(QMouseEvent *event, const ExactCard &card)
{
emit cardClickedDatabaseDisplay(event, card);
}
void VisualDatabaseDisplayWidget::onHover(const ExactCard &hoveredCard)
{
emit cardHoveredDatabaseDisplay(hoveredCard);
}
void VisualDatabaseDisplayWidget::addCardToDisplay(const ExactCard &cardToAdd)
{
cards->append(cardToAdd);
auto *display = new CardInfoPictureWithTextOverlayWidget(flowWidget, false);
display->setScaleFactor(cardSizeWidget->getSlider()->value());
display->setCard(cardToAdd);
flowWidget->addWidget(display);
connect(display, &CardInfoPictureWithTextOverlayWidget::cardClicked, this, &VisualDatabaseDisplayWidget::onClick);
connect(display, &CardInfoPictureWithTextOverlayWidget::hoveredOnCard, this, &VisualDatabaseDisplayWidget::onHover);
connect(cardSizeWidget->getSlider(), &QSlider::valueChanged, display, &CardInfoPictureWidget::setScaleFactor);
}
bool VisualDatabaseDisplayWidget::isVisualDisplayMode() const
{
return !displayModeButton->isChecked();
}
QStringList VisualDatabaseDisplayWidget::collectSetFilterTerms() const
{
return filterModel->getFilterTermsOfType(CardFilter::AttrSet);
}
void VisualDatabaseDisplayWidget::onSearchModelChanged()
{
if (flowWidget->isVisible()) {
// Clear the current page and prepare for new data
flowWidget->clearLayout(); // Clear existing cards
cards->clear(); // Clear the card list
// Reset scrollbar position to the top after loading new cards
QScrollBar *scrollBar = flowWidget->scrollArea->verticalScrollBar();
scrollBar->setValue(0); // Reset scrollbar to top
currentPage = 0;
loadCurrentPage();
qCDebug(VisualDatabaseDisplayLog) << "Search model changed";
}
visualDisplayModel->setSetFilterTerms(collectSetFilterTerms());
}
void VisualDatabaseDisplayWidget::onSelectedCardChanged(const QString &cardName)
@ -368,113 +318,3 @@ void VisualDatabaseDisplayWidget::onRelatedCardClicked(const QString &relatedCar
ExactCard exactCard = CardDatabaseManager::query()->guessCard({relatedCard});
emit cardInfoRequested(exactCard);
}
bool VisualDatabaseDisplayWidget::nearEndOfPage() const
{
if (!flowWidget->isVisible()) {
return false;
}
QScrollBar *scrollBar = flowWidget->scrollArea->verticalScrollBar();
if (scrollBar->value() + scrollBar->pageStep() * 2 < scrollBar->maximum()) {
return false; // there is at least two pages of space to scroll remaining
}
return true;
}
void VisualDatabaseDisplayWidget::loadCurrentPage()
{
// Ensure only the initial page is loaded
if (currentPage == 0) {
if (!initialLoadScheduled) {
initialLoadScheduled = true;
qCDebug(VisualDatabaseDisplayLog) << "Loading the first page";
// Defer the first page so the tab switch stays responsive. The card
// grid builds one event loop turn later. This also applies to
// search-driven reloads, which reset currentPage back to 0.
QTimer::singleShot(0, this, [this] {
initialLoadScheduled = false;
populateCards();
});
}
} else if (nearEndOfPage()) {
// If not the first page, just load the next page and append to the flow widget
loadNextPage();
}
}
void VisualDatabaseDisplayWidget::populateCards()
{
cards->clear();
loadNextPage();
}
void VisualDatabaseDisplayWidget::loadNextPage()
{
// Calculate the start and end indices for the next page
int rowCount = databaseDisplayModel->rowCount();
int start = currentPage * cardsPerPage;
int end = qMin(start + cardsPerPage, rowCount);
// Load more cards if we are at the end of the current list and can fetch more
if (end >= rowCount && databaseDisplayModel->canFetchMore(QModelIndex())) {
databaseDisplayModel->fetchMore(QModelIndex());
}
// Load the next page of cards and add them to the flow widget
flowWidget->setUpdatesEnabled(false);
loadPage(start, end);
flowWidget->setUpdatesEnabled(true);
}
void VisualDatabaseDisplayWidget::loadPage(int start, int end)
{
QList<const CardFilter *> setFilters = filterModel->getFiltersOfType(CardFilter::AttrSet);
for (int row = start; row < end; ++row) {
qCDebug(VisualDatabaseDisplayLog) << "Adding " << row;
QModelIndex index = databaseDisplayModel->index(row, CardDatabaseModel::NameColumn);
QVariant name = databaseDisplayModel->data(index, Qt::DisplayRole);
qCDebug(VisualDatabaseDisplayLog) << name.toString();
if (CardInfoPtr info = CardDatabaseManager::query()->getCardInfo(name.toString())) {
if (!setFilters.empty()) {
const auto &setMap = info->getSets();
for (const CardFilter *setFilter : setFilters) {
const auto it = setMap.constFind(setFilter->term());
if (it != setMap.constEnd()) {
for (const PrintingInfo &printing : it.value()) {
addCardToDisplay(ExactCard(info, printing));
}
}
}
} else {
addCardToDisplay(CardDatabaseManager::query()->getPreferredCard(info));
}
} else {
qCDebug(VisualDatabaseDisplayLog) << "Card not found in database!";
}
}
currentPage++;
}
void VisualDatabaseDisplayWidget::modelDirty() const
{
debounceTimer->start(debounceTime);
}
void VisualDatabaseDisplayWidget::sortCardList(const QStringList &properties,
Qt::SortOrder order = Qt::AscendingOrder) const
{
CardInfoComparator comparator(properties, order);
std::sort(cards->begin(), cards->end(), [comparator](const ExactCard &a, const ExactCard &b) {
return comparator(a.getCardPtr(), b.getCardPtr());
});
}
void VisualDatabaseDisplayWidget::databaseDataChanged(const QModelIndex &topLeft, const QModelIndex &bottomRight)
{
(void)topLeft;
(void)bottomRight;
qCDebug(VisualDatabaseDisplayLog) << "Database Data changed";
}

View file

@ -9,8 +9,6 @@
#include "libcockatrice/card/card_info.h"
#include <QLoggingCategory>
#include <QMouseEvent>
#include <QString>
#include <QStringList>
#include <QWidget>
@ -21,26 +19,21 @@
class CardDatabaseDisplayModel;
class CardDatabaseModel;
class CardDatabaseView;
class CardDatabaseVisualDisplayModel;
class CardDatabaseVisualView;
class CardSizeWidget;
class DeckList;
class DeckListModel;
class ExactCard;
class FilterTreeModel;
class FlowWidget;
class OverlapControlWidget;
class QLabel;
class QModelIndex;
class QMouseEvent;
class QPushButton;
class QScrollArea;
class QTimer;
class QToolButton;
class QVBoxLayout;
class SearchLineEdit;
class VisualDatabaseDisplayColorFilterWidget;
class VisualDatabaseDisplayFilterToolbarWidget;
template <typename T> class QList;
inline Q_LOGGING_CATEGORY(VisualDatabaseDisplayLog, "visual_database_display");
class VisualDatabaseDisplayWidget : public QWidget
{
@ -52,14 +45,6 @@ public:
DeckListModel *deckListModel = nullptr);
void retranslateUi();
void adjustCardsPerPage();
void populateCards();
void loadPage(int start, int end);
void loadNextPage();
void loadCurrentPage();
void sortCardList(const QStringList &properties, Qt::SortOrder order) const;
void setDeckList(const DeckList &new_deck_list_model);
/**
* @brief Sets the callback used to create a custom zone from the add-to-zone menu.
* The callback returns the name of the created zone, or an empty string if creation was cancelled.
@ -87,6 +72,13 @@ public:
bool isVisualDisplayMode() const;
public slots:
/**
* @brief Pushes the active set filters to the visual grid.
*
* The grid's rows follow the display model's filter and sort signals on their
* own; this only forwards the set filters, which decide which printings the
* grid shows.
*/
void onSearchModelChanged();
signals:
@ -101,11 +93,6 @@ signals:
protected slots:
void initialize();
void onClick(QMouseEvent *event, const ExactCard &card);
void onHover(const ExactCard &hoveredCard);
void addCardToDisplay(const ExactCard &cardToAdd);
void databaseDataChanged(const QModelIndex &topLeft, const QModelIndex &bottomRight);
void modelDirty() const;
void onDisplayModeChanged(bool checked);
void onSelectedCardChanged(const QString &cardName);
@ -125,31 +112,21 @@ private:
QToolButton *clearFilterWidget;
VisualDatabaseDisplayFilterToolbarWidget *filterContainer;
CardDatabaseDisplayModel *databaseDisplayModel;
CardDatabaseVisualDisplayModel *visualDisplayModel;
CardDatabaseView *databaseView;
CardDatabaseVisualView *cardView;
std::function<QString()> newZoneCreator;
QList<ExactCard> *cards;
QVBoxLayout *mainLayout;
QScrollArea *scrollArea;
FlowWidget *flowWidget;
QWidget *overlapCategories;
QVBoxLayout *overlapCategoriesLayout;
OverlapControlWidget *overlapControlWidget;
CardSizeWidget *cardSizeWidget;
QTimer *debounceTimer;
int debounceTime = 300; // in Ms
int currentPage = 0; // Current page index
int cardsPerPage = 100; // Number of cards per page
bool filtersInitialized = false;
bool initialLoadScheduled = false;
void initializeFilters();
void highlightAllSearchEdit();
void updateDisplayModeButton();
bool nearEndOfPage() const;
[[nodiscard]] QStringList collectSetFilterTerms() const;
protected:
void resizeEvent(QResizeEvent *event) override;
void showEvent(QShowEvent *event) override;
};

View file

@ -254,7 +254,11 @@ void MainWindow::actWatchReplay()
void MainWindow::localGameEnded()
{
delete localServer;
// Defer deletion so the LocalClients (already scheduled with deleteLater() in TabSupervisor::stop)
// and any queued commands are processed before the server and its interfaces are destroyed.
if (localServer) {
localServer->deleteLater();
}
localServer = nullptr;
aConnect->setEnabled(true);

View file

@ -33,6 +33,7 @@ cmakefile="CMakeLists.txt"
cmakedir="cmake/.*\\.cmake"
cmakeinclude=("cmake/gtest-CMakeLists.txt.in")
scripts="*.sh"
scriptsrx="[^/]*\\.sh$"
color="--"
verbosity=0
sep="----------"
@ -254,7 +255,7 @@ if [[ $branch ]]; then
if [[ $do_shell ]]; then
shell_names=()
for name in "${basenames[@]}"; do
filerx="(^|/)$scripts$"
filerx="(^|/)$scriptsrx"
if [[ $name =~ $filerx ]]; then
shell_names+=("$name")
fi

View file

@ -1,7 +1,3 @@
set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTOUIC ON)
set(CMAKE_AUTORCC ON)
set(HEADERS
libcockatrice/card/card_info.h
libcockatrice/card/card_info_comparator.h

View file

@ -1,7 +1,3 @@
set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTOUIC ON)
set(CMAKE_AUTORCC ON)
set(HEADERS
libcockatrice/deck_list/tree/abstract_deck_list_card_node.h
libcockatrice/deck_list/tree/abstract_deck_list_node.h

View file

@ -1,7 +1,3 @@
set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTOUIC ON)
set(CMAKE_AUTORCC ON)
set(HEADERS libcockatrice/filters/filter_card.h libcockatrice/filters/filter_string.h
libcockatrice/filters/filter_tree.h
)

View file

@ -54,7 +54,7 @@ private:
enum Attr a;
public:
CardFilter(QString &term, Type type, Attr attr) : trm(term), t(type), a(attr)
CardFilter(const QString &term, Type type, Attr attr) : trm(term), t(type), a(attr)
{
}

View file

@ -1,7 +1,3 @@
set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTOUIC ON)
set(CMAKE_AUTORCC ON)
set(HEADERS
libcockatrice/game/abstract_game.h
libcockatrice/game/board/arrow_data.h

View file

@ -1,7 +1,3 @@
set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTOUIC ON)
set(CMAKE_AUTORCC ON)
set(HEADERS
libcockatrice/interfaces/interface_cache_storage_settings_provider.h
libcockatrice/interfaces/interface_card_database_path_provider.h

View file

@ -1,7 +1,3 @@
set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTOUIC ON)
set(CMAKE_AUTORCC ON)
add_subdirectory(libcockatrice/models/database)
add_subdirectory(libcockatrice/models/deck_list)

View file

@ -1,10 +1,7 @@
set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTOUIC ON)
set(CMAKE_AUTORCC ON)
set(HEADERS
card_database_model.h
card_database_display_model.h
card_database_visual_display_model.h
card/card_completer_proxy_model.h
card/card_search_model.h
card_set/card_sets_model.h
@ -17,6 +14,7 @@ add_library(
${HEADERS}
card_database_model.cpp
card_database_display_model.cpp
card_database_visual_display_model.cpp
card/card_completer_proxy_model.cpp
card/card_search_model.cpp
card_set/card_sets_model.cpp

View file

@ -21,7 +21,7 @@
class QObject;
CardDatabaseDisplayModel::CardDatabaseDisplayModel(QObject *parent)
: QSortFilterProxyModel(parent), isToken(ShowAll), filterString(nullptr),
: QSortFilterProxyModel(parent), isToken(ShowAll), filterString(nullptr), cardModel(nullptr),
collator(NaturalSort::makeNaturalSortCollator())
{
filterTree = nullptr;
@ -33,22 +33,37 @@ CardDatabaseDisplayModel::CardDatabaseDisplayModel(QObject *parent)
invalidate();
emit modelDirty();
});
loadedRowCount = 0;
}
void CardDatabaseDisplayModel::setSourceModel(QAbstractItemModel *model)
{
cardModel = qobject_cast<CardDatabaseModel *>(model);
QSortFilterProxyModel::setSourceModel(model);
connect(model, &QAbstractItemModel::rowsInserted, this, [this]() { dirty(); });
connect(model, &QAbstractItemModel::rowsRemoved, this, [this]() { dirty(); });
connect(model, &QAbstractItemModel::modelReset, this, [this]() {
loadedRowCount = 0;
dirty();
});
connect(model, &QAbstractItemModel::modelReset, this, [this]() { dirty(); });
}
QVariant CardDatabaseDisplayModel::data(const QModelIndex &index, int role) const
{
if (role == CardInfoRole) {
return QVariant::fromValue(cardInfoAt(index));
}
return QSortFilterProxyModel::data(index, role);
}
CardInfoPtr CardDatabaseDisplayModel::cardInfoAt(const QModelIndex &index) const
{
if (!index.isValid() || cardModel == nullptr) {
return {};
}
const QModelIndex sourceIndex = mapToSource(index);
return sourceIndex.isValid() ? cardModel->getCard(sourceIndex.row()) : CardInfoPtr();
}
QMap<wchar_t, wchar_t> CardDatabaseDisplayModel::characterTranslation = {{L'“', L'\"'},
@ -56,32 +71,6 @@ QMap<wchar_t, wchar_t> CardDatabaseDisplayModel::characterTranslation = {{L'“'
{L'‘', L'\''},
{L'’', L'\''}};
bool CardDatabaseDisplayModel::canFetchMore(const QModelIndex &index) const
{
return loadedRowCount < sourceModel()->rowCount(index);
}
void CardDatabaseDisplayModel::fetchMore(const QModelIndex &index)
{
int remainder = sourceModel()->rowCount(index) - loadedRowCount;
int itemsToFetch = qMin(100, remainder);
if (itemsToFetch == 0) {
return;
}
const auto startIndex = qMin(rowCount(QModelIndex()), loadedRowCount);
beginInsertRows(QModelIndex(), startIndex, startIndex + itemsToFetch - 1);
loadedRowCount += itemsToFetch;
endInsertRows();
}
int CardDatabaseDisplayModel::rowCount(const QModelIndex &parent) const
{
return QSortFilterProxyModel::rowCount(parent);
}
bool CardDatabaseDisplayModel::lessThan(const QModelIndex &left, const QModelIndex &right) const
{
const QString leftString = sourceModel()->data(left, CardDatabaseModel::SortRole).toString();

View file

@ -21,6 +21,7 @@
#include <libcockatrice/filters/filter_string.h>
#include <qtmetamacros.h>
class CardDatabaseModel;
class FilterTree;
class QObject;
template <class Key, class T> class QMap;
@ -36,13 +37,25 @@ public:
ShowAll
};
enum Role
{
/**
* @brief The CardInfoPtr behind a proxy row.
*
* Saves views from looking the card up again through its name, and keeps
* them from depending on the source model's column layout.
*/
CardInfoRole = Qt::UserRole + 1
};
private:
FilterBool isToken;
QString cardName, cardText;
QSet<QString> cardNameSet, cardTypes, cardColors;
FilterTree *filterTree;
FilterString *filterString;
int loadedRowCount;
/// The source model, kept typed so CardInfoRole does not cast per row.
CardDatabaseModel *cardModel;
QTimer dirtyTimer;
CardSearchLanguage searchLanguage;
QString searchText;
@ -85,9 +98,16 @@ public:
dirtyTimer.start(20);
}
void clearFilterAll();
[[nodiscard]] int rowCount(const QModelIndex &parent = QModelIndex()) const override;
[[nodiscard]] bool canFetchMore(const QModelIndex &parent) const override;
void fetchMore(const QModelIndex &parent) override;
[[nodiscard]] QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
/**
* @brief Returns the card behind a proxy row, without the QVariant wrapper of CardInfoRole.
*
* The visual grid calls this for every row on each rebuild; CardInfoRole stays the
* generic way in for consumers that only see the model through data().
*/
[[nodiscard]] CardInfoPtr cardInfoAt(const QModelIndex &index) const;
signals:
void modelDirty();

View file

@ -27,14 +27,18 @@ CardDatabaseModel::CardDatabaseModel(CardDatabase *_db, bool _showOnlyCardsFromE
CardDatabaseModel::~CardDatabaseModel() = default;
int CardDatabaseModel::rowCount(const QModelIndex & /*parent*/) const
int CardDatabaseModel::rowCount(const QModelIndex &parent) const
{
return cardList.size();
// A list model's card rows are leaves; returning the full card count for a
// valid parent makes every row claim all cards as children.
return parent.isValid() ? 0 : cardList.size();
}
int CardDatabaseModel::columnCount(const QModelIndex & /*parent*/) const
int CardDatabaseModel::columnCount(const QModelIndex &parent) const
{
return CARDDBMODEL_COLUMNS;
// Card rows are leaves, so a valid parent holds no columns. Reporting the
// row width for a card index would claim child cells that do not exist.
return parent.isValid() ? 0 : CARDDBMODEL_COLUMNS;
}
QVariant CardDatabaseModel::data(const QModelIndex &index, int role) const

View file

@ -0,0 +1,123 @@
#include "card_database_visual_display_model.h"
#include "card_database_display_model.h"
#include "libcockatrice/card/database/card_database_querier.h"
#include <QAbstractItemModel>
#include <QList>
#include <QStringList>
#include <QVariant>
/// Coalesces the bursts of proxy signals a filter, sort or database load produces.
static constexpr int REBUILD_COALESCE_MS = 30;
CardDatabaseVisualDisplayModel::CardDatabaseVisualDisplayModel(CardDatabaseDisplayModel *_displayModel,
CardDatabaseQuerier *_querier,
QObject *parent)
: QAbstractListModel(parent), displayModel(_displayModel), querier(_querier)
{
rebuildTimer.setSingleShot(true);
rebuildTimer.setInterval(REBUILD_COALESCE_MS);
connect(&rebuildTimer, &QTimer::timeout, this, &CardDatabaseVisualDisplayModel::rebuild);
if (displayModel == nullptr) {
return;
}
connect(displayModel, &QAbstractItemModel::modelReset, this, &CardDatabaseVisualDisplayModel::scheduleRebuild);
connect(displayModel, &QAbstractItemModel::layoutChanged, this, &CardDatabaseVisualDisplayModel::scheduleRebuild);
connect(displayModel, &QAbstractItemModel::rowsInserted, this, &CardDatabaseVisualDisplayModel::scheduleRebuild);
connect(displayModel, &QAbstractItemModel::rowsRemoved, this, &CardDatabaseVisualDisplayModel::scheduleRebuild);
connect(displayModel, &QAbstractItemModel::dataChanged, this, &CardDatabaseVisualDisplayModel::scheduleRebuild);
}
void CardDatabaseVisualDisplayModel::setSetFilterTerms(const QStringList &terms)
{
if (setFilterTerms == terms) {
return;
}
setFilterTerms = terms;
refresh();
}
void CardDatabaseVisualDisplayModel::refresh()
{
rebuildTimer.stop();
rebuild();
}
void CardDatabaseVisualDisplayModel::scheduleRebuild()
{
rebuildTimer.start();
}
void CardDatabaseVisualDisplayModel::rebuild()
{
beginResetModel();
entries.clear();
if (displayModel != nullptr) {
const int proxyRows = displayModel->rowCount();
entries.reserve(proxyRows);
for (int row = 0; row < proxyRows; ++row) {
const CardInfoPtr info = displayModel->cardInfoAt(displayModel->index(row, 0));
if (info) {
appendPrintingsFor(info);
}
}
}
endResetModel();
}
void CardDatabaseVisualDisplayModel::appendPrintingsFor(const CardInfoPtr &info)
{
if (setFilterTerms.isEmpty()) {
entries.append({info, PrintingInfo()});
return;
}
const auto &printingsBySet = info->getSets();
for (const QString &term : setFilterTerms) {
const auto printings = printingsBySet.constFind(term);
if (printings == printingsBySet.constEnd()) {
continue;
}
for (const PrintingInfo &printing : printings.value()) {
entries.append({info, printing});
}
}
}
int CardDatabaseVisualDisplayModel::rowCount(const QModelIndex &parent) const
{
return parent.isValid() ? 0 : static_cast<int>(entries.size());
}
QVariant CardDatabaseVisualDisplayModel::data(const QModelIndex &index, int role) const
{
if (!index.isValid() || index.row() >= entries.size()) {
return {};
}
if (role == Qt::DisplayRole || role == Qt::AccessibleTextRole) {
return cardAt(index.row()).getName();
}
return {};
}
ExactCard CardDatabaseVisualDisplayModel::cardAt(int row) const
{
if (row < 0 || row >= entries.size()) {
return {};
}
const Entry &entry = entries.at(row);
if (entry.printing.isEmpty() && setFilterTerms.isEmpty()) {
return querier != nullptr ? querier->getPreferredCard(entry.info) : ExactCard(entry.info);
}
return ExactCard(entry.info, entry.printing);
}

View file

@ -0,0 +1,90 @@
/**
* @file card_database_visual_display_model.h
* @ingroup CardDatabaseModels
* @brief A list model of the exact cards a visual card grid paints.
*
* The filtered and sorted CardDatabaseDisplayModel holds one row per card name.
* A visual grid shows one picture per *printing* instead, so this model flattens
* the proxy rows into ExactCards: the preferred printing per card normally, and
* every printing of the selected sets while set filters are active.
*/
#ifndef COCKATRICE_CARD_DATABASE_VISUAL_DISPLAY_MODEL_H
#define COCKATRICE_CARD_DATABASE_VISUAL_DISPLAY_MODEL_H
#include "libcockatrice/card/card_info.h"
#include "libcockatrice/card/printing/exact_card.h"
#include "libcockatrice/card/printing/printing_info.h"
#include <QAbstractListModel>
#include <QList>
#include <QModelIndex>
#include <QPointer>
#include <QStringList>
#include <QTimer>
#include <qtmetamacros.h>
class CardDatabaseDisplayModel;
class CardDatabaseQuerier;
class CardDatabaseVisualDisplayModel : public QAbstractListModel
{
Q_OBJECT
public:
/**
* @brief Flattens _displayModel into the cards a visual grid displays.
* @param _displayModel The filtered and sorted proxy model of the card database.
* @param _querier Resolves the preferred printing of a card.
* @param parent Parent object.
*/
explicit CardDatabaseVisualDisplayModel(CardDatabaseDisplayModel *_displayModel,
CardDatabaseQuerier *_querier,
QObject *parent = nullptr);
/**
* @brief Sets the set shorthand names of the active set filters.
*
* With no terms every card is shown with its preferred printing. With terms
* every printing belonging to one of the named sets is shown instead, in term
* order. Rebuilds immediately when the terms change.
*/
void setSetFilterTerms(const QStringList &terms);
/**
* @brief Rebuilds the row list from the proxy model right away.
*
* Proxy model changes normally rebuild on a short coalescing timer; call this
* when the caller needs the new rows before returning.
*/
void refresh();
[[nodiscard]] int rowCount(const QModelIndex &parent = QModelIndex()) const override;
[[nodiscard]] QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
/**
* @brief Returns the card painted at @p row, with its preferred printing resolved.
* @param row Row in this model, not in the proxy model.
*/
[[nodiscard]] ExactCard cardAt(int row) const;
private:
struct Entry
{
CardInfoPtr info;
/// Empty when the entry follows the preferred printing rather than a set filter.
PrintingInfo printing;
};
void scheduleRebuild();
void rebuild();
void appendPrintingsFor(const CardInfoPtr &info);
QPointer<CardDatabaseDisplayModel> displayModel;
CardDatabaseQuerier *querier;
QStringList setFilterTerms;
QList<Entry> entries;
QTimer rebuildTimer;
};
#endif // COCKATRICE_CARD_DATABASE_VISUAL_DISPLAY_MODEL_H

View file

@ -1,7 +1,3 @@
set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTOUIC ON)
set(CMAKE_AUTORCC ON)
set(HEADERS deck_list_model.h deck_list_sort_filter_proxy_model.h)
add_library(

View file

@ -134,6 +134,13 @@ void DeckListModel::rebuildTree()
int DeckListModel::rowCount(const QModelIndex &parent) const
{
// Only column 0 carries children. Column 1 and later indexes are siblings
// used by views for the remaining cells of a row, and a model that hangs
// children off them breaks QAbstractItemModelTester and proxy models.
if (parent.isValid() && parent.column() != 0) {
return 0;
}
// debugIndexInfo("rowCount", parent);
auto *node = getNode<InnerDecklistNode *>(parent);
if (node) {
@ -282,7 +289,7 @@ QVariant DeckListModel::headerData(const int section, const Qt::Orientation orie
QModelIndex DeckListModel::index(int row, int column, const QModelIndex &parent) const
{
// debugIndexInfo("index", parent);
if (!hasIndex(row, column, parent)) {
if ((parent.isValid() && parent.column() != 0) || !hasIndex(row, column, parent)) {
return {};
}

View file

@ -1,7 +1,3 @@
set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTOUIC ON)
set(CMAKE_AUTORCC ON)
add_subdirectory(libcockatrice/network/client)
add_subdirectory(libcockatrice/network/server)

View file

@ -1,7 +1,3 @@
set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTOUIC ON)
set(CMAKE_AUTORCC ON)
add_subdirectory(abstract)
add_subdirectory(local)
add_subdirectory(remote)

View file

@ -1,7 +1,3 @@
set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTOUIC ON)
set(CMAKE_AUTORCC ON)
set(HEADERS abstract_client.h latency_tracker.h)
set(SOURCES abstract_client.cpp latency_tracker.cpp)
@ -15,7 +11,7 @@ target_include_directories(libcockatrice_network_client_abstract PUBLIC .)
target_link_libraries(
libcockatrice_network_client_abstract
PUBLIC ${COCKATRICE_QT_VERSION_NAME}::Network ${COCKATRICE_QT_VERSION_NAME}::WebSockets libcockatrice_protocol
libcockatrice_network_server_remote
libcockatrice_network_server_remote libcockatrice_utility
)
target_precompile_headers(libcockatrice_network_client_abstract PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtcore_pch.h")

View file

@ -10,6 +10,7 @@
#include "libcockatrice/protocol/pb/serverinfo_user.pb.h"
#include "libcockatrice/protocol/pb/session_commands.pb.h"
#include "libcockatrice/protocol/pb/session_event.pb.h"
#include "libcockatrice/utility/passwordhasher.h"
#include <QDebug>
#include <QList>
@ -80,6 +81,26 @@ AbstractClient::~AbstractClient()
{
}
Command_AccountPassword AbstractClient::makeAccountPasswordCommand(const QString &oldPassword,
const QString &newPassword) const
{
Command_AccountPassword cmd;
cmd.set_old_password(oldPassword.toStdString());
if (getServerSupportsChallengeResponse()) {
// A verifier lets the server upgrade the stored credential without ever seeing the
// password, and it is what a challenge-response server expects for a new password.
cmd.set_hashed_new_password(PasswordHasher::generatePasswordVerifier(newPassword).toStdString());
} else if (getServerSupportsPasswordHash()) {
// The server hashes the password itself with its own salt; a salted hash is the
// strongest form it accepts, so the plaintext never goes on the wire.
const QString passwordSalt = PasswordHasher::generateRandomSalt();
cmd.set_hashed_new_password(PasswordHasher::computeHash(newPassword, passwordSalt).toStdString());
} else {
cmd.set_new_password(newPassword.toStdString());
}
return cmd;
}
void AbstractClient::processProtocolItem(const ServerMessage &item)
{
switch (item.message_type()) {

View file

@ -47,6 +47,7 @@ class Event_UserJoined;
class Event_UserLeft;
class Event_ServerMessage;
class Event_ListRooms;
class Command_AccountPassword;
class Event_GameJoined;
class Event_UserMessage;
class Event_NotifyUser;
@ -67,6 +68,9 @@ enum ClientStatus
StatusSubmitForgotPasswordReset,
StatusSubmitForgotPasswordChallenge,
StatusGettingPasswordSalt,
//! The server accepted the credentials but keeps the session open until the account
//! password is replaced; the connection must survive for `Command_AccountPassword`.
StatusPasswordChangeRequired,
};
class AbstractClient : public QObject
@ -171,6 +175,16 @@ public:
{
return serverSupportsChallengeResponse;
}
/**
* @brief Builds the account-password command in the form this server understands.
*
* Challenge-response servers take a scrypt verifier; servers that only advertise
* SupportsPasswordHash take a salted SHA-512 hash; anything older gets the plaintext,
* which is the only form it understands. Both password-change paths go through here so
* the two cannot drift apart and hand a plaintext password to a server that can hash it.
*/
Command_AccountPassword makeAccountPasswordCommand(const QString &oldPassword, const QString &newPassword) const;
const QString &getUserName() const
{
return userName;

View file

@ -1,7 +1,3 @@
set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTOUIC ON)
set(CMAKE_AUTORCC ON)
set(HEADERS local_client.h)
set(SOURCES local_client.cpp)

View file

@ -33,16 +33,18 @@ LocalClient::LocalClient(LocalServerInterface *_lsi,
sendCommand(prepareSessionCommand(joinCmd));
}
LocalClient::~LocalClient()
{
}
void LocalClient::sendCommandContainer(const CommandContainer &cont)
{
if (LocalClientLog().isDebugEnabled()) {
qCDebug(LocalClientLog).noquote() << userName << "OUT" << getSafeDebugString(cont);
}
if (lsi.isNull()) {
qCWarning(LocalClientLog).noquote()
<< userName << "dropping command, local server interface no longer exists:" << getSafeDebugString(cont);
return;
}
lsi->itemFromClient(cont);
}

View file

@ -10,6 +10,7 @@
#include "../abstract/abstract_client.h"
#include <QLoggingCategory>
#include <QPointer>
#include <QString>
#include <qtmetamacros.h>
@ -24,14 +25,15 @@ class LocalClient : public AbstractClient
{
Q_OBJECT
private:
LocalServerInterface *lsi;
// Guarded pointer: the LocalServer (and its interfaces) may be destroyed while this client
// still has pending commands queued on the event loop.
QPointer<LocalServerInterface> lsi;
public:
LocalClient(LocalServerInterface *_lsi,
const QString &_playerName,
const QString &_clientId,
QObject *parent = nullptr);
~LocalClient() override;
void sendCommandContainer(const CommandContainer &cont) override;
private slots:

View file

@ -1,7 +1,3 @@
set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTOUIC ON)
set(CMAKE_AUTORCC ON)
set(HEADERS remote_client.h)
set(SOURCES remote_client.cpp)

View file

@ -125,6 +125,7 @@ RemoteClient::RemoteClient(QObject *parent, INetworkSettingsProvider *_networkSe
&RemoteClient::doSubmitForgotPasswordResetToServer);
connect(this, &RemoteClient::sigSubmitForgotPasswordChallengeToServer, this,
&RemoteClient::doSubmitForgotPasswordChallengeToServer);
connect(this, &RemoteClient::sigChangePasswordToServer, this, &RemoteClient::doChangePasswordToServer);
}
RemoteClient::~RemoteClient()
@ -354,6 +355,29 @@ void RemoteClient::submitPasswordVerifierResponse(const Response &response)
}
}
void RemoteClient::changePasswordToServer(const QString &oldPassword, const QString &newPassword)
{
emit sigChangePasswordToServer(oldPassword, newPassword);
}
void RemoteClient::doChangePasswordToServer(const QString &oldPassword, const QString &newPassword)
{
const Command_AccountPassword cmd = makeAccountPasswordCommand(oldPassword, newPassword);
PendingCommand *pend = prepareSessionCommand(cmd);
connect(pend, &PendingCommand::finished, this, &RemoteClient::accountPasswordResponse);
sendCommand(pend);
}
void RemoteClient::accountPasswordResponse(const Response &response)
{
if (response.response_code() == Response::RespOk) {
emit sigPasswordChangeSuccess();
} else {
emit sigPasswordChangeError(response.response_code());
}
}
void RemoteClient::processConnectionClosedEvent(const Event_ConnectionClosed & /*event*/)
{
doDisconnectFromServer();
@ -475,6 +499,23 @@ void RemoteClient::loginResponse(const Response &response)
emit notifyUserAboutUpdate();
}
} else if (response.response_code() == Response::RespPasswordChangeRequired) {
// The credentials were accepted: the server keeps this session open and serves
// nothing but ping and Command_AccountPassword until the password is replaced.
// Treating it as a failed login would disconnect before the dialog that collects
// the new password (queued to the UI thread behind this slot) can send anything:
// the command would sit in pendingCommands forever, because the ping timer that
// times it out stops with the connection. Keep the session and let the controller
// report the outcome through accountPasswordResponse().
QList<QString> missingFeatures;
if (resp.missing_features_size() > 0) {
for (int i = 0; i < resp.missing_features_size(); ++i) {
missingFeatures << QString::fromStdString(resp.missing_features(i));
}
}
setStatus(StatusPasswordChangeRequired);
emit loginError(response.response_code(), QString::fromStdString(resp.denied_reason_str()),
static_cast<quint32>(resp.denied_end_time()), missingFeatures);
} else if (response.response_code() != Response::RespNotConnected) {
QList<QString> missingFeatures;
if (resp.missing_features_size() > 0) {

View file

@ -71,6 +71,11 @@ signals:
//! should only store it for the connection it is currently negotiating, so
//! the hostname and user name are intentionally not part of the signal.
void sigPasswordVerifierReady(const QString &verifier);
//! \brief Outcome of the password change the server demanded at login.
void sigPasswordChangeSuccess();
void sigPasswordChangeError(int responseCode);
//! \brief Request a password change; queued to the client thread like the other requests.
void sigChangePasswordToServer(const QString &oldPassword, const QString &newPassword);
private slots:
void slotConnected();
void readData();
@ -99,6 +104,8 @@ private slots:
Command_Login generateCommandLogin();
void doSubmitPasswordVerifier();
void submitPasswordVerifierResponse(const Response &response);
void accountPasswordResponse(const Response &response);
void doChangePasswordToServer(const QString &oldPassword, const QString &newPassword);
void doDisconnectFromServer();
void doActivateToServer(const QString &_token);
void doRequestForgotPasswordToServer(const QString &hostname, unsigned int port, const QString &_userName);
@ -201,6 +208,10 @@ public:
unsigned int port,
const QString &_userName,
const QString &_email);
//! \brief Change the password of the account that just logged in, following the
//! server's forced-password-change reply. Emits sigPasswordChangeSuccess or
//! sigPasswordChangeError when the server answers.
void changePasswordToServer(const QString &oldPassword, const QString &newPassword);
};
#endif

View file

@ -1,7 +1,3 @@
set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTOUIC ON)
set(CMAKE_AUTORCC ON)
add_subdirectory(local)
add_subdirectory(remote)

View file

@ -1,7 +1,3 @@
set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTOUIC ON)
set(CMAKE_AUTORCC ON)
set(HEADERS local_server.h local_server_interface.h)
set(SOURCES local_server.cpp local_server_interface.cpp)

View file

@ -1,7 +1,3 @@
set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTOUIC ON)
set(CMAKE_AUTORCC ON)
set(HEADERS
game/dump_zone_range.h
game/server_abstract_participant.h
@ -12,6 +8,7 @@ set(HEADERS
game/server_cardzone.h
game/server_counter.h
game/game_config.h
game/role_info.h
game/server_deck_validation_strategy.h
game/server_game.h
game/server_game_lifecycle_strategy.h

View file

@ -0,0 +1,13 @@
#ifndef COCKATRICE_ROLE_INFO_H
#define COCKATRICE_ROLE_INFO_H
/**
* @brief Represents information about a participant's roles.
*/
struct RoleInfo
{
bool isJudge = false; ///< Whether the participant joined as a judge
bool isSpectator = false; ///< Whether the participant joined as a spectator
};
#endif // COCKATRICE_ROLE_INFO_H

View file

@ -61,10 +61,10 @@
Server_AbstractParticipant::Server_AbstractParticipant(Server_Game *_game,
int _playerId,
const ServerInfo_User &_userInfo,
bool _judge,
const RoleInfo &_roleInfo,
Server_AbstractUserInterface *_userInterface)
: ServerInfo_User_Container(_userInfo), game(_game), userInterface(_userInterface), pingTime(0),
playerId(_playerId), judge(_judge)
playerId(_playerId), roleInfo(_roleInfo)
{
}
@ -98,8 +98,8 @@ void Server_AbstractParticipant::getProperties(ServerInfo_PlayerProperties &resu
if (withUserInfo) {
copyUserInfo(*(result.mutable_user_info()), true);
}
result.set_spectator(spectator);
result.set_judge(judge);
result.set_spectator(isSpectator());
result.set_judge(isJudge());
result.set_ping_seconds(pingTime);
getPlayerProperties(result);
}
@ -176,7 +176,7 @@ Response::ResponseCode Server_AbstractParticipant::cmdUnconcede(const Command_Un
Response::ResponseCode
Server_AbstractParticipant::cmdJudge(const Command_Judge &cmd, ResponseContainer &rc, GameEventStorage &ges)
{
if (!judge) {
if (!isJudge()) {
return Response::RespFunctionNotAllowed;
}
@ -204,7 +204,7 @@ Response::ResponseCode Server_AbstractParticipant::cmdReadyStart(const Command_R
Response::ResponseCode
Server_AbstractParticipant::cmdGameSay(const Command_GameSay &cmd, ResponseContainer & /*rc*/, GameEventStorage &ges)
{
if (spectator) {
if (isSpectator()) {
/* Spectators can only talk if:
* (a) the game creator allows it
* (b) the spectator is a moderator/administrator
@ -365,7 +365,7 @@ Response::ResponseCode Server_AbstractParticipant::cmdNextTurn(const Command_Nex
return Response::RespGameNotStarted;
}
if (!judge) {
if (!isJudge()) {
return Response::RespFunctionNotAllowed;
}
@ -381,7 +381,7 @@ Response::ResponseCode Server_AbstractParticipant::cmdSetActivePhase(const Comma
return Response::RespGameNotStarted;
}
if (!judge) {
if (!isJudge()) {
return Response::RespFunctionNotAllowed;
}
@ -415,8 +415,8 @@ Response::ResponseCode Server_AbstractParticipant::cmdReverseTurn(const Command_
ResponseContainer & /*rc*/,
GameEventStorage &ges)
{
if (!judge) {
if (spectator) {
if (!isJudge()) {
if (isSpectator()) {
return Response::RespFunctionNotAllowed;
}
@ -577,7 +577,7 @@ void Server_AbstractParticipant::setUserInterface(Server_AbstractUserInterface *
void Server_AbstractParticipant::disconnectClient()
{
bool isRegistered = userInfo->user_level() & ServerInfo_User::IsRegistered;
if (!isRegistered || spectator) {
if (!isRegistered || isSpectator()) {
game->removeParticipant(this, Event_Leave::USER_DISCONNECTED);
} else {
setUserInterface(nullptr);

View file

@ -2,6 +2,7 @@
#define ABSTRACT_PARTICIPANT_H
#include "../serverinfo_user_container.h"
#include "role_info.h"
#include "server_arrowtarget.h"
#include <QMutex>
@ -61,8 +62,7 @@ protected:
Server_AbstractUserInterface *userInterface;
int pingTime;
int playerId;
bool spectator;
bool judge;
RoleInfo roleInfo;
virtual void getPlayerProperties(ServerInfo_PlayerProperties &result);
mutable QMutex playerMutex;
@ -70,7 +70,7 @@ public:
Server_AbstractParticipant(Server_Game *_game,
int _playerId,
const ServerInfo_User &_userInfo,
bool _judge,
const RoleInfo &_roleInfo,
Server_AbstractUserInterface *_handler);
~Server_AbstractParticipant() override;
virtual void prepareDestroy()
@ -91,11 +91,11 @@ public:
}
bool isSpectator() const
{
return spectator;
return roleInfo.isSpectator;
}
bool isJudge() const
{
return judge;
return roleInfo.isJudge;
}
Server_Game *getGame() const
{

View file

@ -81,10 +81,9 @@ Server_AbstractPlayer::Server_AbstractPlayer(Server_Game *_game,
const ServerInfo_User &_userInfo,
bool _judge,
Server_AbstractUserInterface *_userInterface)
: Server_AbstractParticipant(_game, _playerId, _userInfo, _judge, _userInterface), conceded(false), deck(nullptr),
sideboardLocked(true), readyStart(false), nextCardId(0)
: Server_AbstractParticipant(_game, _playerId, _userInfo, {.isJudge = _judge}, _userInterface), conceded(false),
deck(nullptr), sideboardLocked(true), readyStart(false), nextCardId(0)
{
spectator = false;
}
Server_AbstractPlayer::~Server_AbstractPlayer() = default;
@ -295,7 +294,7 @@ Response::ResponseCode Server_AbstractPlayer::moveCard(GameEventStorage &ges,
{
// Disallow controller change to other zones than the table.
if (((targetzone->getType() != ServerInfo_Zone::PublicZone) || !targetzone->hasCoords()) &&
(startzone->getPlayer() != targetzone->getPlayer()) && !judge) {
(startzone->getPlayer() != targetzone->getPlayer()) && !isJudge()) {
return Response::RespContextError;
}
@ -808,7 +807,7 @@ Server_AbstractPlayer::cmdMoveCard(const Command_MoveCard &cmd, ResponseContaine
return Response::RespNameNotFound;
}
if ((startPlayer != this) && (!startZone->getPlayersWithWritePermission().contains(playerId)) && !judge) {
if ((startPlayer != this) && (!startZone->getPlayersWithWritePermission().contains(playerId)) && !isJudge()) {
return Response::RespContextError;
}
@ -821,7 +820,7 @@ Server_AbstractPlayer::cmdMoveCard(const Command_MoveCard &cmd, ResponseContaine
return Response::RespNameNotFound;
}
if ((startPlayer != this) && (targetPlayer != this) && !judge) {
if ((startPlayer != this) && (targetPlayer != this) && !isJudge()) {
return Response::RespContextError;
}
@ -1598,7 +1597,7 @@ Server_AbstractPlayer::cmdRevealCards(const Command_RevealCards &cmd, ResponseCo
zone->addWritePermission(cmd.player_id());
}
if (judge) {
if (isJudge()) {
ges.setOverwriteOwnership(true);
}

View file

@ -41,6 +41,7 @@
#include "libcockatrice/protocol/pb/serverinfo_replay_match.pb.h"
#include "libcockatrice/protocol/pb/serverinfo_user.pb.h"
#include "libcockatrice/protocol/pb/session_event.pb.h"
#include "role_info.h"
#include "server_abstract_player.h"
#include "server_abstractuserinterface.h"
#include "server_arrow.h"
@ -479,9 +480,8 @@ void Server_Game::stopGameIfFinished()
Response::ResponseCode Server_Game::checkJoin(ServerInfo_User *user,
const QString &_password,
bool spectator,
bool overrideRestrictions,
bool asJudge)
const RoleInfo &roleInfo,
bool overrideRestrictions)
{
Server_DatabaseInterface *databaseInterface = room->getServer()->getDatabaseInterface();
for (auto *participant : participants.values()) {
@ -490,11 +490,11 @@ Response::ResponseCode Server_Game::checkJoin(ServerInfo_User *user,
}
}
if (asJudge && !(user->user_level() & ServerInfo_User::IsJudge)) {
if (roleInfo.isJudge && !(user->user_level() & ServerInfo_User::IsJudge)) {
return Response::RespUserLevelTooLow;
}
if (!(overrideRestrictions && (user->user_level() & (ServerInfo_User::IsModerator | ServerInfo_User::IsJudge)))) {
if ((_password != password) && !(spectator && !spectatorsNeedPassword)) {
if ((_password != password) && !(roleInfo.isSpectator && !spectatorsNeedPassword)) {
return Response::RespWrongPassword;
}
if (!(user->user_level() & ServerInfo_User::IsRegistered) && onlyRegistered) {
@ -510,13 +510,13 @@ Response::ResponseCode Server_Game::checkJoin(ServerInfo_User *user,
QString::fromStdString(user->name()))) {
return Response::RespInIgnoreList;
}
if (spectator) {
if (roleInfo.isSpectator) {
if (!spectatorsAllowed) {
return Response::RespSpectatorsNotAllowed;
}
}
}
if (!spectator && (gameStarted || (getPlayerCount() >= getMaxPlayers()))) {
if (!roleInfo.isSpectator && (gameStarted || (getPlayerCount() >= getMaxPlayers()))) {
return Response::RespGameFull;
}
@ -537,19 +537,18 @@ bool Server_Game::containsUser(const QString &userName) const
void Server_Game::addPlayer(Server_AbstractUserInterface *userInterface,
ResponseContainer &rc,
bool spectator,
bool judge,
const RoleInfo &roleInfo,
bool broadcastUpdate)
{
QMutexLocker locker(&gameMutex);
Server_AbstractParticipant *newParticipant;
if (spectator) {
if (roleInfo.isSpectator) {
newParticipant = new Server_Spectator(this, nextPlayerId++, userInterface->copyUserInfo(true, true, true),
judge, userInterface);
roleInfo.isJudge, userInterface);
} else {
newParticipant = new Server_Player(this, nextPlayerId++, userInterface->copyUserInfo(true, true, true), judge,
userInterface);
newParticipant = new Server_Player(this, nextPlayerId++, userInterface->copyUserInfo(true, true, true),
roleInfo.isJudge, userInterface);
}
newParticipant->moveToThread(thread());
@ -560,7 +559,7 @@ void Server_Game::addPlayer(Server_AbstractUserInterface *userInterface,
const QString playerName = QString::fromStdString(newParticipant->getUserInfo()->name());
participants.insert(newParticipant->getPlayerId(), newParticipant);
if (spectator) {
if (roleInfo.isSpectator) {
allSpectatorsEver.insert(playerName);
} else {
allPlayersEver.insert(playerName);
@ -579,7 +578,7 @@ void Server_Game::addPlayer(Server_AbstractUserInterface *userInterface,
emit gameInfoChanged(gameInfo);
}
if ((newParticipant->getUserInfo()->user_level() & ServerInfo_User::IsRegistered) && !spectator) {
if ((newParticipant->getUserInfo()->user_level() & ServerInfo_User::IsRegistered) && !roleInfo.isSpectator) {
room->getServer()->addPersistentPlayer(playerName, room->getId(), gameId, newParticipant->getPlayerId());
}
@ -871,6 +870,13 @@ void Server_Game::createGameJoinedEvent(Server_AbstractParticipant *joiningParti
}
}
bool Server_Game::canSeePrivateEventsFor(const Server_AbstractParticipant *participant, int privatePlayerId) const
{
return participant->getPlayerId() == privatePlayerId // same player
|| participant->isJudge() // judges see everything
|| (participant->isSpectator() && spectatorsSeeEverything); // spectators see everything
}
void Server_Game::sendGameEventContainer(GameEventContainer *cont,
GameEventStorageItem::EventRecipients recipients,
int privatePlayerId)
@ -879,8 +885,7 @@ void Server_Game::sendGameEventContainer(GameEventContainer *cont,
cont->set_game_id(gameId);
for (auto *participant : participants.values()) {
const bool playerPrivate = (participant->getPlayerId() == privatePlayerId) || participant->isJudge() ||
(participant->isSpectator() && spectatorsSeeEverything);
const bool playerPrivate = canSeePrivateEventsFor(participant, privatePlayerId);
if ((recipients.testFlag(GameEventStorageItem::SendToPrivate) && playerPrivate) ||
(recipients.testFlag(GameEventStorageItem::SendToOthers) && !playerPrivate)) {
participant->sendGameEvent(*cont);

View file

@ -62,6 +62,7 @@ class Message;
} // namespace protobuf
} // namespace google
struct GameConfig;
struct RoleInfo;
class Server_Game : public QObject
{
@ -109,6 +110,7 @@ private:
bool omniscient,
bool withUserInfo);
void storeGameInformation();
bool canSeePrivateEventsFor(const Server_AbstractParticipant *participant, int privatePlayerId) const;
signals:
void sigStartGameIfReady(bool override);
void gameInfoChanged(ServerInfo_Game gameInfo);
@ -188,12 +190,11 @@ public:
return shareDecklistsOnLoad;
}
Response::ResponseCode
checkJoin(ServerInfo_User *user, const QString &_password, bool spectator, bool overrideRestrictions, bool asJudge);
checkJoin(ServerInfo_User *user, const QString &_password, const RoleInfo &roleInfo, bool overrideRestrictions);
bool containsUser(const QString &userName) const;
void addPlayer(Server_AbstractUserInterface *userInterface,
ResponseContainer &rc,
bool spectator,
bool judge,
const RoleInfo &roleInfo,
bool broadcastUpdate = true);
void removeParticipant(Server_AbstractParticipant *participant, Event_Leave::LeaveReason reason);
void removeArrowsRelatedToPlayer(GameEventStorage &ges, Server_AbstractPlayer *player);

View file

@ -573,7 +573,7 @@ Server_Player::cmdNextTurn(const Command_NextTurn & /*cmd*/, ResponseContainer &
return Response::RespGameNotStarted;
}
if (conceded && !judge) {
if (conceded && !isJudge()) {
return Response::RespContextError;
}
@ -589,7 +589,7 @@ Response::ResponseCode Server_Player::cmdSetActivePhase(const Command_SetActiveP
return Response::RespGameNotStarted;
}
if (!judge) {
if (!isJudge()) {
if (conceded) {
return Response::RespContextError;
}
@ -623,7 +623,7 @@ Response::ResponseCode
Server_Player::cmdReverseTurn(const Command_ReverseTurn &cmd, ResponseContainer &rc, GameEventStorage &ges)
{
if (!judge && conceded) {
if (!isJudge() && conceded) {
return Response::RespContextError;
}
return Server_AbstractParticipant::cmdReverseTurn(cmd, rc, ges);

View file

@ -11,7 +11,6 @@ Server_Spectator::Server_Spectator(Server_Game *_game,
const ServerInfo_User &_userInfo,
bool _judge,
Server_AbstractUserInterface *_userInterface)
: Server_AbstractParticipant(_game, _playerId, _userInfo, _judge, _userInterface)
: Server_AbstractParticipant(_game, _playerId, _userInfo, {.isJudge = _judge, .isSpectator = true}, _userInterface)
{
spectator = true;
}

View file

@ -131,6 +131,16 @@ AuthenticationResult Server::loginUser(Server_ProtocolHandler *session,
return authState;
}
if (authState == PasswordChangeRequired) {
// The credentials were right, but the account must choose a new password before it may
// use the server. Give the session an identity so it can complete that change, without
// registering a user session or announcing the user as logged in.
ServerInfo_User pendingData = databaseInterface->getUserData(name, true);
pendingData.set_address(session->getAddress().toStdString());
session->setUserInfo(pendingData);
return authState;
}
ServerInfo_User data = databaseInterface->getUserData(name, true);
data.set_address(session->getAddress().toStdString());
name = QString::fromStdString(data.name()); // Compensate for case indifference
@ -296,7 +306,9 @@ void Server::removeClient(Server_ProtocolHandler *client)
QWriteLocker locker(&clientsLock);
clients.removeAt(clientIndex);
ServerInfo_User *data = client->getUserInfo();
if (data) {
// A session that never completed login (for example one that still owes a password change)
// has no session id: it was never announced as joined, so it must not be announced as leaving.
if (data && data->has_session_id()) {
Event_UserLeft event;
event.set_name(data->name());
SessionEvent *se = Server_ProtocolHandler::prepareSessionEvent(event);
@ -311,12 +323,10 @@ void Server::removeClient(Server_ProtocolHandler *client)
users.remove(QString::fromStdString(data->name()));
qDebug() << "Server::removeClient: name=" << QString::fromStdString(data->name());
if (data->has_session_id()) {
const qint64 sessionId = data->session_id();
usersBySessionId.remove(sessionId);
emit endSession(sessionId);
qDebug() << "closed session id:" << sessionId;
}
const qint64 sessionId = data->session_id();
usersBySessionId.remove(sessionId);
emit endSession(sessionId);
qDebug() << "closed session id:" << sessionId;
}
qDebug() << "Server::removeClient: removed" << (void *)client << ";" << clients.size() << "clients; "
<< users.size() << "users left";
@ -330,7 +340,9 @@ QList<QString> Server::getOnlineModeratorList() const
ServerInfo_User *data = client->getUserInfo();
//! \todo This line should be updated in the event there is any type of new user level created.
if (data &&
// A session that still owes a forced password change has user info but no session id:
// it never joined, so it must not be reported as an online moderator either.
if (data && data->has_session_id() &&
(data->user_level() & ServerInfo_User::IsModerator || data->user_level() & ServerInfo_User::IsAdmin)) {
results << QString::fromStdString(data->name()).simplified();
}

View file

@ -64,8 +64,9 @@ Server_ProtocolHandler::Server_ProtocolHandler(Server *_server,
Server_DatabaseInterface *_databaseInterface,
QObject *parent)
: QObject(parent), Server_AbstractUserInterface(_server), deleted(false), databaseInterface(_databaseInterface),
authState(NotLoggedIn), usingRealPassword(false), acceptsUserListChanges(false), acceptsRoomListChanges(false),
idleClientWarningSent(false), timeRunning(0), lastDataReceived(0), lastActionReceived(0)
authState(NotLoggedIn), usingRealPassword(false), passwordChangePending(false), acceptsUserListChanges(false),
acceptsRoomListChanges(false), idleClientWarningSent(false), timeRunning(0), lastDataReceived(0),
lastActionReceived(0)
{
connect(server, &Server::pingClockTimeout, this, &Server_ProtocolHandler::pingClockTimeout);
@ -212,7 +213,10 @@ Response::ResponseCode Server_ProtocolHandler::processSessionCommandContainer(co
logDebugMessage(getSafeDebugString(sc));
}
const auto commandType = static_cast<SessionCommand::SessionCommandType>(num);
if (authState == NotLoggedIn && !isPreAuthSessionCommand(commandType)) {
if (passwordChangePending && commandType != SessionCommand::PING &&
commandType != SessionCommand::ACCOUNT_PASSWORD) {
resp = Response::RespFunctionNotAllowed;
} else if (authState == NotLoggedIn && !isPreAuthSessionCommand(commandType)) {
resp = Response::RespLoginNeeded;
} else {
switch (commandType) {
@ -489,6 +493,19 @@ void Server_ProtocolHandler::processCommandContainer(const CommandContainer &con
lastDataReceived = timeRunning;
// A session that still owes a forced password change may only reach the session family,
// where processSessionCommandContainer narrows it further to ping and the change itself.
// Every other family is rejected here, including a container that also carries a session
// command: the dispatch below prefers game/room over session, so testing only for the
// absence of a session command would let such a container through.
if (passwordChangePending &&
(cont.game_command_size() || cont.room_command_size() || cont.moderator_command_size() ||
cont.admin_command_size() || cont.developer_command_size())) {
sendResponseContainer(ResponseContainer(cont.has_cmd_id() ? cont.cmd_id() : -1),
Response::RespFunctionNotAllowed);
return;
}
ResponseContainer responseContainer(cont.has_cmd_id() ? cont.cmd_id() : -1);
Response::ResponseCode finalResponseCode;
@ -696,6 +713,11 @@ Response::ResponseCode Server_ProtocolHandler::cmdLogin(const Command_Login &cmd
server->recordFailedLogin(getAddress(), userName);
return Response::RespAccountNotActivated;
case PasswordChangeRequired:
// The credentials were right, but the account must choose a new password first.
// Keep the session authenticated just far enough for the client to do that:
// processCommandContainer refuses everything except the change itself.
authState = PasswordRight;
passwordChangePending = true;
return Response::RespPasswordChangeRequired;
default:
server->clearFailedLogins(getAddress(), userName);
@ -1119,7 +1141,8 @@ Server_ProtocolHandler::cmdCreateGame(const Command_CreateGame &cmd, Server_Room
auto *game = new Server_Game(config, room);
game->addPlayer(this, rc, asSpectator, asJudge, false);
RoleInfo roleInfo{.isJudge = asJudge, .isSpectator = asSpectator};
game->addPlayer(this, rc, roleInfo, false);
room->addGame(game);
return Response::RespOk;

View file

@ -46,6 +46,12 @@ protected:
Server_DatabaseInterface *databaseInterface;
AuthenticationResult authState;
bool usingRealPassword;
/**
* @brief True when the credentials were right but the account must choose a new password
* before it may use the server. Only ping, the password change and disconnecting
* are served while this is set.
*/
bool passwordChangePending;
bool acceptsUserListChanges;
bool acceptsRoomListChanges;
bool idleClientWarningSent;

View file

@ -1,5 +1,6 @@
#include "server_room.h"
#include "game/role_info.h"
#include "game/server_game.h"
#include "libcockatrice/protocol/pb/response.pb.h"
#include "libcockatrice/protocol/pb/room_event.pb.h"
@ -372,11 +373,11 @@ Response::ResponseCode Server_Room::processJoinGameCommand(const Command_JoinGam
QMutexLocker gameLocker(&game->gameMutex);
Response::ResponseCode result =
game->checkJoin(userInterface->getUserInfo(), QString::fromStdString(cmd.password()), cmd.spectator(),
cmd.override_restrictions(), cmd.join_as_judge());
RoleInfo roleInfo{.isJudge = cmd.join_as_judge(), .isSpectator = cmd.spectator()};
Response::ResponseCode result = game->checkJoin(
userInterface->getUserInfo(), QString::fromStdString(cmd.password()), roleInfo, cmd.override_restrictions());
if (result == Response::RespOk) {
game->addPlayer(userInterface, rc, cmd.spectator(), cmd.join_as_judge());
game->addPlayer(userInterface, rc, roleInfo);
}
return result;

View file

@ -87,14 +87,16 @@ message Command_ViewLogHistory {
extend DeveloperCommand {
optional Command_ViewLogHistory dev_ext = 1001;
}
optional string user_name = 1; // user that created message
optional string ip_address = 2; // ip address of user that created message
optional string game_name = 3; // client id of user that created the message
optional string game_id = 4; // game number the message was sent to
optional string message = 5; // raw message that was sent
repeated string log_location = 6; // destination of message (ex: main room, game room, private chat)
required uint32 date_range = 7; // the length of time (in minutes) to look back for
optional uint32 maximum_results = 8; // the maximum number of query results
optional string user_name = 1; // user that created message
optional string ip_address = 2; // ip address of user that created message
optional string game_name = 3; // client id of user that created the message
optional string game_id = 4; // game number the message was sent to
optional string message = 5; // raw message that was sent
repeated string log_location = 6; // destination of message (ex: main room, game room, private chat)
required uint32 date_range = 7; // the length of time (in minutes) to look back for
optional uint32 maximum_results = 8; // the maximum number of query results (page size when paginating)
optional uint32 offset = 9 [default = 0]; // rows to skip for pagination, ORDER BY log_time DESC
optional string before_time = 10; // page those rows against this `log_time` snapshot (newest first page row)
}
message Command_GrantReplayAccess {

View file

@ -7,4 +7,5 @@ message Response_ViewLogHistory {
optional Response_ViewLogHistory ext = 1015;
}
repeated ServerInfo_ChatMessage log_message = 1;
optional bool has_more = 2 [default = false]; // true when more rows exist past this page
}

View file

@ -1,7 +1,3 @@
set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTOUIC ON)
set(CMAKE_AUTORCC ON)
set(HEADERS libcockatrice/rng/rng_abstract.h libcockatrice/rng/rng_sfmt.h libcockatrice/rng/sfmt/SFMT.h)
add_library(

View file

@ -1,7 +1,3 @@
set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTOUIC ON)
set(CMAKE_AUTORCC ON)
set(HEADERS
libcockatrice/settings/appearance_settings.h
libcockatrice/settings/cache_storage_settings.h

View file

@ -5,16 +5,118 @@
#include <QDebug>
#include <QList>
#include <QMessageLogger>
#include <QSharedPointer>
#include <QSettings>
#include <QString>
#include <QVariant>
#include <qnamespace.h>
#include <algorithm>
#include <optional>
#include <utility>
class QObject;
namespace
{
constexpr auto SERVER_GROUP = "server";
constexpr auto SERVER_DETAILS_GROUP = "server_details";
constexpr auto SERVERS_ARRAY = "servers";
QString legacyKey(const char *field, int index)
{
return QStringLiteral("%1/%2%3")
.arg(QString::fromLatin1(SERVER_DETAILS_GROUP), QString::fromLatin1(field))
.arg(index);
}
//! Expects \a settings to be positioned on the server group.
QList<ServersSettings::ServerEntry> readServerArray(QSettings &settings)
{
QList<ServersSettings::ServerEntry> servers;
settings.beginGroup(SERVER_DETAILS_GROUP);
const int size = settings.beginReadArray(SERVERS_ARRAY);
servers.reserve(size);
for (int i = 0; i < size; ++i) {
settings.setArrayIndex(i);
ServersSettings::ServerEntry entry;
entry.saveName = settings.value("saveName").toString();
entry.host = settings.value("server").toString();
entry.port = settings.value("port").toString();
entry.username = settings.value("username").toString();
entry.password = settings.value("password").toString();
entry.savePassword = settings.value("savePassword").toBool();
entry.site = settings.value("site").toString();
servers.append(entry);
}
settings.endArray();
settings.endGroup();
return servers;
}
//! Expects \a settings to be positioned on the server group.
QList<ServersSettings::ServerEntry> readLegacyServers(QSettings &settings, int lastIndex)
{
QList<ServersSettings::ServerEntry> servers;
for (int i = 0; i <= lastIndex; ++i) {
ServersSettings::ServerEntry entry;
entry.saveName = settings.value(legacyKey("saveName", i)).toString();
entry.host = settings.value(legacyKey("server", i)).toString();
entry.port = settings.value(legacyKey("port", i)).toString();
entry.username = settings.value(legacyKey("username", i)).toString();
entry.password = settings.value(legacyKey("password", i)).toString();
entry.savePassword = settings.value(legacyKey("savePassword", i)).toBool();
entry.site = settings.value(legacyKey("site", i)).toString();
// Older builds could leave the unused index 0 behind.
if (!entry.saveName.isEmpty() || !entry.host.isEmpty()) {
servers.append(entry);
}
}
return servers;
}
void writeServerArray(QSettings &settings, const QList<ServersSettings::ServerEntry> &servers)
{
settings.beginGroup(SERVER_DETAILS_GROUP);
// beginWriteArray() never drops entries past the new size, so without this
// a removed profile would keep its slot - including its saved password -
// in servers.ini forever. The legacy keys live beside the array, not in it.
settings.remove(SERVERS_ARRAY);
settings.beginWriteArray(SERVERS_ARRAY, servers.size());
for (int i = 0; i < servers.size(); ++i) {
settings.setArrayIndex(i);
const ServersSettings::ServerEntry &entry = servers.at(i);
settings.setValue("saveName", entry.saveName);
settings.setValue("server", entry.host);
settings.setValue("port", entry.port);
settings.setValue("username", entry.username);
settings.setValue("password", entry.password);
settings.setValue("savePassword", entry.savePassword);
settings.setValue("site", entry.site);
}
settings.endArray();
settings.endGroup();
}
} // namespace
ServersSettings::ServersSettings(const QString &settingPath, QObject *parent)
: SettingsManager(settingPath + "servers.ini", "server", QString(), parent)
: SettingsManager(settingPath + "servers.ini", SERVER_GROUP, QString(), parent)
{
migrateLegacyServers();
}
QList<ServersSettings::ServerEntry> ServersSettings::getServers() const
{
return loadServers();
}
std::optional<ServersSettings::ServerEntry> ServersSettings::getServer(int index) const
{
const QList<ServerEntry> servers = loadServers();
if (index < 0 || index >= servers.size()) {
return std::nullopt;
}
return servers.at(index);
}
void ServersSettings::setPreviousHostLogin(int previous)
@ -45,16 +147,14 @@ void ServersSettings::setPrevioushostName(const QString &name)
QString ServersSettings::getSaveName(QString defaultname)
{
int index = getPrevioushostindex(getPrevioushostName());
QVariant saveName = getValue(QString("saveName%1").arg(index), "server", "server_details");
return saveName == QVariant() ? std::move(defaultname) : saveName.toString();
const auto server = getServer(getPrevioushostindex(getPrevioushostName()));
return server ? server->saveName : std::move(defaultname);
}
QString ServersSettings::getSite(QString defaultSite)
{
int index = getPrevioushostindex(getPrevioushostName());
QVariant site = getValue(QString("site%1").arg(index), "server", "server_details");
return site == QVariant() ? std::move(defaultSite) : site.toString();
const auto server = getServer(getPrevioushostindex(getPrevioushostName()));
return server ? server->site : std::move(defaultSite);
}
QString ServersSettings::getPrevioushostName() const
@ -65,10 +165,9 @@ QString ServersSettings::getPrevioushostName() const
int ServersSettings::getPrevioushostindex(const QString &saveName) const
{
int size = getValue("totalServers", "server", "server_details").toInt();
for (int i = 0; i <= size; ++i) {
if (saveName == getValue(QString("saveName%1").arg(i), "server", "server_details").toString()) {
const QList<ServerEntry> servers = loadServers();
for (int i = 0; i < servers.size(); ++i) {
if (servers.at(i).saveName == saveName) {
return i;
}
}
@ -78,43 +177,38 @@ int ServersSettings::getPrevioushostindex(const QString &saveName) const
QString ServersSettings::getHostname(QString defaultHost) const
{
int index = getPrevioushostindex(getPrevioushostName());
QVariant hostname = getValue(QString("server%1").arg(index), "server", "server_details");
return hostname == QVariant() ? std::move(defaultHost) : hostname.toString();
const auto server = getServer(getPrevioushostindex(getPrevioushostName()));
return server ? server->host : std::move(defaultHost);
}
QString ServersSettings::getPort(QString defaultPort) const
{
int index = getPrevioushostindex(getPrevioushostName());
QVariant port = getValue(QString("port%1").arg(index), "server", "server_details");
qCDebug(ServersSettingsLog) << "getPort() index = " << index << " port.val = " << port.toString();
return port == QVariant() ? std::move(defaultPort) : port.toString();
const auto server = getServer(getPrevioushostindex(getPrevioushostName()));
qCDebug(ServersSettingsLog) << "getPort() found server = " << server.has_value();
return server ? server->port : std::move(defaultPort);
}
QString ServersSettings::getPlayerName(QString defaultName) const
{
int index = getPrevioushostindex(getPrevioushostName());
QVariant name = getValue(QString("username%1").arg(index), "server", "server_details");
qCDebug(ServersSettingsLog) << "getPlayerName() index = " << index << " name.val = " << name.toString();
return name == QVariant() ? std::move(defaultName) : name.toString();
const auto server = getServer(getPrevioushostindex(getPrevioushostName()));
qCDebug(ServersSettingsLog) << "getPlayerName() found server = " << server.has_value();
return server ? server->username : std::move(defaultName);
}
QString ServersSettings::getPassword()
{
int index = getPrevioushostindex(getPrevioushostName());
if (getSavePassword()) {
return getValue(QString("password%1").arg(index), "server", "server_details").toString();
const auto server = getServer(getPrevioushostindex(getPrevioushostName()));
if (!server || !server->savePassword) {
return QString();
}
return QString();
return server->password;
}
bool ServersSettings::getSavePassword() const
{
int index = getPrevioushostindex(getPrevioushostName());
bool save = getValue(QString("savePassword%1").arg(index), "server", "server_details").toBool();
return save;
const auto server = getServer(getPrevioushostindex(getPrevioushostName()));
return server && server->savePassword;
}
void ServersSettings::setAutoConnect(int autoconnect)
@ -157,15 +251,19 @@ void ServersSettings::setFPPlayerName(QString playerName)
void ServersSettings::setServerPassword(const QString &saveName, const QString &password)
{
const int index = getPrevioushostindex(saveName);
if (index >= 0) {
setValue(password, QString("password%1").arg(index), "server", "server_details");
} else {
// A credential write that silently no-ops is hard to diagnose from a
// bug report; surface a mismatched profile name instead.
qCWarning(ServersSettingsLog) << "setServerPassword() could not find profile:" << saveName
<< "- password not saved";
QList<ServerEntry> servers = loadServers();
for (ServerEntry &server : servers) {
if (server.saveName == saveName) {
server.password = password;
storeServers(servers);
return;
}
}
// A credential write that silently no-ops is hard to diagnose from a bug
// report; surface a mismatched profile name instead.
qCWarning(ServersSettingsLog) << "setServerPassword() could not find profile:" << saveName
<< "- password not saved";
}
QString ServersSettings::getFPPlayerName(QString defaultName) const
@ -202,62 +300,29 @@ void ServersSettings::addNewServer(const QString &saveName,
return;
}
int index = getValue("totalServers", "server", "server_details").toInt() + 1;
setValue(saveName, QString("saveName%1").arg(index), "server", "server_details");
setValue(serv, QString("server%1").arg(index), "server", "server_details");
setValue(port, QString("port%1").arg(index), "server", "server_details");
setValue(username, QString("username%1").arg(index), "server", "server_details");
setValue(savePassword, QString("savePassword%1").arg(index), "server", "server_details");
setValue(index, "totalServers", "server", "server_details");
setValue(password, QString("password%1").arg(index), "server", "server_details");
setValue(site, QString("site%1").arg(index), "server", "server_details");
QList<ServerEntry> servers = loadServers();
ServerEntry entry;
entry.saveName = saveName;
entry.host = serv;
entry.port = port;
entry.username = username;
entry.password = password;
entry.savePassword = savePassword;
entry.site = site;
servers.append(entry);
storeServers(servers);
}
void ServersSettings::removeServer(QString servAddr)
{
int size = getValue("totalServers", "server", "server_details").toInt();
bool found = false;
for (int i = 0; i <= size; ++i) {
if (!found) {
// find entry and overwrite it
if (servAddr == getValue(QString("server%1").arg(i), "server", "server_details").toString()) {
found = true;
}
} else {
// move all other entries after it one back, overwriting the previous one
int previous = i - 1; // we delete only one entry
setValue(getValue(QString("server%1").arg(i), "server", "server_details"),
QString("server%1").arg(previous), "server", "server_details");
setValue(getValue(QString("port%1").arg(i), "server", "server_details"), QString("port%1").arg(previous),
"server", "server_details");
setValue(getValue(QString("username%1").arg(i), "server", "server_details"),
QString("username%1").arg(previous), "server", "server_details");
setValue(getValue(QString("savePassword%1").arg(i), "server", "server_details"),
QString("savePassword%1").arg(previous), "server", "server_details");
setValue(getValue(QString("password%1").arg(i), "server", "server_details"),
QString("password%1").arg(previous), "server", "server_details");
setValue(getValue(QString("saveName%1").arg(i), "server", "server_details"),
QString("saveName%1").arg(previous), "server", "server_details");
setValue(getValue(QString("site%1").arg(i), "server", "server_details"), QString("site%1").arg(previous),
"server", "server_details");
QList<ServerEntry> servers = loadServers();
for (int i = 0; i < servers.size(); ++i) {
if (servers.at(i).host == servAddr) {
servers.removeAt(i);
storeServers(servers);
return;
}
}
// if we have deleted an entry, adjust the total
if (found) {
setValue(size - 1, "totalServers", "server", "server_details");
// delete last value
deleteValue(QString("server%1").arg(size), "server", "server_details");
deleteValue(QString("port%1").arg(size), "server", "server_details");
deleteValue(QString("username%1").arg(size), "server", "server_details");
deleteValue(QString("savePassword%1").arg(size), "server", "server_details");
deleteValue(QString("password%1").arg(size), "server", "server_details");
deleteValue(QString("saveName%1").arg(size), "server", "server_details");
deleteValue(QString("site%1").arg(size), "server", "server_details");
}
}
/**
@ -265,23 +330,23 @@ void ServersSettings::removeServer(QString servAddr)
*/
bool ServersSettings::updateExistingServerWithoutLoss(QString saveName, QString serv, QString port, QString site)
{
int size = getValue("totalServers", "server", "server_details").toInt();
for (int i = 0; i <= size; ++i) {
if (serv == getValue(QString("server%1").arg(i), "server", "server_details").toString()) {
if (!port.isEmpty()) {
setValue(port, QString("port%1").arg(i), "server", "server_details");
}
if (!site.isEmpty()) {
setValue(site, QString("site%1").arg(i), "server", "server_details");
}
setValue(saveName, QString("saveName%1").arg(i), "server", "server_details");
return true;
QList<ServerEntry> servers = loadServers();
for (ServerEntry &server : servers) {
if (server.host != serv) {
continue;
}
if (!port.isEmpty()) {
server.port = port;
}
if (!site.isEmpty()) {
server.site = site;
}
server.saveName = saveName;
storeServers(servers);
return true;
}
return false;
}
@ -293,17 +358,13 @@ bool ServersSettings::updateExistingServer(QString saveName,
bool savePassword,
QString site)
{
int size = getValue("totalServers", "server", "server_details").toInt();
for (int i = 0; i <= size; ++i) {
if (serv == getValue(QString("server%1").arg(i), "server", "server_details").toString()) {
setValue(port, QString("port%1").arg(i), "server", "server_details");
updateServerFields(i, saveName, username, password, savePassword, site);
return true;
}
const int index = findServerIndex(serv, port);
if (index < 0) {
return false;
}
return false;
updateServerFields(index, saveName, username, password, savePassword, site);
return true;
}
void ServersSettings::updateServerFields(int index,
@ -313,33 +374,33 @@ void ServersSettings::updateServerFields(int index,
bool savePassword,
const QString &site)
{
QList<ServerEntry> servers = loadServers();
if (index < 0 || index >= servers.size()) {
return;
}
ServerEntry &server = servers[index];
if (!username.isEmpty()) {
setValue(username, QString("username%1").arg(index), "server", "server_details");
server.username = username;
}
if (savePassword && !password.isEmpty()) {
setValue(password, QString("password%1").arg(index), "server", "server_details");
server.password = password;
} else {
setValue(QString(), QString("password%1").arg(index), "server", "server_details");
server.password.clear();
}
if (!site.isEmpty()) {
setValue(site, QString("site%1").arg(index), "server", "server_details");
server.site = site;
}
setValue(savePassword, QString("savePassword%1").arg(index), "server", "server_details");
setValue(saveName, QString("saveName%1").arg(index), "server", "server_details");
server.savePassword = savePassword;
server.saveName = saveName;
storeServers(servers);
}
int ServersSettings::findServerIndex(const QString &host, const QString &port) const
{
int size = getValue("totalServers", "server", "server_details").toInt();
for (int i = 0; i <= size; ++i) {
QString storedHost = getValue(QString("server%1").arg(i), "server", "server_details").toString();
QString storedPort = getValue(QString("port%1").arg(i), "server", "server_details").toString();
if (storedHost == host && storedPort == port) {
const QList<ServerEntry> servers = loadServers();
for (int i = 0; i < servers.size(); ++i) {
if (servers.at(i).host == host && servers.at(i).port == port) {
return i;
}
}
@ -349,12 +410,9 @@ int ServersSettings::findServerIndex(const QString &host, const QString &port) c
int ServersSettings::findHostIndex(const QString &host) const
{
int size = getValue("totalServers", "server", "server_details").toInt();
for (int i = 0; i <= size; ++i) {
QString storedHost = getValue(QString("server%1").arg(i), "server", "server_details").toString();
if (storedHost.compare(host, Qt::CaseInsensitive) == 0) {
const QList<ServerEntry> servers = loadServers();
for (int i = 0; i < servers.size(); ++i) {
if (servers.at(i).host.compare(host, Qt::CaseInsensitive) == 0) {
return i;
}
}
@ -364,29 +422,100 @@ int ServersSettings::findHostIndex(const QString &host) const
bool ServersSettings::hasUsername(const QString &host, const QString &port) const
{
int index = findServerIndex(host, port);
if (index < 0) {
return false;
}
QString user = getValue(QString("username%1").arg(index), "server", "server_details").toString();
return !user.isEmpty();
const auto server = getServer(findServerIndex(host, port));
return server && !server->username.isEmpty();
}
bool ServersSettings::hasCredentials(const QString &host, const QString &port) const
{
int index = findServerIndex(host, port);
if (index < 0) {
return false;
}
bool save = getValue(QString("savePassword%1").arg(index), "server", "server_details").toBool();
QString password = getValue(QString("password%1").arg(index), "server", "server_details").toString();
return save && !password.isEmpty();
const auto server = getServer(findServerIndex(host, port));
return server && server->savePassword && !server->password.isEmpty();
}
bool ServersSettings::hasLoginData(const QString &host, const QString &port) const
{
return hasUsername(host, port) && hasCredentials(host, port);
}
QList<ServersSettings::ServerEntry> ServersSettings::loadServers() const
{
QSettings settings = getSettings();
settings.beginGroup(SERVER_GROUP);
const QList<ServerEntry> servers = readServerArray(settings);
settings.endGroup();
return servers;
}
void ServersSettings::storeServers(const QList<ServerEntry> &servers)
{
batchWrite([&servers](QSettings &settings) {
settings.beginGroup(SERVER_GROUP);
writeServerArray(settings, servers);
settings.endGroup();
});
}
void ServersSettings::migrateLegacyServers()
{
{
QSettings settings = getSettings();
settings.beginGroup(SERVER_GROUP);
// Only builds that predate the array format write this marker. Once it
// is gone there is nothing left to import and no reason to touch the
// file (a sync on every start is a needless open-and-write).
const bool hasLegacy = settings.contains(QStringLiteral("%1/totalServers").arg(SERVER_DETAILS_GROUP));
settings.endGroup();
if (!hasLegacy) {
return;
}
}
const QSettings::Status status = batchWrite([](QSettings &settings) {
settings.beginGroup(SERVER_GROUP);
const int lastLegacyIndex =
settings.value(QStringLiteral("%1/totalServers").arg(SERVER_DETAILS_GROUP), -1).toInt();
QList<ServersSettings::ServerEntry> servers = readServerArray(settings);
// An older build can write the legacy keys again after the array
// migration, so the array is not automatically complete: append every
// legacy profile the array does not already hold instead of dropping it.
int imported = 0;
const QList<ServersSettings::ServerEntry> legacyServers = readLegacyServers(settings, lastLegacyIndex);
for (const ServersSettings::ServerEntry &entry : legacyServers) {
const bool known =
std::any_of(servers.cbegin(), servers.cend(), [&entry](const ServersSettings::ServerEntry &existing) {
return existing.host == entry.host && existing.port == entry.port;
});
if (!known) {
servers.append(entry);
++imported;
}
}
writeServerArray(settings, servers);
if (imported > 0) {
qCDebug(ServersSettingsLog) << "Migrated" << imported << "server profiles to the array format";
}
// The array (freshly written or already present) is authoritative now.
for (int i = 0; i <= lastLegacyIndex; ++i) {
settings.remove(legacyKey("saveName", i));
settings.remove(legacyKey("server", i));
settings.remove(legacyKey("port", i));
settings.remove(legacyKey("username", i));
settings.remove(legacyKey("password", i));
settings.remove(legacyKey("savePassword", i));
settings.remove(legacyKey("site", i));
}
settings.remove(QStringLiteral("%1/totalServers").arg(SERVER_DETAILS_GROUP));
settings.endGroup();
});
if (status != QSettings::NoError) {
qCWarning(ServersSettingsLog) << "Failed to migrate server profiles:" << status;
}
}

View file

@ -9,9 +9,11 @@
#include "settings_manager.h"
#include <QList>
#include <QLoggingCategory>
#include <QString>
#include <QStringList>
#include <optional>
#include <qtmetamacros.h>
class QObject;
@ -27,6 +29,22 @@ class ServersSettings : public SettingsManager
friend class SettingsCache;
public:
struct ServerEntry
{
QString saveName;
QString host;
QString port;
QString username;
QString password;
bool savePassword = false;
QString site;
};
explicit ServersSettings(const QString &settingPath, QObject *parent = nullptr);
QList<ServerEntry> getServers() const;
std::optional<ServerEntry> getServer(int index) const;
int getPreviousHostLogin() const;
int getPrevioushostindex(const QString &) const;
QStringList getPreviousHostList() const;
@ -88,8 +106,11 @@ public:
bool getClearDebugLogStatus(bool abDefaultValue) const;
private:
explicit ServersSettings(const QString &settingPath, QObject *parent = nullptr);
ServersSettings(const ServersSettings & /*other*/);
QList<ServerEntry> loadServers() const;
void storeServers(const QList<ServerEntry> &servers);
void migrateLegacyServers();
};
#endif // SERVERSSETTINGS_H

View file

@ -193,13 +193,12 @@ QVariant SettingsManager::getValue(const QString &name,
return value;
}
void SettingsManager::batchWrite(std::function<void(QSettings &)> batchWriteFunction)
QSettings::Status SettingsManager::batchWrite(std::function<void(QSettings &)> batchWriteFunction)
{
auto settings = getSettings();
settings.setAtomicSyncRequired(false);
batchWriteFunction(settings);
settings.sync(); // single flush
settings.setAtomicSyncRequired(true);
return settings.status();
}
/**

View file

@ -28,7 +28,9 @@ public:
QVariant getValue(const QString &name, const QString &group, const QString &subGroup = QString()) const;
QVariant
getValue(const QString &name, const QString &group, const QString &subGroup, const QVariant &defaultValue) const;
void batchWrite(std::function<void(QSettings &)> batchWriteFunction);
//! \brief Runs \a batchWriteFunction on a temporary QSettings and syncs once.
//! \return the status of the underlying file after the final sync.
QSettings::Status batchWrite(std::function<void(QSettings &)> batchWriteFunction);
void sync();

View file

@ -1,10 +1,6 @@
cmake_minimum_required(VERSION 3.16)
project(Utility VERSION "${PROJECT_VERSION_MAJOR}.${PROJECT_VERSION_MINOR}.${PROJECT_VERSION_PATCH}")
set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTOUIC ON)
set(CMAKE_AUTORCC ON)
set(UTILITY_SOURCES
libcockatrice/utility/cryptoutil.cpp
libcockatrice/utility/expression.cpp

View file

@ -62,9 +62,11 @@ QByteArray PasswordHasher::deriveKey(const QString &password, const QByteArray &
{
QByteArray key(SCRYPT_VERIFIER_LENGTH, '\0');
const QByteArray passwordUtf8 = password.toUtf8();
// EVP_PBE_scrypt aborts unless maxmem covers the required working memory,
// which is roughly 128 * n * r bytes (plus the small Salsa20/8 block array).
const auto maxmem = static_cast<quint64>(128) * n * r + static_cast<quint64>(128) * r * p + 4096;
// EVP_PBE_scrypt aborts unless maxmem covers the required working memory: V is 128 * n *
// r bytes, B is 128 * r * p, and XY is 256 * r. Under-budgeting XY makes OpenSSL fail
// for r >= 17, which costParamsAreSane still accepts.
const auto maxmem =
static_cast<quint64>(128) * n * r + static_cast<quint64>(128) * r * p + static_cast<quint64>(256) * r + 4096;
if (EVP_PBE_scrypt(passwordUtf8.constData(), passwordUtf8.size(),
reinterpret_cast<const unsigned char *>(salt.constData()), salt.size(), n, r, p, maxmem,
reinterpret_cast<unsigned char *>(key.data()), key.size()) != 1) {

View file

@ -455,9 +455,11 @@ authentication_strictness=mixed
; the scrypt verifier, so old clients can still log in during the window below without
; the database holding a fast-crackable hash in the clear.
; Generate with: openssl rand -hex 32
; If unset, migrated accounts have no downgrade window: old clients must use a password
; reset to get back in. Rotating the key invalidates existing backups (they become
; unusable immediately; expired ones are still purged).
; If unset there is no downgrade path at all: a migrated account keeps only a scrypt
; verifier, and an administrator's password reset stores a verifier too, so pre-challenge
; clients cannot log in with the temporary password either. Set the key if you still serve
; them. Rotating the key invalidates existing backups (they become unusable immediately;
; expired ones are still purged).
legacy_backup_key=
; Days a legacy password backup may go unused before it is purged from the database.

View file

@ -58,9 +58,9 @@ void IslInterface::sharedCtor(const QSslCertificate &cert, const QSslKey &privat
socket->setLocalCertificate(cert);
socket->setPrivateKey(privateKey);
connect(socket, SIGNAL(readyRead()), this, SLOT(readClient()), Qt::QueuedConnection);
connect(socket, &QSslSocket::readyRead, this, &IslInterface::readClient, Qt::QueuedConnection);
connect(socket, &QAbstractSocket::errorOccurred, this, &IslInterface::catchSocketError);
connect(this, SIGNAL(outputBufferChanged()), this, SLOT(flushOutputBuffer()), Qt::QueuedConnection);
connect(this, &IslInterface::outputBufferChanged, this, &IslInterface::flushOutputBuffer, Qt::QueuedConnection);
}
IslInterface::IslInterface(int _socketDescriptor,

View file

@ -100,8 +100,10 @@ Servatrice_GameServer::Servatrice_GameServer(Servatrice *_server,
newThread->start();
server->watchWorkerThread(newThread);
QMetaObject::invokeMethod(newDatabaseInterface, "initDatabase", Qt::BlockingQueuedConnection,
Q_ARG(QSqlDatabase, _sqlDatabase));
QMetaObject::invokeMethod(
newDatabaseInterface,
[newDatabaseInterface, _sqlDatabase] { newDatabaseInterface->initDatabase(_sqlDatabase); },
Qt::BlockingQueuedConnection);
connectionPools.append(newPool);
}
@ -125,9 +127,10 @@ void Servatrice_GameServer::incomingConnection(qintptr socketDescriptor)
auto ssi = new TcpServerSocketInterface(server, pool->getDatabaseInterface());
ssi->moveToThread(pool->thread());
pool->addClient();
connect(ssi, SIGNAL(destroyed()), pool, SLOT(removeClient()));
connect(ssi, &QObject::destroyed, pool, &Servatrice_ConnectionPool::removeClient);
QMetaObject::invokeMethod(ssi, "initConnection", Qt::QueuedConnection, Q_ARG(int, socketDescriptor));
QMetaObject::invokeMethod(
ssi, [ssi, socketDescriptor] { ssi->initConnection(socketDescriptor); }, Qt::QueuedConnection);
}
Servatrice_ConnectionPool *Servatrice_GameServer::findLeastUsedConnectionPool()
@ -168,13 +171,15 @@ Servatrice_WebsocketGameServer::Servatrice_WebsocketGameServer(Servatrice *_serv
newThread->start();
server->watchWorkerThread(newThread);
QMetaObject::invokeMethod(newDatabaseInterface, "initDatabase", Qt::BlockingQueuedConnection,
Q_ARG(QSqlDatabase, _sqlDatabase));
QMetaObject::invokeMethod(
newDatabaseInterface,
[newDatabaseInterface, _sqlDatabase] { newDatabaseInterface->initDatabase(_sqlDatabase); },
Qt::BlockingQueuedConnection);
connectionPools.append(newPool);
connect(this, SIGNAL(newConnection()), this, SLOT(onNewConnection()));
}
connect(this, &QWebSocketServer::newConnection, this, &Servatrice_WebsocketGameServer::onNewConnection);
}
Servatrice_WebsocketGameServer::~Servatrice_WebsocketGameServer()
@ -199,9 +204,10 @@ void Servatrice_WebsocketGameServer::onNewConnection()
*/
// ssi->moveToThread(pool->thread());
pool->addClient();
connect(ssi, SIGNAL(destroyed()), pool, SLOT(removeClient()));
connect(ssi, &QObject::destroyed, pool, &Servatrice_ConnectionPool::removeClient);
QMetaObject::invokeMethod(ssi, "initConnection", Qt::QueuedConnection, Q_ARG(void *, nextPendingConnection()));
void *socket = nextPendingConnection();
QMetaObject::invokeMethod(ssi, [ssi, socket] { ssi->initConnection(socket); }, Qt::QueuedConnection);
}
Servatrice_ConnectionPool *Servatrice_WebsocketGameServer::findLeastUsedConnectionPool()
@ -224,14 +230,14 @@ Servatrice_ConnectionPool *Servatrice_WebsocketGameServer::findLeastUsedConnecti
void Servatrice_IslServer::incomingConnection(qintptr socketDescriptor)
{
auto thread = new QThread;
connect(thread, SIGNAL(finished()), thread, SLOT(deleteLater()));
connect(thread, &QThread::finished, thread, &QObject::deleteLater);
auto interface = new IslInterface(static_cast<int>(socketDescriptor), cert, privateKey, server);
interface->moveToThread(thread);
connect(interface, SIGNAL(destroyed()), thread, SLOT(quit()));
connect(interface, &QObject::destroyed, thread, &QThread::quit);
thread->start();
QMetaObject::invokeMethod(interface, "initServer", Qt::QueuedConnection);
QMetaObject::invokeMethod(interface, [interface] { interface->initServer(); }, Qt::QueuedConnection);
}
Servatrice::Servatrice(QObject *parent)
@ -585,15 +591,16 @@ bool Servatrice::initServer()
auto *thread = new QThread;
thread->setObjectName("isl_" + QString::number(prop.id));
connect(thread, SIGNAL(finished()), thread, SLOT(deleteLater()));
connect(thread, &QThread::finished, thread, &QObject::deleteLater);
IslInterface *interface = new IslInterface(prop.id, prop.hostname, prop.address.toString(),
prop.controlPort, prop.cert, cert, key, this);
interface->moveToThread(thread);
connect(interface, SIGNAL(destroyed()), thread, SLOT(quit()));
connect(interface, &QObject::destroyed, thread, &QThread::quit);
thread->start();
QMetaObject::invokeMethod(interface, "initClient", Qt::BlockingQueuedConnection);
QMetaObject::invokeMethod(
interface, [interface] { interface->initClient(); }, Qt::BlockingQueuedConnection);
}
qDebug() << "Starting ISL server on port" << getISLNetworkPort();
@ -610,18 +617,18 @@ bool Servatrice::initServer()
}
pingClock = new QTimer(this);
connect(pingClock, SIGNAL(timeout()), this, SIGNAL(pingClockTimeout()));
connect(pingClock, &QTimer::timeout, this, &Servatrice::pingClockTimeout);
pingClock->start(getClientKeepAlive() * 1000);
statusUpdateClock = new QTimer(this);
connect(statusUpdateClock, SIGNAL(timeout()), this, SLOT(statusUpdate()));
connect(statusUpdateClock, &QTimer::timeout, this, &Servatrice::statusUpdate);
if (getServerStatusUpdateTime() != 0) {
qDebug() << "Starting status update clock, interval" << getServerStatusUpdateTime() << "ms";
statusUpdateClock->start(getServerStatusUpdateTime());
}
deckShareCleanupClock = new QTimer(this);
connect(deckShareCleanupClock, SIGNAL(timeout()), this, SLOT(cleanupExpiredDeckShares()));
connect(deckShareCleanupClock, &QTimer::timeout, this, &Servatrice::cleanupExpiredDeckShares);
const int deckShareCleanupInterval = getDeckShareCleanupInterval();
if (deckShareCleanupInterval > 0) {
qDebug() << "Starting deck share cleanup clock, interval" << deckShareCleanupInterval << "ms";
@ -1038,7 +1045,7 @@ void Servatrice::scheduleShutdown(const QString &reason, int minutes)
nextShutdownMessageMinutes = shutdownMinutes;
if (minutes > 0) {
shutdownTimer = new QTimer;
connect(shutdownTimer, SIGNAL(timeout()), this, SLOT(shutdownTimeout()));
connect(shutdownTimer, &QTimer::timeout, this, &Servatrice::shutdownTimeout);
shutdownTimer->start(60000);
}
shutdownStateMutex.unlock();
@ -1101,25 +1108,19 @@ void Servatrice::addIslInterface(int _serverId, IslInterface *interface)
{
// Only call with islLock locked for writing
islInterfaces.insert(_serverId, interface);
connect(interface, SIGNAL(externalUserJoined(ServerInfo_User)), this, SLOT(externalUserJoined(ServerInfo_User)));
connect(interface, SIGNAL(externalUserLeft(QString)), this, SLOT(externalUserLeft(QString)));
connect(interface, SIGNAL(externalRoomUserJoined(int, ServerInfo_User)), this,
SLOT(externalRoomUserJoined(int, ServerInfo_User)));
connect(interface, SIGNAL(externalRoomUserLeft(int, QString)), this, SLOT(externalRoomUserLeft(int, QString)));
connect(interface, SIGNAL(externalRoomSay(int, QString, QString, QString)), this,
SLOT(externalRoomSay(int, QString, QString, QString)));
connect(interface, SIGNAL(externalRoomRemoveMessages(int, QString, int)), this,
SLOT(externalRoomRemoveMessages(int, QString, int)));
connect(interface, SIGNAL(externalRoomGameListChanged(int, ServerInfo_Game)), this,
SLOT(externalRoomGameListChanged(int, ServerInfo_Game)));
connect(interface, SIGNAL(joinGameCommandReceived(Command_JoinGame, int, int, int, qint64)), this,
SLOT(externalJoinGameCommandReceived(Command_JoinGame, int, int, int, qint64)));
connect(interface, SIGNAL(gameCommandContainerReceived(CommandContainer, int, int, qint64)), this,
SLOT(externalGameCommandContainerReceived(CommandContainer, int, int, qint64)));
connect(interface, SIGNAL(responseReceived(Response, qint64)), this,
SLOT(externalResponseReceived(Response, qint64)));
connect(interface, SIGNAL(gameEventContainerReceived(GameEventContainer, qint64)), this,
SLOT(externalGameEventContainerReceived(GameEventContainer, qint64)));
connect(interface, &IslInterface::externalUserJoined, this, &Servatrice::externalUserJoined);
connect(interface, &IslInterface::externalUserLeft, this, &Servatrice::externalUserLeft);
connect(interface, &IslInterface::externalRoomUserJoined, this, &Servatrice::externalRoomUserJoined);
connect(interface, &IslInterface::externalRoomUserLeft, this, &Servatrice::externalRoomUserLeft);
connect(interface, &IslInterface::externalRoomSay, this, &Servatrice::externalRoomSay);
connect(interface, &IslInterface::externalRoomRemoveMessages, this, &Servatrice::externalRoomRemoveMessages);
connect(interface, &IslInterface::externalRoomGameListChanged, this, &Servatrice::externalRoomGameListChanged);
connect(interface, &IslInterface::joinGameCommandReceived, this, &Servatrice::externalJoinGameCommandReceived);
connect(interface, &IslInterface::gameCommandContainerReceived, this,
&Servatrice::externalGameCommandContainerReceived);
connect(interface, &IslInterface::responseReceived, this, &Servatrice::externalResponseReceived);
connect(interface, &IslInterface::gameEventContainerReceived, this,
&Servatrice::externalGameEventContainerReceived);
}
void Servatrice::removeIslInterface(int _serverId)

View file

@ -33,6 +33,7 @@
#include <QStringList>
#include <QVariant>
#include <QVariantList>
#include <QtGlobal>
#include <google/protobuf/stubs/common.h>
#include <libcockatrice/deck_list/deck_list.h>
#include <libcockatrice/protocol/pb/game_replay.pb.h>
@ -482,6 +483,10 @@ AuthenticationResult Servatrice_DatabaseInterface::checkUserPassword(Server_Prot
const QByteArray expected = PasswordHasher::computeResponse(key, nonce);
handler->clearAuthNonce();
if (PasswordHasher::constantTimeEquals(expected, response)) {
if (forceChange) {
qCDebug(DatabaseInterfaceLog) << "Login accepted but password change required";
return PasswordChangeRequired;
}
qCDebug(DatabaseInterfaceLog) << "Login accepted: challenge-response password right";
return PasswordRight;
}
@ -755,6 +760,20 @@ QString Servatrice_DatabaseInterface::getUserPasswordData(const QString &user)
return query->value(0).toString();
}
QString Servatrice_DatabaseInterface::sealLegacyBackup(const QString &storedName, const QString &credential)
{
const QByteArray key = server->getLegacyBackupKey();
if (key.isEmpty()) {
return {};
}
const QByteArray aad = storedName.simplified().toUtf8();
const QString sealed = CryptoUtil::encryptSecret(credential.toUtf8(), key, aad);
if (sealed.isEmpty() || sealed.length() > MAX_LEGACY_BACKUP_LENGTH) {
return {};
}
return sealed;
}
bool Servatrice_DatabaseInterface::submitPasswordVerifier(const QString &user, const QString &passwordVerifier)
{
if (server->getAuthenticationMethod() != Servatrice::AuthenticationSql) {
@ -779,18 +798,13 @@ bool Servatrice_DatabaseInterface::submitPasswordVerifier(const QString &user, c
QString sealedBackup;
QDateTime backupUsedAt;
if (PasswordHasher::isLegacyFormat(currentCredential)) {
const QByteArray key = server->getLegacyBackupKey();
if (!key.isEmpty()) {
const QByteArray aad = storedName.simplified().toUtf8();
sealedBackup = CryptoUtil::encryptSecret(currentCredential.toUtf8(), key, aad);
if (sealedBackup.isEmpty() || sealedBackup.length() > MAX_LEGACY_BACKUP_LENGTH) {
qCWarning(DatabaseInterfaceLog)
<< "Failed to seal legacy backup for user" << user << "- account migrates without a downgrade path";
sealedBackup.clear();
} else {
backupUsedAt = QDateTime::currentDateTimeUtc();
}
if (PasswordHasher::isLegacyFormat(currentCredential) && !server->getLegacyBackupKey().isEmpty()) {
sealedBackup = sealLegacyBackup(storedName, currentCredential);
if (sealedBackup.isEmpty()) {
qCWarning(DatabaseInterfaceLog)
<< "Failed to seal legacy backup for user" << user << "- account migrates without a downgrade path";
} else {
backupUsedAt = QDateTime::currentDateTimeUtc();
}
}
@ -1644,6 +1658,54 @@ bool Servatrice_DatabaseInterface::changeUserPassword(const QString &user,
return passwordQuery->numRowsAffected() > 0;
}
bool Servatrice_DatabaseInterface::changeUserPasswordWithLegacyBackup(const QString &user,
const QString &passwordVerifier,
const QString &legacyCredential)
{
if (server->getAuthenticationMethod() != Servatrice::AuthenticationSql) {
return false;
}
if (!checkSql()) {
return false;
}
// Seal against the stored row name, not the caller-supplied one: the name column is
// case-insensitive, so a differently-cased name would fail the tag check on open.
QSqlQuery *nameQuery = prepareQuery("SELECT name FROM {prefix}_users WHERE name = :name");
nameQuery->bindValue(":name", user);
if (!execSqlQuery(nameQuery) || !nameQuery->next()) {
return false;
}
const QString storedName = nameQuery->value(0).toString();
const QString sealedBackup = sealLegacyBackup(storedName, legacyCredential);
const QDateTime backupUsedAt = sealedBackup.isEmpty() ? QDateTime() : QDateTime::currentDateTimeUtc();
if (sealedBackup.isEmpty()) {
// Without a backup a pre-challenge client has nothing to authenticate against: the
// row holds a verifier and the legacy hash it would send cannot match it. Say so,
// because the symptom - the temporary password appearing not to work - shows up on
// a different machine from the one that reset it.
qCWarning(DatabaseInterfaceLog) << "Stored a verifier without a legacy backup for user" << user
<< "- pre-challenge clients cannot use this password"
<< "- check security/legacy_backup_key";
}
QSqlQuery *query = prepareQuery("update {prefix}_users set password_sha512=:password, "
"password_legacy_backup=:backup, password_legacy_backup_used_at=:used_at, "
"passwordLastChangedDate = NOW() where name = :name");
query->bindValue(":password", passwordVerifier);
query->bindValue(":backup", sealedBackup);
query->bindValue(":used_at", backupUsedAt.isValid()
? QVariant(backupUsedAt.toString(QStringLiteral("yyyy-MM-dd HH:mm:ss")))
: QVariant());
query->bindValue(":name", storedName);
if (!execSqlQuery(query)) {
return false;
}
return query->numRowsAffected() > 0;
}
void Servatrice_DatabaseInterface::setForcePasswordChange(const QString &user, bool force)
{
if (!checkSql()) {
@ -2188,8 +2250,14 @@ QList<ServerInfo_ChatMessage> Servatrice_DatabaseInterface::getMessageLogHistory
bool &game,
bool &room,
int &range,
int &maxresults)
int &maxresults,
int offset,
const QString &beforeTime,
bool *hasMore)
{
if (hasMore != nullptr) {
*hasMore = false;
}
QList<ServerInfo_ChatMessage> results;
ServerInfo_ChatMessage chatMessage;
@ -2198,8 +2266,31 @@ QList<ServerInfo_ChatMessage> Servatrice_DatabaseInterface::getMessageLogHistory
return results;
}
if (user.isEmpty() && ipaddress.isEmpty() && gameid.isEmpty() && gamename.isEmpty()) {
// To ensure quick results and minimal lag, require an indexed field
// Page size guard: always bound the scan so a filter-less browse of room
// history cannot become a 50M-row fetch. Zero means "client did not set a
// size", so fall back to one smooth-scroll page.
const int pageSize = maxresults <= 0 ? 200 : qBound(1, maxresults, 1000);
maxresults = pageSize;
const int safeOffset = qMax(0, offset);
const bool hasIndexedFilter = !user.isEmpty() || !ipaddress.isEmpty() || !gameid.isEmpty() || !gamename.isEmpty();
const bool hasLocationFilter = chat || game || room;
if (!hasLocationFilter) {
// No target_type restriction would mix private chat into a room/game
// browse, so refuse rather than leak.
return results;
}
// A browse narrowed only by a message substring has no index to serve it, and `range`
// has no upper bound on the wire: the client offers 20 days, but a hand-built command
// can ask for any value and every LIKE candidate in it is a row the server examines.
// Clamping here keeps the guard independent of what the client happens to send.
constexpr int maxFilterlessRangeHours = 20 * 24;
if (!hasIndexedFilter && range > maxFilterlessRangeHours) {
range = maxFilterlessRangeHours;
}
if (!hasIndexedFilter && range <= 0) {
// Filter-less browse must stay time-bounded (idx_log_time) so the
// query stays an index range scan instead of a full table scan.
return results;
}
@ -2254,10 +2345,23 @@ QList<ServerInfo_ChatMessage> Servatrice_DatabaseInterface::getMessageLogHistory
queryString.append(" AND log_time >= DATE_SUB(now(), INTERVAL :range_time HOUR)");
}
if (maxresults) {
queryString.append(" LIMIT :limit_size");
if (!beforeTime.isEmpty()) {
// Pages after the first run against the snapshot fixed by the first one, so rows
// logged while the moderator scrolls can no longer push the OFFSET window down and
// repeat a row already on screen.
queryString.append(" AND log_time <= :before_time");
}
// Newest-first keeps "going back from today" stable across pages and lets
// the client append older rows at the bottom for smooth infinite scroll.
queryString.append(" ORDER BY log_time DESC");
// Fetch one extra row when the caller wants a has_more cursor so a full
// page does not need a second empty round-trip to prove exhaustion.
const bool detectMore = (hasMore != nullptr);
const int fetchSize = detectMore ? pageSize + 1 : pageSize;
queryString.append(" LIMIT :limit_size OFFSET :offset_size");
QSqlQuery *query = prepareQuery(queryString);
if (!user.isEmpty()) {
query->bindValue(":user_name", user);
@ -2277,9 +2381,11 @@ QList<ServerInfo_ChatMessage> Servatrice_DatabaseInterface::getMessageLogHistory
if (range) {
query->bindValue(":range_time", range);
}
if (maxresults) {
query->bindValue(":limit_size", maxresults);
if (!beforeTime.isEmpty()) {
query->bindValue(":before_time", beforeTime);
}
query->bindValue(":limit_size", fetchSize);
query->bindValue(":offset_size", safeOffset);
if (!execSqlQuery(query)) {
qCWarning(DatabaseInterfaceLog) << "Failed to collect log history information: SQL Error";
@ -2299,6 +2405,11 @@ QList<ServerInfo_ChatMessage> Servatrice_DatabaseInterface::getMessageLogHistory
results << chatMessage;
}
if (detectMore && results.size() > pageSize) {
results.removeLast();
*hasMore = true;
}
return results;
}

View file

@ -62,6 +62,9 @@ private:
bool checkUserIsIpBanned(const QString &ipAddress, QString &banReason, int &banSecondsRemaining);
/** Must be called after checkSql and server is known to be in auth mode. */
bool checkUserIsNameBanned(QString const &userName, QString &banReason, int &banSecondsRemaining);
/** Seal a legacy credential for the account's downgrade backup; empty when the
* backup key is unset or the sealed value does not fit the column. */
QString sealLegacyBackup(const QString &storedName, const QString &credential);
bool isStrictModeEnabled(bool &ok) const;
protected:
@ -192,6 +195,11 @@ public:
const QString &targetName,
const QString &channel = QString()) override;
bool changeUserPassword(const QString &user, const QString &password, bool passwordNeedsHash) override;
/** Replace a credential with an already-derived verifier, keeping a sealed legacy hash of the
* same password as the downgrade backup for pre-challenge clients. */
bool changeUserPasswordWithLegacyBackup(const QString &user,
const QString &passwordVerifier,
const QString &legacyCredential);
bool changeUserPassword(const QString &user,
const QString &oldPassword,
bool oldPasswordNeedsHash,
@ -211,7 +219,10 @@ public:
bool &game,
bool &room,
int &range,
int &maxresults);
int &maxresults,
int offset = 0,
const QString &beforeTime = QString(),
bool *hasMore = nullptr);
QList<ServerInfo_UserSession> getUserSessions(const QString &userName, int limit);
QList<ServerInfo_UserAlt> getUserAlts(const QString &userName);
QList<ServerInfo_ModeratorLogin> getModeratorLastLogins();

View file

@ -199,7 +199,8 @@ AbstractServerSocketInterface::AbstractServerSocketInterface(Servatrice *_server
// Never call flushOutputQueue directly from outputQueueChanged. In case of a socket error,
// it could lead to this object being destroyed while another function is still on the call stack. -> mutex
// deadlocks etc.
connect(this, SIGNAL(outputQueueChanged()), this, SLOT(flushOutputQueue()), Qt::QueuedConnection);
connect(this, &AbstractServerSocketInterface::outputQueueChanged, this,
&AbstractServerSocketInterface::flushOutputQueue, Qt::QueuedConnection);
}
bool AbstractServerSocketInterface::initSession()
@ -1742,12 +1743,17 @@ Response::ResponseCode AbstractServerSocketInterface::cmdGetLogHistory(const Com
int dateRange = cmd.date_range();
int maximumResults = cmd.maximum_results();
const int offset = cmd.has_offset() ? static_cast<int>(cmd.offset()) : 0;
const QString beforeTime = cmd.has_before_time() ? QString::fromStdString(cmd.before_time()) : QString();
Response_ViewLogHistory *re = new Response_ViewLogHistory;
if (servatrice->getEnableLogQuery()) {
QListIterator<ServerInfo_ChatMessage> messageIterator(sqlInterface->getMessageLogHistory(
userName, ipAddress, gameName, gameID, message, chatType, gameType, roomType, dateRange, maximumResults));
bool hasMore = false;
QList<ServerInfo_ChatMessage> history =
sqlInterface->getMessageLogHistory(userName, ipAddress, gameName, gameID, message, chatType, gameType,
roomType, dateRange, maximumResults, offset, beforeTime, &hasMore);
QListIterator<ServerInfo_ChatMessage> messageIterator(history);
while (messageIterator.hasNext()) {
ServerInfo_ChatMessage chatMessage = messageIterator.next();
if (!allowPrivateChat) {
@ -1755,6 +1761,7 @@ Response::ResponseCode AbstractServerSocketInterface::cmdGetLogHistory(const Com
}
re->add_log_message()->CopyFrom(chatMessage);
}
re->set_has_more(hasMore);
} else {
ServerInfo_ChatMessage chatMessage;
@ -1982,7 +1989,8 @@ Response::ResponseCode AbstractServerSocketInterface::cmdBanFromServer(const Com
SessionEvent *se = userList[i]->prepareSessionEvent(event);
userList[i]->sendProtocolItem(*se);
delete se;
QMetaObject::invokeMethod(userList[i], "prepareDestroy", Qt::QueuedConnection);
QMetaObject::invokeMethod(
userList[i], [user = userList[i]] { user->prepareDestroy(); }, Qt::QueuedConnection);
}
}
@ -2641,7 +2649,15 @@ Response::ResponseCode AbstractServerSocketInterface::cmdResetUserPassword(const
}
const QString tempPassword = PasswordHasher::generateRandomSalt();
if (!sqlInterface->changeUserPassword(userName, tempPassword, true)) {
// Store a scrypt verifier rather than a legacy salt+hash: a legacy row makes the next
// login look like an authentication downgrade, which modern clients refuse when they
// still hold a verifier for the account. Where security/legacy_backup_key is configured,
// keep a sealed legacy hash of the same temporary password as the downgrade backup so
// pre-challenge clients keep the path the reset used to provide; without the key they
// have none, which changeUserPasswordWithLegacyBackup warns about.
const QString verifier = PasswordHasher::generatePasswordVerifier(tempPassword);
const QString legacyCredential = PasswordHasher::computeHash(tempPassword, PasswordHasher::generateRandomSalt());
if (!sqlInterface->changeUserPasswordWithLegacyBackup(userName, verifier, legacyCredential)) {
return Response::RespInternalError;
}
sqlInterface->setForcePasswordChange(userName, true);
@ -3779,6 +3795,9 @@ Response::ResponseCode AbstractServerSocketInterface::cmdAccountPassword(const C
}
databaseInterface->setForcePasswordChange(userName, false);
// The forced change is done, but this session never completed login (see
// Server_ProtocolHandler::cmdLogin). It stays restricted to ping and another password
// change; the client reconnects with the new password to actually enter the server.
return Response::RespOk;
}
@ -4180,15 +4199,15 @@ Response::ResponseCode
AbstractServerSocketInterface::cmdUpdateServerMessage(const Command_UpdateServerMessage & /*cmd*/,
ResponseContainer & /*rc*/)
{
QMetaObject::invokeMethod(server, "updateLoginMessage");
QMetaObject::invokeMethod(servatrice, [srv = servatrice] { srv->updateLoginMessage(); });
return Response::RespOk;
}
Response::ResponseCode AbstractServerSocketInterface::cmdShutdownServer(const Command_ShutdownServer &cmd,
ResponseContainer & /*rc*/)
{
QMetaObject::invokeMethod(server, "scheduleShutdown", Q_ARG(QString, textFromStdString(cmd.reason())),
Q_ARG(int, cmd.minutes()));
QMetaObject::invokeMethod(servatrice, [srv = servatrice, reason = textFromStdString(cmd.reason()),
minutes = cmd.minutes()] { srv->scheduleShutdown(reason, minutes); });
return Response::RespOk;
}
@ -4197,7 +4216,9 @@ Response::ResponseCode AbstractServerSocketInterface::cmdReloadConfig(const Comm
{
logDebugMessage("Received admin command: reloading configuration");
settingsCache->sync();
QMetaObject::invokeMethod(server, "setRequiredFeatures", Q_ARG(QString, server->getRequiredFeatures()));
QMetaObject::invokeMethod(servatrice, [srv = servatrice, features = servatrice->getRequiredFeatures()] {
srv->setRequiredFeatures(features);
});
return Response::RespOk;
}
@ -4249,7 +4270,7 @@ bool AbstractServerSocketInterface::removeAdminFlagFromUser(const QString &userN
delete se;
}
QMetaObject::invokeMethod(user, "prepareDestroy", Qt::QueuedConnection);
QMetaObject::invokeMethod(user, [user] { user->prepareDestroy(); }, Qt::QueuedConnection);
return true;
}
@ -4424,10 +4445,10 @@ TcpServerSocketInterface::TcpServerSocketInterface(Servatrice *_server,
{
socket = new QTcpSocket(this);
socket->setSocketOption(QAbstractSocket::LowDelayOption, 1);
connect(socket, SIGNAL(readyRead()), this, SLOT(readClient()));
connect(socket, SIGNAL(disconnected()), this, SLOT(catchSocketDisconnected()));
connect(socket, SIGNAL(errorOccurred(QAbstractSocket::SocketError)), this,
SLOT(catchSocketError(QAbstractSocket::SocketError)));
connect(socket, &QTcpSocket::readyRead, this, [this] { readClient(); });
connect(socket, &QTcpSocket::disconnected, this, [this] { catchSocketDisconnected(); });
connect(socket, &QTcpSocket::errorOccurred, this,
[this](QAbstractSocket::SocketError error) { catchSocketError(error); });
}
TcpServerSocketInterface::~TcpServerSocketInterface()
@ -4649,8 +4670,7 @@ void WebsocketServerSocketInterface::initConnection(void *_socket)
}
}
connect(socket, SIGNAL(binaryMessageReceived(const QByteArray &)), this,
SLOT(binaryMessageReceived(const QByteArray &)));
connect(socket, &QWebSocket::binaryMessageReceived, this, &WebsocketServerSocketInterface::binaryMessageReceived);
// QWebSocket::errorOccurred was added in Qt 6.5 (error() is deprecated from 6.5 on),
// so a typed connect cannot compile against the 6.4 floor.
#if QT_VERSION >= QT_VERSION_CHECK(6, 5, 0)
@ -4660,7 +4680,7 @@ void WebsocketServerSocketInterface::initConnection(void *_socket)
connect(socket, SIGNAL(error(QAbstractSocket::SocketError)), this,
SLOT(catchSocketError(QAbstractSocket::SocketError)));
#endif
connect(socket, SIGNAL(disconnected()), this, SLOT(catchSocketDisconnected()));
connect(socket, &QWebSocket::disconnected, this, [this] { catchSocketDisconnected(); });
// Add this object to the server's list of connections before it can receive socket events.
// Otherwise, in case of a socket error, it could be removed from the list before it is added.

View file

@ -25,7 +25,7 @@ static const qint64 IDLE_TIMEOUT_MS = 60000; // 60 seconds
// MAX_RETRY_DELAY cap is applied, preventing unbounded intermediate values.
static const int MAX_BACKOFF_SHIFT = 10;
inline Q_LOGGING_CATEGORY(SmtpClientLog, "smtp_client")
inline Q_LOGGING_CATEGORY(SmtpClientLog, "smtp_client");
static QString pendingKey(const QString &userName, EmailType type, const QString &token)
{
@ -55,24 +55,23 @@ SmtpClient::SmtpClient(QObject *parent) : QObject(parent)
idleTimer = new QTimer(this);
idleTimer->setSingleShot(true);
connect(idleTimer, SIGNAL(timeout()), this, SLOT(idleTimeout()));
connect(idleTimer, &QTimer::timeout, this, &SmtpClient::idleTimeout);
connect(smtp, SIGNAL(authenticated()), this, SLOT(authenticated()));
connect(smtp, SIGNAL(authenticationFailed(const QByteArray &)), this,
SLOT(authenticationFailed(const QByteArray &)));
connect(smtp, SIGNAL(connected()), this, SLOT(connected()));
connect(smtp, SIGNAL(connectionFailed(const QByteArray &)), this, SLOT(connectionFailed(const QByteArray &)));
connect(smtp, SIGNAL(disconnected()), this, SLOT(disconnected()));
connect(smtp, SIGNAL(encrypted()), this, SLOT(encrypted()));
connect(smtp, SIGNAL(encryptionFailed(const QByteArray &)), this, SLOT(encryptionFailed(const QByteArray &)));
connect(smtp, SIGNAL(finished()), this, SLOT(finished()));
connect(smtp, SIGNAL(mailFailed(int, int, const QByteArray &)), this,
SLOT(mailFailed(int, int, const QByteArray &)));
connect(smtp, SIGNAL(mailSent(int)), this, SLOT(mailSent(int)));
connect(smtp, SIGNAL(recipientRejected(int, const QString &, const QByteArray &)), this,
SLOT(recipientRejected(int, const QString &, const QByteArray &)));
connect(smtp, SIGNAL(senderRejected(int, const QString &, const QByteArray &)), this,
SLOT(senderRejected(int, const QString &, const QByteArray &)));
connect(smtp, &QxtSmtp::authenticated, this, &SmtpClient::authenticated);
connect(smtp, qOverload<const QByteArray &>(&QxtSmtp::authenticationFailed), this,
&SmtpClient::authenticationFailed);
connect(smtp, &QxtSmtp::connected, this, &SmtpClient::connected);
connect(smtp, qOverload<const QByteArray &>(&QxtSmtp::connectionFailed), this, &SmtpClient::connectionFailed);
connect(smtp, &QxtSmtp::disconnected, this, &SmtpClient::disconnected);
connect(smtp, &QxtSmtp::encrypted, this, &SmtpClient::encrypted);
connect(smtp, qOverload<const QByteArray &>(&QxtSmtp::encryptionFailed), this, &SmtpClient::encryptionFailed);
connect(smtp, &QxtSmtp::finished, this, &SmtpClient::finished);
connect(smtp, qOverload<int, int, const QByteArray &>(&QxtSmtp::mailFailed), this, &SmtpClient::mailFailed);
connect(smtp, &QxtSmtp::mailSent, this, &SmtpClient::mailSent);
connect(smtp, qOverload<int, const QString &, const QByteArray &>(&QxtSmtp::recipientRejected), this,
&SmtpClient::recipientRejected);
connect(smtp, qOverload<int, const QString &, const QByteArray &>(&QxtSmtp::senderRejected), this,
&SmtpClient::senderRejected);
}
SmtpClient::~SmtpClient()

View file

@ -328,6 +328,40 @@ cockatrice_add_test(
LABELS
server
)
cockatrice_add_test(
NAME
server_forced_password_change_test
SOURCES
server_forced_password_change_test.cpp
LIBS
libcockatrice_network_server_remote
libcockatrice_rng
LABELS
server
)
cockatrice_add_test(
NAME
client_login_status_test
SOURCES
${VERSION_STRING_CPP}
client_login_status_test.cpp
LIBS
libcockatrice_network_client_remote
LABELS
ci
)
# The local game runs one client and one server in the same process.
# Either of them can be destroyed first; the survivor has to stay usable.
cockatrice_add_test(
NAME
local_client_lifetime_test
SOURCES
local_client_lifetime_test.cpp
LIBS
libcockatrice_network
LABELS
ci
)
cockatrice_add_test(
NAME
server_room_channels_test
@ -494,6 +528,7 @@ add_subdirectory(carddatabase)
add_subdirectory(deck_list_model)
add_subdirectory(deck_list_zones)
add_subdirectory(loading_from_clipboard)
add_subdirectory(model_contract)
add_subdirectory(movecard_tests)
add_subdirectory(oracle)
add_subdirectory(settings)

View file

@ -35,6 +35,21 @@ if(WITH_ORACLE OR WITH_CLIENT)
LIBS
libcockatrice_filters
)
# ------------------------
# Visual Display Model Test
# ------------------------
cockatrice_add_test(
NAME
visual_display_model_test
SOURCES
${VERSION_STRING_CPP}
visual_display_model_test.cpp
mocks.cpp
LIBS
libcockatrice_card
libcockatrice_models
)
endif()
# ------------------------

View file

@ -0,0 +1,188 @@
/**
* @file visual_display_model_test.cpp
* @brief Tests for the flattened model behind the visual card database grid.
*/
#include "card_database_display_model.h"
#include "card_database_model.h"
#include "card_database_visual_display_model.h"
#include "libcockatrice/card/card_info.h"
#include "libcockatrice/card/database/card_database.h"
#include "libcockatrice/card/database/card_database_querier.h"
#include "libcockatrice/card/printing/exact_card.h"
#include "libcockatrice/card/printing/printing_info.h"
#include "libcockatrice/card/set/card_set.h"
#include "libcockatrice/interfaces/noop_card_preference_provider.h"
#include "libcockatrice/interfaces/noop_card_set_priority_controller.h"
#include "test_card_database_path_provider.h"
#include "gtest/gtest.h"
#include <QCoreApplication>
#include <QDate>
#include <QEventLoop>
#include <QHash>
#include <QMap>
#include <QString>
#include <QTimer>
#include <memory>
namespace
{
// GTest's main() creates no QCoreApplication, which QTimer needs for its event loop.
const QCoreApplication &application()
{
static int argc = 1;
static char executable[] = "visual_display_model_test";
static char *argv[] = {executable, nullptr};
static const QCoreApplication app(argc, argv);
return app;
}
/// Lets the rebuild timer fire, as it would in the running client.
void waitForRebuild()
{
QEventLoop loop;
QTimer::singleShot(100, &loop, &QEventLoop::quit);
loop.exec();
}
class CardDatabaseVisualDisplayModelTest : public ::testing::Test
{
protected:
void SetUp() override
{
(void)application();
db = std::make_unique<CardDatabase>(nullptr, new NoopCardPreferenceProvider(),
new TestCardDatabasePathProvider(), new NoopCardSetPriorityController());
db->loadCardDatabases();
cardModel = std::make_unique<CardDatabaseModel>(db.get(), false);
displayModel = std::make_unique<CardDatabaseDisplayModel>();
displayModel->setSourceModel(cardModel.get());
visualModel = std::make_unique<CardDatabaseVisualDisplayModel>(displayModel.get(), db->query());
visualModel->refresh();
}
/**
* @brief Builds a card with @p printingCount printings in each named set.
* @param printingCountPerSet Set short name to the number of printings in that set.
*/
[[nodiscard]] CardInfoPtr makeCard(const QString &name, const QMap<QString, int> &printingCountPerSet)
{
SetToPrintingsMap setsInfo;
for (auto it = printingCountPerSet.constBegin(); it != printingCountPerSet.constEnd(); ++it) {
CardSetPtr set = CardSet::newInstance(&setController, it.key(), it.key() + " test set", "expansion",
QDate(), CardSet::PriorityPrimary);
for (int printing = 0; printing < it.value(); ++printing) {
QHash<QString, QString> properties;
properties["uuid"] = QString("%1-%2-%3").arg(it.key(), name).arg(printing);
setsInfo[it.key()].append(PrintingInfo(set, LazyPropertiesHash(properties)));
}
}
CardInfo::UiAttributes attributes = {.tableRow = 1};
return CardInfo::newInstance(name, "", false, {}, {}, {}, setsInfo, attributes);
}
NoopCardSetPriorityController setController;
std::unique_ptr<CardDatabase> db;
std::unique_ptr<CardDatabaseModel> cardModel;
std::unique_ptr<CardDatabaseDisplayModel> displayModel;
std::unique_ptr<CardDatabaseVisualDisplayModel> visualModel;
};
TEST_F(CardDatabaseVisualDisplayModelTest, ExposesTheCardInfoBehindAProxyRow)
{
ASSERT_GT(displayModel->rowCount(), 0);
const QVariant info = displayModel->data(displayModel->index(0, CardDatabaseModel::NameColumn),
CardDatabaseDisplayModel::CardInfoRole);
ASSERT_TRUE(info.isValid());
EXPECT_FALSE(info.value<CardInfoPtr>().isNull());
}
TEST_F(CardDatabaseVisualDisplayModelTest, FlattensOneRowPerProxyCardWithoutSetFilters)
{
ASSERT_GT(displayModel->rowCount(), 0);
EXPECT_EQ(displayModel->rowCount(), visualModel->rowCount());
for (int row = 0; row < visualModel->rowCount(); ++row) {
const ExactCard card = visualModel->cardAt(row);
ASSERT_TRUE(card) << "row " << row;
EXPECT_EQ(
displayModel->data(displayModel->index(row, CardDatabaseModel::NameColumn), Qt::DisplayRole).toString(),
card.getName())
<< "row " << row;
}
}
TEST_F(CardDatabaseVisualDisplayModelTest, ShowsOneRowPerCardWhenNoSetIsFiltered)
{
const int rowsBefore = visualModel->rowCount();
db->addCard(makeCard("Multi", {{"TST", 2}, {"OTH", 1}}));
visualModel->refresh();
EXPECT_EQ(rowsBefore + 1, visualModel->rowCount());
EXPECT_EQ("Multi", visualModel->cardAt(visualModel->rowCount() - 1).getName());
}
TEST_F(CardDatabaseVisualDisplayModelTest, ShowsEveryPrintingOfAFilteredSet)
{
db->addCard(makeCard("Multi", {{"TST", 2}, {"OTH", 1}}));
visualModel->refresh();
visualModel->setSetFilterTerms({"TST"});
ASSERT_EQ(2, visualModel->rowCount());
for (int row = 0; row < visualModel->rowCount(); ++row) {
const ExactCard card = visualModel->cardAt(row);
ASSERT_TRUE(card) << "row " << row;
EXPECT_EQ("Multi", card.getName());
ASSERT_FALSE(card.getPrinting().isEmpty()) << "row " << row;
EXPECT_EQ("TST", card.getPrinting().getSet()->getShortName()) << "row " << row;
}
EXPECT_NE(visualModel->cardAt(0).getPrinting(), visualModel->cardAt(1).getPrinting());
}
TEST_F(CardDatabaseVisualDisplayModelTest, HidesCardsWithoutAPrintingInTheFilteredSets)
{
visualModel->setSetFilterTerms({"NO SUCH SET"});
EXPECT_EQ(0, visualModel->rowCount());
}
TEST_F(CardDatabaseVisualDisplayModelTest, RestoresOneRowPerCardWhenTheSetTermsClear)
{
db->addCard(makeCard("Multi", {{"TST", 2}, {"OTH", 1}}));
visualModel->refresh();
const int rowsBefore = visualModel->rowCount();
visualModel->setSetFilterTerms({"TST"});
ASSERT_EQ(2, visualModel->rowCount());
visualModel->setSetFilterTerms({});
EXPECT_EQ(rowsBefore, visualModel->rowCount());
}
TEST_F(CardDatabaseVisualDisplayModelTest, RebuildsByItselfWhenTheProxyGainsARow)
{
const int rowsBefore = visualModel->rowCount();
db->addCard(makeCard("Late", {{"TST", 1}}));
waitForRebuild();
EXPECT_EQ(rowsBefore + 1, visualModel->rowCount());
}
TEST_F(CardDatabaseVisualDisplayModelTest, ReportsTheCardNameForDisplayAndAccessibleText)
{
ASSERT_GT(visualModel->rowCount(), 0);
const QString expected = visualModel->cardAt(0).getName();
EXPECT_EQ(expected, visualModel->data(visualModel->index(0), Qt::DisplayRole).toString());
EXPECT_EQ(expected, visualModel->data(visualModel->index(0), Qt::AccessibleTextRole).toString());
EXPECT_FALSE(visualModel->data(QModelIndex(), Qt::DisplayRole).isValid());
}
} // namespace

View file

@ -0,0 +1,116 @@
/** @file client_login_status_test.cpp
* @brief The login response state machine must keep the session a forced password change needs.
* @ingroup Tests
*/
#include "abstract_client.h"
#include "libcockatrice/interfaces/interface_network_settings_provider.h"
#include "libcockatrice/protocol/pb/response.pb.h"
#include "libcockatrice/protocol/pb/response_login.pb.h"
#include "remote_client.h"
#include <QCoreApplication>
#include <QMetaObject>
#include <QString>
#include <gtest/gtest.h>
namespace
{
class StubNetworkSettingsProvider : public INetworkSettingsProvider
{
public:
QString getClientID() override
{
return {};
}
[[nodiscard]] int getTimeOut() const override
{
return 1000;
}
[[nodiscard]] int getKeepAlive() const override
{
return 60;
}
[[nodiscard]] bool getNotifyAboutUpdates() const override
{
return false;
}
void setKnownMissingFeatures(const QString &) override
{
}
QString getKnownMissingFeatures() override
{
return {};
}
};
/**
* @brief Feeds a login response to RemoteClient without a socket.
*
* `loginResponse` is the slot the socket thread runs for the login command's answer, so
* invoking it directly exercises the status transition that decides whether `readData()`
* tears the connection down right after it.
*/
class ClientLoginStatusTest : public ::testing::Test
{
protected:
void SetUp() override
{
client = new RemoteClient(nullptr, &provider);
}
void TearDown() override
{
delete client;
client = nullptr;
}
void deliverLoginResponse(Response::ResponseCode code)
{
Response response;
response.set_response_code(code);
response.MutableExtension(Response_Login::ext);
ASSERT_TRUE(
QMetaObject::invokeMethod(client, "loginResponse", Qt::DirectConnection, Q_ARG(Response, response)));
}
StubNetworkSettingsProvider provider;
RemoteClient *client = nullptr;
};
/**
* @brief A required password change is not a failed login.
*
* The server keeps the session open and accepts only ping and Command_AccountPassword until
* the password is replaced. StatusDisconnecting would make readData() close the socket right
* after this slot returns - before the dialog that collects the new password (queued to the
* UI thread) can send anything - and the pending command would never be answered because the
* ping timer that times it out dies with the connection. StatusLoggedIn would start the
* normal post-login flow for a session that cannot serve it.
*/
TEST_F(ClientLoginStatusTest, PasswordChangeRequiredKeepsTheSessionOpen)
{
deliverLoginResponse(Response::RespPasswordChangeRequired);
EXPECT_EQ(client->getStatus(), StatusPasswordChangeRequired);
EXPECT_NE(client->getStatus(), StatusDisconnecting);
EXPECT_NE(client->getStatus(), StatusLoggedIn);
}
/** @brief Every other login error still ends the connection, as it did before. */
TEST_F(ClientLoginStatusTest, OtherLoginErrorsDisconnect)
{
deliverLoginResponse(Response::RespWrongPassword);
EXPECT_EQ(client->getStatus(), StatusDisconnecting);
}
} // namespace
int main(int argc, char **argv)
{
QCoreApplication app(argc, argv);
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}

View file

@ -1,28 +1,18 @@
add_executable(
game_smoke_test
${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/cache_settings.cpp
${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/card_counter_settings.cpp
${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/shortcuts_settings.cpp
${CMAKE_SOURCE_DIR}/cockatrice/src/client/network/update/client/release_channel.cpp
cockatrice_add_test(
NAME
game_logic_test
SOURCES
game_test_main.cpp
card_state_test.cpp
card_zone_logic_test.cpp
game_event_handler_test.cpp
player_actions_test.cpp
replay_test.cpp
${VERSION_STRING_CPP}
game_smoke_test.cpp
LIBS
libcockatrice_game
INCLUDE_DIRS
${CMAKE_CURRENT_SOURCE_DIR}
DEFINITIONS
GAME_TEST_DATA_DIR="${CMAKE_CURRENT_SOURCE_DIR}/data"
)
# SettingsCache is still reached from the game logic, but it lives in the application sources
# rather than in libcockatrice_settings, so the client settings sources are compiled in here
# exactly like loader_local_matching_test and theme_migration_test do.
target_include_directories(game_smoke_test PRIVATE ${CMAKE_SOURCE_DIR}/cockatrice/src)
target_link_libraries(
game_smoke_test
PRIVATE libcockatrice_game
PRIVATE Threads::Threads
PRIVATE ${GTEST_BOTH_LIBRARIES}
PRIVATE ${TEST_QT_MODULES}
)
add_test(NAME game_smoke_test COMMAND game_smoke_test)
if(NOT GTEST_FOUND)
add_dependencies(game_smoke_test gtest)
endif()

View file

@ -0,0 +1,110 @@
#include "gtest/gtest.h"
#include <QMetaType>
#include <QString>
#include <QVariantList>
#include <libcockatrice/game/board/card_state.h>
#include <libcockatrice/utility/card_ref.h>
namespace
{
CardRef cardRefFor(const QString &name)
{
CardRef cardRef;
cardRef.name = name;
return cardRef;
}
} // namespace
TEST(CardStateTest, AttachmentGraphKeepsBothEndsConsistent)
{
CardState host(nullptr, nullptr, cardRefFor(QStringLiteral("Host")), 1);
CardState guest(nullptr, nullptr, cardRefFor(QStringLiteral("Guest")), 2);
guest.setAttachedTo(&host);
host.addAttachedCard(&guest);
EXPECT_EQ(guest.getAttachedTo(), &host);
ASSERT_EQ(host.getAttachedCards().size(), 1);
EXPECT_EQ(host.getAttachedCards().first(), &guest);
host.removeAttachedCard(&guest);
guest.setAttachedTo(nullptr);
EXPECT_TRUE(host.getAttachedCards().isEmpty());
EXPECT_EQ(guest.getAttachedTo(), nullptr);
}
TEST(CardStateTest, CountersAddUpdateAndClear)
{
CardState card(nullptr, nullptr, cardRefFor(QStringLiteral("Cat")), 1);
int changes = 0;
QObject::connect(&card, &CardState::countersChanged, [&changes]() { ++changes; });
card.insertCounter(1, 2);
EXPECT_EQ(card.getCounters().value(1), 2);
card.setCounter(1, 3);
EXPECT_EQ(card.getCounters().value(1), 3);
card.setCounter(1, 0);
EXPECT_FALSE(card.getCounters().contains(1));
card.setCounter(2, 1);
card.clearCounters();
EXPECT_TRUE(card.getCounters().isEmpty());
EXPECT_EQ(changes, 5);
}
TEST(CardStateTest, TapFlipAttackingAndReset)
{
CardState card(nullptr, nullptr, cardRefFor(QStringLiteral("Cat")), 1);
card.setTapped(true);
card.setFaceDown(true);
card.setAttacking(true);
card.setAnnotation(QStringLiteral("note"));
card.setPT(QStringLiteral("4/4"));
card.setDoesntUntap(true);
card.setDestroyOnZoneChange(true);
card.setColor(QStringLiteral("red"));
card.insertCounter(1, 2);
card.resetState(true);
EXPECT_FALSE(card.getTapped());
EXPECT_FALSE(card.getAttacking());
EXPECT_TRUE(card.getCounters().isEmpty());
EXPECT_TRUE(card.getPT().isEmpty());
EXPECT_FALSE(card.getDoesntUntap());
EXPECT_EQ(card.getAnnotation(), QStringLiteral("note"));
// resetState() deliberately leaves these three alone.
EXPECT_TRUE(card.getFaceDown());
EXPECT_EQ(card.getColor(), QStringLiteral("red"));
EXPECT_TRUE(card.getDestroyOnZoneChange());
card.setAnnotation(QStringLiteral("other"));
card.resetState(false);
EXPECT_TRUE(card.getAnnotation().isEmpty());
}
TEST(CardStateTest, ParsePT)
{
const QVariantList plain = CardState::parsePT(QStringLiteral("3/3"));
ASSERT_EQ(plain.size(), 2);
EXPECT_EQ(plain.at(0).toString(), QStringLiteral("3"));
EXPECT_EQ(plain.at(1).toString(), QStringLiteral("3"));
const QVariantList negative = CardState::parsePT(QStringLiteral("-1/2"));
ASSERT_EQ(negative.size(), 2);
EXPECT_EQ(negative.at(0).typeId(), QMetaType::Int);
EXPECT_EQ(negative.at(0).toInt(), -1);
EXPECT_EQ(negative.at(1).toString(), QStringLiteral("2"));
const QVariantList star = CardState::parsePT(QStringLiteral("*"));
ASSERT_EQ(star.size(), 1);
EXPECT_EQ(star.at(0).toString(), QStringLiteral("*"));
EXPECT_TRUE(CardState::parsePT(QString()).isEmpty());
}

View file

@ -0,0 +1,107 @@
#include "gtest/gtest.h"
#include <QSignalSpy>
#include <QString>
#include <libcockatrice/game/board/card_state.h>
#include <libcockatrice/game/zones/hand_zone_logic.h>
#include <libcockatrice/game/zones/view_zone_logic.h>
#include <libcockatrice/utility/card_ref.h>
namespace
{
CardRef cardRefFor(const QString &name)
{
CardRef cardRef;
cardRef.name = name;
return cardRef;
}
} // namespace
TEST(CardZoneLogicTest, AddsAndTakesCardsWithoutAGraphicsSide)
{
HandZoneLogic zone(nullptr, QStringLiteral("hand"), false, false, true);
CardState card(nullptr, nullptr, cardRefFor(QStringLiteral("Lightning Bolt")), 7);
QSignalSpy cardAddedSpy(&zone, &CardZoneLogic::cardAdded);
QSignalSpy cardCountSpy(&zone, &CardZoneLogic::cardCountChanged);
zone.addCard(&card, false, 0);
ASSERT_EQ(zone.getCards().size(), 1);
EXPECT_EQ(zone.getCards().first(), &card);
EXPECT_EQ(card.getZone(), &zone);
EXPECT_EQ(cardAddedSpy.count(), 1);
EXPECT_GE(cardCountSpy.count(), 1);
EXPECT_EQ(zone.takeCard(0, 7), &card);
EXPECT_TRUE(zone.getCards().isEmpty());
EXPECT_EQ(card.getId(), 7);
}
TEST(CardZoneLogicTest, AsksTheGraphicsSideToCreateCards)
{
HandZoneLogic zone(nullptr, QStringLiteral("library"), false, true, true);
// The graphics side owns the view logic and registers it on the zone it
// shows; adding a card then goes through prepareAddCard and the request
// signal instead of the test emitting the signal itself.
ZoneViewZoneLogic view(nullptr, &zone, 1, true, false, false);
zone.getViews().append(&view);
QSignalSpy requestSpy(&view, &CardZoneLogic::createCardRequested);
CardState card(nullptr, nullptr, cardRefFor(QStringLiteral("Lightning Bolt")), 7);
zone.addCard(&card, true, 0, 0);
ASSERT_EQ(requestSpy.count(), 1);
const QList<QVariant> arguments = requestSpy.takeFirst();
const ServerInfo_Card cardInfo = arguments.at(0).value<ServerInfo_Card>();
EXPECT_EQ(cardInfo.id(), 7);
EXPECT_EQ(cardInfo.name(), "Lightning Bolt");
EXPECT_TRUE(arguments.at(1).toBool());
EXPECT_EQ(card.getZone(), &zone);
EXPECT_EQ(zone.getCards().size(), 1);
}
TEST(CardZoneLogicTest, MoveBetweenZonesUpdatesCardZone)
{
HandZoneLogic first(nullptr, QStringLiteral("hand"), false, false, true);
HandZoneLogic second(nullptr, QStringLiteral("grave"), false, false, true);
CardState card(nullptr, nullptr, cardRefFor(QStringLiteral("Cat")), 7);
first.addCard(&card, false, 0);
ASSERT_EQ(first.getCards().size(), 1);
CardState *taken = first.takeCard(0, 7);
ASSERT_EQ(taken, &card);
EXPECT_TRUE(first.getCards().isEmpty());
second.addCard(taken, false, 0);
EXPECT_EQ(second.getCards().size(), 1);
EXPECT_EQ(card.getZone(), &second);
}
TEST(CardZoneLogicTest, GetCardFindsById)
{
HandZoneLogic zone(nullptr, QStringLiteral("hand"), false, false, true);
CardState first(nullptr, nullptr, cardRefFor(QStringLiteral("Cat")), 7);
CardState second(nullptr, nullptr, cardRefFor(QStringLiteral("Dog")), 8);
zone.addCard(&first, false, 0);
zone.addCard(&second, false, 0);
EXPECT_EQ(zone.getCard(7), &first);
EXPECT_EQ(zone.getCard(8), &second);
EXPECT_EQ(zone.getCard(99), nullptr);
}
TEST(CardZoneLogicTest, ReorganizeSignalFiresOnAdd)
{
HandZoneLogic zone(nullptr, QStringLiteral("hand"), false, false, true);
CardState card(nullptr, nullptr, cardRefFor(QStringLiteral("Cat")), 7);
QSignalSpy reorganizeSpy(&zone, &CardZoneLogic::reorganizeCards);
zone.addCard(&card, true, 0);
EXPECT_EQ(reorganizeSpy.count(), 1);
}

64
tests/game/data/cards.xml Normal file
View file

@ -0,0 +1,64 @@
<?xml version="1.0" encoding="UTF-8"?>
<cockatrice_carddatabase version="4">
<cards>
<card>
<name>Cat</name>
<set>CAT</set>
<tablerow>0</tablerow>
<text>Meow!</text>
<prop>
<muid>111</muid>
<colors>G</colors>
<manacost>2G</manacost>
<cmc>2</cmc>
<type>Creature — Cat</type>
<maintype>Creature</maintype>
<pt>3/3</pt>
</prop>
</card>
<card>
<name>Grizzly Bears</name>
<set>LEA</set>
<tablerow>0</tablerow>
<cipt>1</cipt>
<text></text>
<prop>
<muid>112</muid>
<colors>G</colors>
<manacost>1G</manacost>
<cmc>2</cmc>
<type>Creature — Bear</type>
<maintype>Creature</maintype>
<pt>2/2</pt>
</prop>
</card>
<card>
<name>Lightning Bolt</name>
<set>LEA</set>
<tablerow>3</tablerow>
<text>Lightning Bolt deals 3 damage to any target.</text>
<prop>
<muid>113</muid>
<colors>R</colors>
<manacost>R</manacost>
<cmc>1</cmc>
<type>Instant</type>
<maintype>Instant</maintype>
</prop>
</card>
<card>
<name>Forest</name>
<set>LEA</set>
<tablerow>2</tablerow>
<text>(Add G.)</text>
<prop>
<muid>114</muid>
<colors>G</colors>
<manacost></manacost>
<cmc>0</cmc>
<type>Land</type>
<maintype>Land</maintype>
</prop>
</card>
</cards>
</cockatrice_carddatabase>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<cockatrice_carddatabase version="4">
<cards />
</cockatrice_carddatabase>

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