Merge branch 'master' into master

This commit is contained in:
Jurriaan 2026-11-02 16:43:51 +01:00 • committed by GitHub
commit ba22736def
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
114 changed files with 4835 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
@ -290,6 +291,7 @@ set(cockatrice_SOURCES
src/interface/widgets/utility/sequence_edit.cpp
src/interface/widgets/utility/visibility_change_listener.cpp
src/interface/widgets/utility/visibility_change_listener.h
src/interface/widgets/visual_database_display/card_database_visual_view.cpp
src/interface/widgets/visual_database_display/visual_database_display_color_filter_widget.cpp
src/interface/widgets/visual_database_display/visual_database_display_filter_save_load_widget.cpp
src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.cpp

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

@ -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);
@ -191,8 +180,6 @@ VisualDatabaseDisplayWidget::VisualDatabaseDisplayWidget(QWidget *parent,
databaseLoadIndicator->setVisible(false);
}
QScrollBar *scrollBar = flowWidget->scrollArea->verticalScrollBar();
connect(scrollBar, &QScrollBar::valueChanged, [this](int /*value*/) { loadCurrentPage(); });
retranslateUi();
}
@ -211,7 +198,7 @@ void VisualDatabaseDisplayWidget::initialize()
mainLayout->addWidget(databaseView);
mainLayout->addWidget(flowWidget);
mainLayout->addWidget(cardView);
mainLayout->addWidget(cardSizeWidget);
@ -242,8 +229,6 @@ void VisualDatabaseDisplayWidget::initializeFilters()
databaseDisplayModel->setFilterTree(filterModel->filterTree());
QTimer::singleShot(5000, this, [this] { loadCurrentPage(); });
retranslateUi();
});
}
@ -279,71 +264,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)
@ -369,113 +319,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

@ -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

@ -21,10 +21,12 @@ cockatrice_add_test(
)
# ------------------------
# Filter String and Tree Test
# (guard must match the condition for libcockatrice_filters in the root CMakeLists.txt)
# Guard to match condition for libcockatrice_filters in the root CMakeLists.txt
# ------------------------
if(WITH_ORACLE OR WITH_CLIENT)
# ------------------------
# Filter String Test
# ------------------------
cockatrice_add_test(
NAME
filter_string_test
@ -35,6 +37,10 @@ if(WITH_ORACLE OR WITH_CLIENT)
LIBS
libcockatrice_filters
)
# ------------------------
# Filter Tree Test
# ------------------------
cockatrice_add_test(
NAME
filter_tree_test
@ -45,6 +51,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>

View file

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<cockatrice_carddatabase version="4">
<cards>
<card>
<name>Kitten</name>
<set>CAT</set>
<tablerow>0</tablerow>
<text></text>
<token>1</token>
<prop>
<muid>211</muid>
<colors>G</colors>
<manacost></manacost>
<cmc></cmc>
<type>Token</type>
<maintype>Token</maintype>
<pt>1/1</pt>
</prop>
</card>
</cards>
</cockatrice_carddatabase>

View file

@ -0,0 +1,566 @@
#include "game_test_fixture.h"
#include "gtest/gtest.h"
#include <QList>
#include <QObject>
#include <QPoint>
#include <QString>
#include <libcockatrice/game/board/card_state.h>
#include <libcockatrice/game/board/counter_state.h>
#include <libcockatrice/game/game_event_handler.h>
#include <libcockatrice/game/game_meta_info.h>
#include <libcockatrice/game/game_state.h>
#include <libcockatrice/game/zones/table_zone_logic.h>
#include <libcockatrice/protocol/pb/context_concede.pb.h>
#include <libcockatrice/protocol/pb/event_attach_card.pb.h>
#include <libcockatrice/protocol/pb/event_change_zone_properties.pb.h>
#include <libcockatrice/protocol/pb/event_create_counter.pb.h>
#include <libcockatrice/protocol/pb/event_create_token.pb.h>
#include <libcockatrice/protocol/pb/event_del_counter.pb.h>
#include <libcockatrice/protocol/pb/event_destroy_card.pb.h>
#include <libcockatrice/protocol/pb/event_draw_cards.pb.h>
#include <libcockatrice/protocol/pb/event_flip_card.pb.h>
#include <libcockatrice/protocol/pb/event_game_closed.pb.h>
#include <libcockatrice/protocol/pb/event_game_host_changed.pb.h>
#include <libcockatrice/protocol/pb/event_game_state_changed.pb.h>
#include <libcockatrice/protocol/pb/event_kicked.pb.h>
#include <libcockatrice/protocol/pb/event_leave.pb.h>
#include <libcockatrice/protocol/pb/event_move_card.pb.h>
#include <libcockatrice/protocol/pb/event_player_properties_changed.pb.h>
#include <libcockatrice/protocol/pb/event_set_active_phase.pb.h>
#include <libcockatrice/protocol/pb/event_set_active_player.pb.h>
#include <libcockatrice/protocol/pb/event_set_card_attr.pb.h>
#include <libcockatrice/protocol/pb/event_set_card_counter.pb.h>
#include <libcockatrice/protocol/pb/event_set_counter.pb.h>
#include <libcockatrice/protocol/pb/game_event_container.pb.h>
#include <libcockatrice/protocol/pb/game_event_context.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_card.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_counter.pb.h>
#include <libcockatrice/utility/zone_names.h>
#include <string>
namespace
{
/**
* Wraps one player-scoped event in the container the server would send and
* pushes it through the real dispatcher, so the test exercises the same
* GameEventHandler -> PlayerEventHandler path as a live client.
*/
template <typename EventType>
void sendGameEvent(GameHarness &harness,
int playerId,
const EventType &message,
const GameEventContext *context = nullptr)
{
GameEventContainer container;
container.set_game_id(42);
if (context != nullptr) {
container.mutable_context()->CopyFrom(*context);
}
GameEvent *event = container.add_event_list();
event->set_player_id(playerId);
event->MutableExtension(EventType::ext)->CopyFrom(message);
harness.game()->getGameEventHandler()->processGameEventContainer(container, harness.client(),
EventProcessingOptions());
}
Event_SetCardAttr cardAttrEvent(const char *zoneName, int cardId, CardAttribute attribute, const char *value)
{
Event_SetCardAttr event;
event.set_zone_name(zoneName);
event.set_card_id(cardId);
event.set_attribute(attribute);
event.set_attr_value(value);
return event;
}
} // namespace
TEST(GameEventHandlerTest, MoveCardMovesBetweenZones)
{
GameHarness harness;
CardState *cat = harness.addCardToZone(ZoneNames::HAND, QStringLiteral("Cat"), 7);
ASSERT_NE(cat, nullptr);
Event_MoveCard event;
event.set_card_id(7);
event.set_start_player_id(1);
event.set_start_zone(ZoneNames::HAND);
event.set_position(0);
event.set_target_player_id(1);
event.set_target_zone(ZoneNames::TABLE);
event.set_x(0);
event.set_y(TableZoneLogic::tableRowToGridY(0));
event.set_new_card_id(7);
event.set_face_down(false);
sendGameEvent(harness, 1, event);
EXPECT_TRUE(harness.zone(ZoneNames::HAND)->getCards().isEmpty());
CardZoneLogic *table = harness.zone(ZoneNames::TABLE);
ASSERT_EQ(table->getCards().size(), 1);
EXPECT_EQ(table->getCards().first(), cat);
EXPECT_EQ(cat->getZone(), table);
EXPECT_EQ(cat->getId(), 7);
EXPECT_EQ(cat->getCardRef().name, QStringLiteral("Cat"));
EXPECT_FALSE(cat->getFaceDown());
EXPECT_EQ(cat->getPT(), QStringLiteral("3/3"));
EXPECT_EQ(cat->getGridPoint(), QPoint(0, TableZoneLogic::tableRowToGridY(0)));
}
TEST(GameEventHandlerTest, MoveCardFaceDownKeepsTheCardHidden)
{
GameHarness harness;
CardState *cat = harness.addCardToZone(ZoneNames::HAND, QStringLiteral("Cat"), 7);
ASSERT_NE(cat, nullptr);
Event_MoveCard event;
event.set_card_id(7);
event.set_start_player_id(1);
event.set_start_zone(ZoneNames::HAND);
event.set_position(0);
event.set_target_player_id(1);
event.set_target_zone(ZoneNames::TABLE);
event.set_x(1);
event.set_y(TableZoneLogic::tableRowToGridY(2));
event.set_new_card_id(7);
event.set_face_down(true);
sendGameEvent(harness, 1, event);
ASSERT_EQ(harness.zone(ZoneNames::TABLE)->getCards().size(), 1);
EXPECT_TRUE(cat->getFaceDown());
// A face-down card keeps whatever P/T it had instead of picking up the
// printed one from the card database.
EXPECT_TRUE(cat->getPT().isEmpty());
}
TEST(GameEventHandlerTest, SetCardAttrUpdatesSingleCard)
{
GameHarness harness;
CardState *cat = harness.addCardToZone(ZoneNames::TABLE, QStringLiteral("Cat"), 7);
ASSERT_NE(cat, nullptr);
sendGameEvent(harness, 1, cardAttrEvent(ZoneNames::TABLE, 7, AttrTapped, "1"));
sendGameEvent(harness, 1, cardAttrEvent(ZoneNames::TABLE, 7, AttrAttacking, "1"));
sendGameEvent(harness, 1, cardAttrEvent(ZoneNames::TABLE, 7, AttrPT, "5/5"));
sendGameEvent(harness, 1, cardAttrEvent(ZoneNames::TABLE, 7, AttrAnnotation, "note"));
sendGameEvent(harness, 1, cardAttrEvent(ZoneNames::TABLE, 7, AttrColor, "red"));
sendGameEvent(harness, 1, cardAttrEvent(ZoneNames::TABLE, 7, AttrDoesntUntap, "1"));
sendGameEvent(harness, 1, cardAttrEvent(ZoneNames::TABLE, 7, AttrFaceDown, "1"));
EXPECT_TRUE(cat->getTapped());
EXPECT_TRUE(cat->getAttacking());
EXPECT_EQ(cat->getPT(), QStringLiteral("5/5"));
EXPECT_EQ(cat->getAnnotation(), QStringLiteral("note"));
EXPECT_EQ(cat->getColor(), QStringLiteral("red"));
EXPECT_TRUE(cat->getDoesntUntap());
EXPECT_TRUE(cat->getFaceDown());
}
TEST(GameEventHandlerTest, SetCardAttrWithoutCardIdTouchesEveryCardInZone)
{
GameHarness harness;
CardState *cat = harness.addCardToZone(ZoneNames::TABLE, QStringLiteral("Cat"), 7);
CardState *bears = harness.addCardToZone(ZoneNames::TABLE, QStringLiteral("Grizzly Bears"), 9);
ASSERT_NE(cat, nullptr);
ASSERT_NE(bears, nullptr);
Event_SetCardAttr event;
event.set_zone_name(ZoneNames::TABLE);
event.set_attribute(AttrAnnotation);
event.set_attr_value("marked");
sendGameEvent(harness, 1, event);
EXPECT_EQ(cat->getAnnotation(), QStringLiteral("marked"));
EXPECT_EQ(bears->getAnnotation(), QStringLiteral("marked"));
}
TEST(GameEventHandlerTest, UntapAllLeavesDoesntUntapCardsTapped)
{
GameHarness harness;
CardState *cat = harness.addCardToZone(ZoneNames::TABLE, QStringLiteral("Cat"), 7);
CardState *bears = harness.addCardToZone(ZoneNames::TABLE, QStringLiteral("Grizzly Bears"), 9);
ASSERT_NE(cat, nullptr);
ASSERT_NE(bears, nullptr);
cat->setDoesntUntap(true);
sendGameEvent(harness, 1, cardAttrEvent(ZoneNames::TABLE, 7, AttrTapped, "1"));
sendGameEvent(harness, 1, cardAttrEvent(ZoneNames::TABLE, 9, AttrTapped, "1"));
Event_SetCardAttr untapAll;
untapAll.set_zone_name(ZoneNames::TABLE);
untapAll.set_attribute(AttrTapped);
untapAll.set_attr_value("0");
sendGameEvent(harness, 1, untapAll);
EXPECT_TRUE(cat->getTapped());
EXPECT_FALSE(bears->getTapped());
}
TEST(GameEventHandlerTest, SetCardCounterAddsAndRemoves)
{
GameHarness harness;
CardState *cat = harness.addCardToZone(ZoneNames::HAND, QStringLiteral("Cat"), 7);
ASSERT_NE(cat, nullptr);
Event_SetCardCounter add;
add.set_zone_name(ZoneNames::HAND);
add.set_card_id(7);
add.set_counter_id(1);
add.set_counter_value(2);
sendGameEvent(harness, 1, add);
EXPECT_EQ(cat->getCounters().value(1), 2);
Event_SetCardCounter remove = add;
remove.set_counter_value(0);
sendGameEvent(harness, 1, remove);
EXPECT_FALSE(cat->getCounters().contains(1));
}
TEST(GameEventHandlerTest, AttachCardLinksBothEndsAndUnattachClearsThem)
{
GameHarness harness;
CardState *host = harness.addCardToZone(ZoneNames::TABLE, QStringLiteral("Cat"), 7);
CardState *guest = harness.addCardToZone(ZoneNames::TABLE, QStringLiteral("Grizzly Bears"), 9);
ASSERT_NE(host, nullptr);
ASSERT_NE(guest, nullptr);
Event_AttachCard attach;
attach.set_start_zone(ZoneNames::TABLE);
attach.set_card_id(9);
attach.set_target_player_id(1);
attach.set_target_zone(ZoneNames::TABLE);
attach.set_target_card_id(7);
sendGameEvent(harness, 1, attach);
EXPECT_EQ(guest->getAttachedTo(), host);
ASSERT_EQ(host->getAttachedCards().size(), 1);
EXPECT_EQ(host->getAttachedCards().first(), guest);
Event_AttachCard detach;
detach.set_start_zone(ZoneNames::TABLE);
detach.set_card_id(9);
sendGameEvent(harness, 1, detach);
EXPECT_EQ(guest->getAttachedTo(), nullptr);
EXPECT_TRUE(host->getAttachedCards().isEmpty());
}
TEST(GameEventHandlerTest, ChangeZonePropertiesUpdatesReveal)
{
GameHarness harness;
CardZoneLogic *deck = harness.zone(ZoneNames::DECK);
ASSERT_NE(deck, nullptr);
Event_ChangeZoneProperties reveal;
reveal.set_zone_name(ZoneNames::DECK);
reveal.set_always_reveal_top_card(true);
sendGameEvent(harness, 1, reveal);
EXPECT_TRUE(deck->getAlwaysRevealTopCard());
reveal.set_always_reveal_top_card(false);
sendGameEvent(harness, 1, reveal);
EXPECT_FALSE(deck->getAlwaysRevealTopCard());
}
TEST(GameEventHandlerTest, DrawCardsMovesNamedCardsFromDeckToHand)
{
GameHarness harness;
ASSERT_NE(harness.addCardToZone(ZoneNames::DECK, QStringLiteral("Cat"), 7), nullptr);
Event_DrawCards draw;
draw.set_number(1);
ServerInfo_Card *cardInfo = draw.add_cards();
cardInfo->set_id(7);
cardInfo->set_name("Cat");
sendGameEvent(harness, 1, draw);
EXPECT_TRUE(harness.zone(ZoneNames::DECK)->getCards().isEmpty());
ASSERT_EQ(harness.zone(ZoneNames::HAND)->getCards().size(), 1);
CardState *drawn = harness.zone(ZoneNames::HAND)->getCards().first();
EXPECT_EQ(drawn->getId(), 7);
EXPECT_EQ(drawn->getName(), QStringLiteral("Cat"));
}
TEST(GameEventHandlerTest, FlipCardHidesAndRevealsTheCard)
{
GameHarness harness;
CardState *cat = harness.addCardToZone(ZoneNames::TABLE, QStringLiteral("Cat"), 7);
ASSERT_NE(cat, nullptr);
Event_FlipCard flip;
flip.set_zone_name(ZoneNames::TABLE);
flip.set_card_id(7);
flip.set_face_down(true);
sendGameEvent(harness, 1, flip);
EXPECT_TRUE(cat->getFaceDown());
// Flipping face down does not touch the card reference: the client must not
// learn (or overwrite) the name of a card it is not allowed to see.
EXPECT_EQ(cat->getName(), QStringLiteral("Cat"));
// Turning it face up carries the real printing, so the state picks it up.
flip.set_face_down(false);
flip.set_card_name("Grizzly Bears");
sendGameEvent(harness, 1, flip);
EXPECT_FALSE(cat->getFaceDown());
EXPECT_EQ(cat->getName(), QStringLiteral("Grizzly Bears"));
}
TEST(GameEventHandlerTest, DestroyCardRemovesItAndDetachesAttachedCards)
{
GameHarness harness;
CardState *host = harness.addCardToZone(ZoneNames::TABLE, QStringLiteral("Cat"), 7);
CardState *guest = harness.addCardToZone(ZoneNames::TABLE, QStringLiteral("Grizzly Bears"), 9);
ASSERT_NE(host, nullptr);
ASSERT_NE(guest, nullptr);
guest->setAttachedTo(host);
ASSERT_EQ(guest->getAttachedTo(), host);
ASSERT_EQ(host->getAttachedCards().size(), 1);
Event_DestroyCard destroy;
destroy.set_zone_name(ZoneNames::TABLE);
destroy.set_card_id(7);
sendGameEvent(harness, 1, destroy);
CardZoneLogic *table = harness.zone(ZoneNames::TABLE);
ASSERT_EQ(table->getCards().size(), 1);
EXPECT_EQ(table->getCards().first(), guest);
EXPECT_FALSE(table->getCards().contains(host));
// The attached card survives the host and is left un-attached, ready to be
// laid out again by the zone.
EXPECT_EQ(guest->getAttachedTo(), nullptr);
EXPECT_TRUE(host->getAttachedCards().isEmpty());
EXPECT_EQ(guest->getZone(), table);
}
TEST(GameEventHandlerTest, CreateTokenUsesDatabasePTUnlessFaceDown)
{
GameHarness harness;
CardZoneLogic *table = harness.zone(ZoneNames::TABLE);
ASSERT_NE(table, nullptr);
QList<ServerInfo_Card> created;
QObject::connect(table, &CardZoneLogic::createCardRequested, table,
[&created](const ServerInfo_Card &cardInfo, bool) { created.append(cardInfo); });
Event_CreateToken token;
token.set_zone_name(ZoneNames::TABLE);
token.set_card_id(11);
token.set_card_name("Kitten");
token.set_x(2);
token.set_y(1);
token.set_color("green");
token.set_annotation("a kitten");
token.set_destroy_on_zone_change(true);
sendGameEvent(harness, 1, token);
ASSERT_EQ(created.size(), 1);
// The event carries no P/T, so the token takes the printed one.
EXPECT_EQ(created.first().pt(), "1/1");
EXPECT_EQ(created.first().name(), "Kitten");
EXPECT_EQ(created.first().x(), 2);
EXPECT_EQ(created.first().y(), 1);
EXPECT_EQ(created.first().color(), "green");
EXPECT_EQ(created.first().annotation(), "a kitten");
EXPECT_TRUE(created.first().destroy_on_zone_change());
EXPECT_FALSE(created.first().face_down());
// An explicit P/T always wins over the database.
Event_CreateToken explicitPt = token;
explicitPt.set_card_id(12);
explicitPt.set_pt("3/3");
sendGameEvent(harness, 1, explicitPt);
ASSERT_EQ(created.size(), 2);
EXPECT_EQ(created.last().pt(), "3/3");
// A face-down token must not leak the printed P/T.
Event_CreateToken faceDown = token;
faceDown.set_card_id(13);
faceDown.set_face_down(true);
sendGameEvent(harness, 1, faceDown);
ASSERT_EQ(created.size(), 3);
EXPECT_TRUE(created.last().face_down());
EXPECT_TRUE(created.last().pt().empty());
}
TEST(GameEventHandlerTest, ConcedeAndUnconcedeUpdatePlayerState)
{
GameHarness harness;
ASSERT_NE(harness.addCardToZone(ZoneNames::TABLE, QStringLiteral("Cat"), 7), nullptr);
EXPECT_FALSE(harness.player()->getConceded());
Event_PlayerPropertiesChanged properties;
properties.mutable_player_properties()->set_player_id(1);
properties.mutable_player_properties()->set_conceded(true);
GameEventContext concedeContext;
concedeContext.MutableExtension(Context_Concede::ext);
sendGameEvent(harness, 1, properties, &concedeContext);
EXPECT_TRUE(harness.player()->getConceded());
// Conceding tears the board down for that player.
EXPECT_TRUE(harness.zone(ZoneNames::TABLE)->getCards().isEmpty());
Event_PlayerPropertiesChanged back = properties;
back.mutable_player_properties()->set_conceded(false);
GameEventContext unconcedeContext;
unconcedeContext.MutableExtension(Context_Unconcede::ext);
sendGameEvent(harness, 1, back, &unconcedeContext);
EXPECT_FALSE(harness.player()->getConceded());
}
TEST(GameEventHandlerTest, SetActivePlayerAndPhaseUpdateGameState)
{
GameHarness harness;
GameState *state = harness.game()->getGameState();
ASSERT_NE(state, nullptr);
EXPECT_EQ(state->getActivePlayer(), -1);
EXPECT_EQ(state->getCurrentPhase(), -1);
Event_SetActivePlayer setPlayer;
setPlayer.set_active_player_id(1);
sendGameEvent(harness, 1, setPlayer);
EXPECT_EQ(state->getActivePlayer(), 1);
Event_SetActivePhase setPhase;
setPhase.set_phase(3);
sendGameEvent(harness, 1, setPhase);
EXPECT_EQ(state->getCurrentPhase(), 3);
}
TEST(GameEventHandlerTest, CreateSetAndDeletePlayerCounter)
{
GameHarness harness;
ServerInfo_Counter counter;
counter.set_id(1);
counter.set_name("life");
counter.set_radius(1);
counter.set_count(20);
Event_CreateCounter create;
create.mutable_counter_info()->CopyFrom(counter);
sendGameEvent(harness, 1, create);
CounterState *state = harness.player()->getCounters().value(1);
ASSERT_NE(state, nullptr);
EXPECT_EQ(state->getName(), QStringLiteral("life"));
EXPECT_EQ(state->getValue(), 20);
EXPECT_EQ(harness.player()->getLifeCounter(), state);
Event_SetCounter set;
set.set_counter_id(1);
set.set_value(15);
sendGameEvent(harness, 1, set);
EXPECT_EQ(state->getValue(), 15);
// Updating a counter that does not exist is a no-op, not a crash.
Event_SetCounter unknown = set;
unknown.set_counter_id(99);
sendGameEvent(harness, 1, unknown);
EXPECT_FALSE(harness.player()->getCounters().contains(99));
Event_DelCounter remove;
remove.set_counter_id(1);
sendGameEvent(harness, 1, remove);
EXPECT_FALSE(harness.player()->getCounters().contains(1));
EXPECT_EQ(harness.player()->getLifeCounter(), nullptr);
}
TEST(GameEventHandlerTest, LeaveRemovesThePlayer)
{
GameHarness harness;
ASSERT_NE(harness.addCardToZone(ZoneNames::TABLE, QStringLiteral("Cat"), 7), nullptr);
ASSERT_NE(harness.game()->getPlayerManager()->getPlayer(1), nullptr);
Event_Leave leave;
leave.set_reason(Event_Leave::USER_LEFT);
sendGameEvent(harness, 1, leave);
EXPECT_EQ(harness.game()->getPlayerManager()->getPlayer(1), nullptr);
EXPECT_TRUE(harness.game()->getPlayerManager()->getPlayers().isEmpty());
}
TEST(GameEventHandlerTest, KickedSignalsGameClosed)
{
GameHarness harness;
bool closed = false;
QObject::connect(harness.game()->getGameEventHandler(), &GameEventHandler::gameClosed,
[&closed] { closed = true; });
sendGameEvent(harness, 1, Event_Kicked());
EXPECT_TRUE(closed);
}
TEST(GameEventHandlerTest, GameClosedMarksTheSessionClosed)
{
GameHarness harness;
ASSERT_FALSE(harness.game()->getGameState()->isGameClosed());
Event_GameStateChanged started;
started.set_game_started(true);
sendGameEvent(harness, 1, started);
ASSERT_TRUE(harness.game()->getGameMetaInfo()->started());
sendGameEvent(harness, 1, Event_GameClosed());
EXPECT_TRUE(harness.game()->getGameState()->isGameClosed());
EXPECT_FALSE(harness.game()->getGameMetaInfo()->started());
}
TEST(GameEventHandlerTest, GameHostChangedMovesTheHostRole)
{
GameHarness harness;
ServerInfo_User bob;
bob.set_name("Bob");
bob.set_user_level(ServerInfo_User::IsUser);
ASSERT_NE(harness.game()->getPlayerManager()->addPlayer(2, bob), nullptr);
sendGameEvent(harness, 2, Event_GameHostChanged());
EXPECT_EQ(harness.game()->getGameState()->getHostId(), 2);
}

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