mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-11-05 09:33:25 -08:00
Compare commits
18 commits
164a67404d
...
734f91c8e9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
734f91c8e9 | ||
|
|
fecf10e2b1 | ||
|
|
9f30892dff | ||
|
|
8b0c980e85 | ||
|
|
62e590d300 | ||
|
|
a1a8a59f4a | ||
|
|
94e6281d87 | ||
|
|
3cf8bdb00e | ||
|
|
5134e72eb4 | ||
|
|
b37c8b828f | ||
|
|
719863e43d | ||
|
|
d021f20d14 | ||
|
|
ce543896f5 | ||
|
|
429eb2a1fd | ||
|
|
a74dfeef6e | ||
|
|
5fe7f186d3 | ||
|
|
50a2ae9cc8 | ||
|
|
ef4574d2b9 |
121 changed files with 5804 additions and 1058 deletions
|
|
@ -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"
|
||||
|
|
|
|||
32
.github/workflows/desktop-build.yml
vendored
32
.github/workflows/desktop-build.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -248,6 +249,7 @@ set(cockatrice_SOURCES
|
|||
src/interface/widgets/playmat/playmat_settings_dialog.cpp
|
||||
src/interface/widgets/quick_settings/settings_button_widget.cpp
|
||||
src/interface/widgets/quick_settings/settings_popup_widget.cpp
|
||||
src/interface/widgets/replay/replay_event_index.cpp
|
||||
src/interface/widgets/replay/replay_manager.cpp
|
||||
src/interface/widgets/replay/replay_quick_settings_widget.cpp
|
||||
src/interface/widgets/replay/replay_timeline_widget.cpp
|
||||
|
|
@ -290,6 +292,7 @@ set(cockatrice_SOURCES
|
|||
src/interface/widgets/utility/sequence_edit.cpp
|
||||
src/interface/widgets/utility/visibility_change_listener.cpp
|
||||
src/interface/widgets/utility/visibility_change_listener.h
|
||||
src/interface/widgets/visual_database_display/card_database_visual_view.cpp
|
||||
src/interface/widgets/visual_database_display/visual_database_display_color_filter_widget.cpp
|
||||
src/interface/widgets/visual_database_display/visual_database_display_filter_save_load_widget.cpp
|
||||
src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.cpp
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
876
cockatrice/src/interface/widgets/replay/replay_event_index.cpp
Normal file
876
cockatrice/src/interface/widgets/replay/replay_event_index.cpp
Normal file
|
|
@ -0,0 +1,876 @@
|
|||
#include "replay_event_index.h"
|
||||
|
||||
#include <libcockatrice/game/phase.h>
|
||||
#include "libcockatrice/protocol/get_pb_extension.h"
|
||||
#include "libcockatrice/protocol/pb/card_attributes.pb.h"
|
||||
#include "libcockatrice/protocol/pb/context_concede.pb.h"
|
||||
#include "libcockatrice/protocol/pb/context_connection_state_changed.pb.h"
|
||||
#include "libcockatrice/protocol/pb/context_deck_select.pb.h"
|
||||
#include "libcockatrice/protocol/pb/context_mulligan.pb.h"
|
||||
#include "libcockatrice/protocol/pb/context_ready_start.pb.h"
|
||||
#include "libcockatrice/protocol/pb/context_set_sideboard_lock.pb.h"
|
||||
#include "libcockatrice/protocol/pb/context_undo_draw.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_dump_zone.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_log_notice.pb.h"
|
||||
#include "libcockatrice/protocol/pb/event_game_say.pb.h"
|
||||
#include "libcockatrice/protocol/pb/event_game_state_changed.pb.h"
|
||||
#include "libcockatrice/protocol/pb/event_join.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_reveal_cards.pb.h"
|
||||
#include "libcockatrice/protocol/pb/event_reverse_turn.pb.h"
|
||||
#include "libcockatrice/protocol/pb/event_roll_die.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/event_shuffle.pb.h"
|
||||
#include "libcockatrice/protocol/pb/game_event.pb.h"
|
||||
#include "libcockatrice/protocol/pb/game_event_container.pb.h"
|
||||
#include "libcockatrice/protocol/pb/game_event_context.pb.h"
|
||||
#include "libcockatrice/protocol/pb/game_replay.pb.h"
|
||||
#include "libcockatrice/protocol/pb/serverinfo_card.pb.h"
|
||||
#include "libcockatrice/protocol/pb/serverinfo_counter.pb.h"
|
||||
#include "libcockatrice/protocol/pb/serverinfo_player.pb.h"
|
||||
#include "libcockatrice/protocol/pb/serverinfo_playerproperties.pb.h"
|
||||
#include "libcockatrice/protocol/pb/serverinfo_zone.pb.h"
|
||||
#include "libcockatrice/utility/zone_names.h"
|
||||
|
||||
#include <QCoreApplication>
|
||||
#include <QHash>
|
||||
#include <QStringList>
|
||||
#include <QtGlobal>
|
||||
#include <algorithm>
|
||||
|
||||
namespace
|
||||
{
|
||||
struct PendingDetail
|
||||
{
|
||||
QString text;
|
||||
QString badge;
|
||||
ReplayEventKind kind = ReplayEventKind::Action;
|
||||
QString actor;
|
||||
};
|
||||
|
||||
QString formatTime(int timeMs)
|
||||
{
|
||||
const int totalSeconds = qMax(0, timeMs) / 1000;
|
||||
const int hours = totalSeconds / 3600;
|
||||
const int minutes = (totalSeconds / 60) % 60;
|
||||
const int seconds = totalSeconds % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return QStringLiteral("%1:%2:%3")
|
||||
.arg(hours)
|
||||
.arg(minutes, 2, 10, QLatin1Char('0'))
|
||||
.arg(seconds, 2, 10, QLatin1Char('0'));
|
||||
}
|
||||
|
||||
return QStringLiteral("%1:%2").arg(minutes, 2, 10, QLatin1Char('0')).arg(seconds, 2, 10, QLatin1Char('0'));
|
||||
}
|
||||
|
||||
class ReplayEventIndexBuilder
|
||||
{
|
||||
Q_DECLARE_TR_FUNCTIONS(ReplayEventIndex)
|
||||
|
||||
public:
|
||||
ReplayEventIndexBuilder(const GameReplay *replay, const QList<int> &timeline) : replay(replay), timeline(timeline)
|
||||
{
|
||||
}
|
||||
|
||||
QList<ReplayEventEntry> build()
|
||||
{
|
||||
QList<ReplayEventEntry> entries;
|
||||
|
||||
for (int i = 0; i < replay->event_list_size(); ++i) {
|
||||
const GameEventContainer &container = replay->event_list(i);
|
||||
const QList<PendingDetail> details = describeContainer(container);
|
||||
if (details.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
ReplayEventEntry entry;
|
||||
entry.eventIndex = i;
|
||||
entry.timeMs = timeline.value(i, static_cast<int>(container.seconds_elapsed()) * 1000);
|
||||
entry.timeLabel = formatTime(entry.timeMs);
|
||||
entry.kind = details.first().kind;
|
||||
entry.badge = details.first().badge;
|
||||
if (details.size() > 1) {
|
||||
entry.badge += QStringLiteral(" +%1").arg(details.size() - 1);
|
||||
}
|
||||
|
||||
const QString sharedActor = details.first().actor;
|
||||
const bool mixedActors = std::any_of(details.cbegin(), details.cend(), [&sharedActor](const auto &detail) {
|
||||
return detail.actor != sharedActor;
|
||||
});
|
||||
|
||||
QStringList detailTexts;
|
||||
for (const PendingDetail &detail : details) {
|
||||
if (mixedActors && !detail.actor.isEmpty()) {
|
||||
detailTexts.append(tr("%1: %2").arg(detail.actor, detail.text));
|
||||
} else {
|
||||
detailTexts.append(detail.text);
|
||||
}
|
||||
}
|
||||
entry.actor = mixedActors ? QString() : sharedActor;
|
||||
entry.detail = detailTexts.join(QStringLiteral(" • "));
|
||||
|
||||
entries.append(entry);
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
private:
|
||||
const GameReplay *replay;
|
||||
const QList<int> &timeline;
|
||||
|
||||
QHash<int, QString> playerNames;
|
||||
QHash<int, bool> spectators;
|
||||
QHash<QString, QString> cardNames;
|
||||
QHash<QString, QString> counterNames;
|
||||
|
||||
int activePlayerId = -1;
|
||||
bool gameStarted = false;
|
||||
bool sawGameState = false;
|
||||
|
||||
static QString cardKey(int playerId, const QString &zone, int cardId)
|
||||
{
|
||||
return QStringLiteral("%1/%2/%3").arg(playerId).arg(zone).arg(cardId);
|
||||
}
|
||||
|
||||
static QString counterKey(int playerId, int counterId)
|
||||
{
|
||||
return QStringLiteral("%1/%2").arg(playerId).arg(counterId);
|
||||
}
|
||||
|
||||
QString playerName(int playerId) const
|
||||
{
|
||||
if (playerId < 0) {
|
||||
return tr("Game");
|
||||
}
|
||||
return playerNames.value(playerId, tr("Player %1").arg(playerId));
|
||||
}
|
||||
|
||||
QString actorName(int playerId) const
|
||||
{
|
||||
if (playerId < 0) {
|
||||
return QString();
|
||||
}
|
||||
return playerNames.value(playerId, tr("Player %1").arg(playerId));
|
||||
}
|
||||
|
||||
bool isSpectator(int playerId) const
|
||||
{
|
||||
return spectators.value(playerId, false);
|
||||
}
|
||||
|
||||
void rememberPlayer(const ServerInfo_PlayerProperties &properties)
|
||||
{
|
||||
const int playerId = properties.player_id();
|
||||
if (playerId < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const QString name = QString::fromStdString(properties.user_info().name());
|
||||
if (!name.isEmpty()) {
|
||||
playerNames.insert(playerId, name);
|
||||
}
|
||||
spectators.insert(playerId, properties.spectator());
|
||||
}
|
||||
|
||||
void rememberCard(int playerId, const QString &zone, int cardId, const QString &name)
|
||||
{
|
||||
if (cardId >= 0 && !name.isEmpty()) {
|
||||
cardNames.insert(cardKey(playerId, zone, cardId), name);
|
||||
}
|
||||
}
|
||||
|
||||
void forgetCard(int playerId, const QString &zone, int cardId)
|
||||
{
|
||||
if (cardId >= 0) {
|
||||
cardNames.remove(cardKey(playerId, zone, cardId));
|
||||
}
|
||||
}
|
||||
|
||||
QString cardName(int playerId, const QString &zone, int cardId) const
|
||||
{
|
||||
if (cardId < 0) {
|
||||
return QString();
|
||||
}
|
||||
return cardNames.value(cardKey(playerId, zone, cardId));
|
||||
}
|
||||
|
||||
void rememberCounter(int playerId, const ServerInfo_Counter &counter)
|
||||
{
|
||||
const QString name = QString::fromStdString(counter.name());
|
||||
if (!name.isEmpty()) {
|
||||
counterNames.insert(counterKey(playerId, counter.id()), name);
|
||||
}
|
||||
}
|
||||
|
||||
QString counterName(int playerId, int counterId) const
|
||||
{
|
||||
return counterNames.value(counterKey(playerId, counterId));
|
||||
}
|
||||
|
||||
QString zoneName(const QString &zone) const
|
||||
{
|
||||
if (zone == ZoneNames::TABLE) {
|
||||
return tr("battlefield");
|
||||
}
|
||||
if (zone == ZoneNames::GRAVE) {
|
||||
return tr("graveyard");
|
||||
}
|
||||
if (zone == ZoneNames::EXILE) {
|
||||
return tr("exile");
|
||||
}
|
||||
if (zone == ZoneNames::HAND) {
|
||||
return tr("hand");
|
||||
}
|
||||
if (zone == ZoneNames::DECK) {
|
||||
return tr("library");
|
||||
}
|
||||
if (zone == ZoneNames::SIDEBOARD) {
|
||||
return tr("sideboard");
|
||||
}
|
||||
if (zone == ZoneNames::STACK) {
|
||||
return tr("stack");
|
||||
}
|
||||
return zone;
|
||||
}
|
||||
|
||||
QString zonePhrase(int playerId, const QString &zone, int actorId) const
|
||||
{
|
||||
if (zone == ZoneNames::TABLE) {
|
||||
return tr("the battlefield");
|
||||
}
|
||||
if (zone == ZoneNames::EXILE) {
|
||||
return tr("exile");
|
||||
}
|
||||
if (zone == ZoneNames::STACK) {
|
||||
return tr("the stack");
|
||||
}
|
||||
|
||||
const QString name = zoneName(zone);
|
||||
if (playerId < 0 || playerId == actorId) {
|
||||
return tr("their %1").arg(name);
|
||||
}
|
||||
return tr("%1's %2").arg(playerName(playerId), name);
|
||||
}
|
||||
|
||||
QString formatCardList(const QStringList &names, int total) const
|
||||
{
|
||||
// Keep repeats: each entry is a drawn/revealed card, so the list length must agree with the count.
|
||||
QStringList listedNames;
|
||||
for (const QString &name : names) {
|
||||
if (!name.isEmpty()) {
|
||||
listedNames.append(name);
|
||||
}
|
||||
}
|
||||
|
||||
if (listedNames.isEmpty()) {
|
||||
if (total <= 0) {
|
||||
return tr("no cards");
|
||||
}
|
||||
return total == 1 ? tr("1 card") : tr("%1 cards").arg(total);
|
||||
}
|
||||
|
||||
if (total < listedNames.size()) {
|
||||
total = listedNames.size();
|
||||
}
|
||||
|
||||
if (listedNames.size() > 3) {
|
||||
return tr("%1 and %2 more").arg(listedNames.mid(0, 3).join(QStringLiteral(", "))).arg(total - 3);
|
||||
}
|
||||
if (total > listedNames.size()) {
|
||||
return tr("%1 and %2 more").arg(listedNames.join(QStringLiteral(", "))).arg(total - listedNames.size());
|
||||
}
|
||||
|
||||
return listedNames.join(QStringLiteral(", "));
|
||||
}
|
||||
|
||||
void updateGameState(const Event_GameStateChanged &event)
|
||||
{
|
||||
for (int i = 0; i < event.player_list_size(); ++i) {
|
||||
const ServerInfo_Player &player = event.player_list(i);
|
||||
const int playerId = player.properties().player_id();
|
||||
rememberPlayer(player.properties());
|
||||
|
||||
for (int j = 0; j < player.zone_list_size(); ++j) {
|
||||
const ServerInfo_Zone &zone = player.zone_list(j);
|
||||
for (int k = 0; k < zone.card_list_size(); ++k) {
|
||||
const ServerInfo_Card &card = zone.card_list(k);
|
||||
rememberCard(playerId, QString::fromStdString(zone.name()), card.id(),
|
||||
QString::fromStdString(card.name()));
|
||||
}
|
||||
}
|
||||
|
||||
for (int j = 0; j < player.counter_list_size(); ++j) {
|
||||
rememberCounter(playerId, player.counter_list(j));
|
||||
}
|
||||
}
|
||||
|
||||
if (event.has_active_player_id()) {
|
||||
activePlayerId = event.active_player_id();
|
||||
}
|
||||
if (event.has_game_started()) {
|
||||
gameStarted = event.game_started();
|
||||
}
|
||||
}
|
||||
|
||||
QString describeMoveCard(const Event_MoveCard &event, int actorId, const GameEventContext &context)
|
||||
{
|
||||
const int startPlayerId = event.start_player_id() >= 0 ? event.start_player_id() : actorId;
|
||||
const int targetPlayerId = event.target_player_id() >= 0 ? event.target_player_id() : startPlayerId;
|
||||
const QString startZone = QString::fromStdString(event.start_zone());
|
||||
const QString targetZone = event.has_target_zone() ? QString::fromStdString(event.target_zone()) : startZone;
|
||||
|
||||
QString name;
|
||||
if (event.has_card_name()) {
|
||||
name = QString::fromStdString(event.card_name());
|
||||
}
|
||||
if (name.isEmpty()) {
|
||||
name = cardName(startPlayerId, startZone, event.card_id());
|
||||
}
|
||||
|
||||
const bool knownName = !name.isEmpty();
|
||||
if (name.isEmpty()) {
|
||||
name = event.face_down() ? tr("a face-down card") : tr("a card");
|
||||
}
|
||||
|
||||
const int newCardId = event.has_new_card_id() ? event.new_card_id() : event.card_id();
|
||||
forgetCard(startPlayerId, startZone, event.card_id());
|
||||
if (knownName) {
|
||||
rememberCard(targetPlayerId, targetZone, newCardId, name);
|
||||
}
|
||||
|
||||
if (startZone == targetZone) {
|
||||
return tr("moved %1 within %2.").arg(name, zonePhrase(targetPlayerId, targetZone, actorId));
|
||||
}
|
||||
if (context.HasExtension(Context_UndoDraw::ext)) {
|
||||
return tr("returned %1 to %2.").arg(name, zonePhrase(targetPlayerId, targetZone, actorId));
|
||||
}
|
||||
|
||||
return tr("moved %1 from %2 to %3.")
|
||||
.arg(name, zonePhrase(startPlayerId, startZone, actorId), zonePhrase(targetPlayerId, targetZone, actorId));
|
||||
}
|
||||
|
||||
QString describeSetCardAttribute(const Event_SetCardAttr &event, int actorId)
|
||||
{
|
||||
const QString zone = QString::fromStdString(event.zone_name());
|
||||
QString name;
|
||||
if (event.has_card_id()) {
|
||||
name = cardName(actorId, zone, event.card_id());
|
||||
}
|
||||
if (name.isEmpty()) {
|
||||
name = event.has_card_id() ? tr("a card") : tr("all cards");
|
||||
}
|
||||
|
||||
const QString value = QString::fromStdString(event.attr_value());
|
||||
switch (event.attribute()) {
|
||||
case AttrTapped:
|
||||
return value == QLatin1String("1") ? tr("tapped %1.").arg(name) : tr("untapped %1.").arg(name);
|
||||
case AttrAttacking:
|
||||
return value == QLatin1String("1") ? tr("%1 is now attacking.").arg(name)
|
||||
: tr("%1 is no longer attacking.").arg(name);
|
||||
case AttrFaceDown:
|
||||
return value == QLatin1String("1") ? tr("turned %1 face down.").arg(name)
|
||||
: tr("turned %1 face up.").arg(name);
|
||||
case AttrColor:
|
||||
return tr("changed %1's color to %2.").arg(name, value);
|
||||
case AttrPT:
|
||||
return tr("set %1's power/toughness to %2.").arg(name, value);
|
||||
case AttrAnnotation:
|
||||
return value.isEmpty() ? tr("cleared %1's annotation.").arg(name)
|
||||
: tr("annotated %1: %2").arg(name, value);
|
||||
case AttrDoesntUntap:
|
||||
return value == QLatin1String("1") ? tr("%1 will no longer untap.").arg(name)
|
||||
: tr("%1 will untap normally again.").arg(name);
|
||||
}
|
||||
|
||||
return tr("changed %1.").arg(name);
|
||||
}
|
||||
|
||||
QString badgeForCardAttribute(CardAttribute attribute) const
|
||||
{
|
||||
switch (attribute) {
|
||||
case AttrTapped:
|
||||
return tr("TAP");
|
||||
case AttrAttacking:
|
||||
return tr("ATTACK");
|
||||
case AttrFaceDown:
|
||||
return tr("FLIP");
|
||||
case AttrColor:
|
||||
return tr("COLOR");
|
||||
case AttrPT:
|
||||
return tr("PT");
|
||||
case AttrAnnotation:
|
||||
return tr("NOTE");
|
||||
case AttrDoesntUntap:
|
||||
return tr("TAP");
|
||||
}
|
||||
return tr("CARD");
|
||||
}
|
||||
|
||||
QString describeLeaveReason(Event_Leave::LeaveReason reason) const
|
||||
{
|
||||
switch (reason) {
|
||||
case Event_Leave::USER_KICKED:
|
||||
return tr("kicked by game host or moderator");
|
||||
case Event_Leave::USER_LEFT:
|
||||
return tr("left the game");
|
||||
case Event_Leave::USER_DISCONNECTED:
|
||||
return tr("disconnected");
|
||||
case Event_Leave::OTHER:
|
||||
default:
|
||||
return tr("reason unknown");
|
||||
}
|
||||
}
|
||||
|
||||
void addDetail(QList<PendingDetail> &details,
|
||||
const QString &text,
|
||||
const QString &badge,
|
||||
ReplayEventKind kind,
|
||||
int actorId,
|
||||
bool forcedByJudge) const
|
||||
{
|
||||
if (text.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const QString finalText = forcedByJudge ? tr("%1 (judge)").arg(text) : text;
|
||||
details.append(PendingDetail{.text = finalText, .badge = badge, .kind = kind, .actor = actorName(actorId)});
|
||||
}
|
||||
|
||||
QList<PendingDetail> describeContainer(const GameEventContainer &container)
|
||||
{
|
||||
QList<PendingDetail> details;
|
||||
const GameEventContext &context = container.context();
|
||||
const bool forcedByJudge = container.has_forced_by_judge();
|
||||
const auto contextType = static_cast<GameEventContext::ContextType>(getPbExtension(context));
|
||||
|
||||
for (int i = 0; i < container.event_list_size(); ++i) {
|
||||
const GameEvent &event = container.event_list(i);
|
||||
const int eventPlayerId = event.player_id();
|
||||
const auto eventType = static_cast<GameEvent::GameEventType>(getPbExtension(event));
|
||||
|
||||
switch (eventType) {
|
||||
case GameEvent::GAME_STATE_CHANGED: {
|
||||
const Event_GameStateChanged &state = event.GetExtension(Event_GameStateChanged::ext);
|
||||
const bool wasStarted = gameStarted;
|
||||
updateGameState(state);
|
||||
if (state.game_started() && !wasStarted) {
|
||||
addDetail(details, tr("Game started."), tr("GAME"), ReplayEventKind::System, -1, forcedByJudge);
|
||||
} else if (!sawGameState) {
|
||||
addDetail(details, tr("Game state loaded."), tr("GAME"), ReplayEventKind::System, -1,
|
||||
forcedByJudge);
|
||||
}
|
||||
sawGameState = true;
|
||||
break;
|
||||
}
|
||||
case GameEvent::PLAYER_PROPERTIES_CHANGED: {
|
||||
const Event_PlayerPropertiesChanged &propertiesEvent =
|
||||
event.GetExtension(Event_PlayerPropertiesChanged::ext);
|
||||
const bool hasProperties = propertiesEvent.has_player_properties();
|
||||
const ServerInfo_PlayerProperties &properties = propertiesEvent.player_properties();
|
||||
// The server only fills in the field that changed, so player_id is absent from these events
|
||||
// unless they are about a specific player; fall back to the event's own player id.
|
||||
const bool describesPlayer = hasProperties && properties.has_player_id();
|
||||
const int playerId = describesPlayer ? properties.player_id() : eventPlayerId;
|
||||
if (describesPlayer) {
|
||||
rememberPlayer(properties);
|
||||
}
|
||||
|
||||
switch (contextType) {
|
||||
case GameEventContext::READY_START:
|
||||
addDetail(details,
|
||||
properties.ready_start() ? tr("is ready to start.") : tr("is no longer ready."),
|
||||
tr("READY"), ReplayEventKind::System, playerId, forcedByJudge);
|
||||
break;
|
||||
case GameEventContext::CONCEDE:
|
||||
addDetail(details, tr("conceded the game."), tr("CONCEDE"), ReplayEventKind::System,
|
||||
playerId, forcedByJudge);
|
||||
break;
|
||||
case GameEventContext::UNCONCEDE:
|
||||
addDetail(details, tr("unconceded the game."), tr("CONCEDE"), ReplayEventKind::System,
|
||||
playerId, forcedByJudge);
|
||||
break;
|
||||
case GameEventContext::DECK_SELECT: {
|
||||
const Context_DeckSelect &deckSelect = context.GetExtension(Context_DeckSelect::ext);
|
||||
const int sideboardSize = deckSelect.sideboard_size();
|
||||
addDetail(details,
|
||||
sideboardSize >= 0
|
||||
? tr("selected a deck with %1 sideboard cards.").arg(sideboardSize)
|
||||
: tr("selected a deck."),
|
||||
tr("DECK"), ReplayEventKind::System, playerId, forcedByJudge);
|
||||
break;
|
||||
}
|
||||
case GameEventContext::MULLIGAN: {
|
||||
const Context_Mulligan &mulligan = context.GetExtension(Context_Mulligan::ext);
|
||||
addDetail(details,
|
||||
mulligan.number() > 0 ? tr("took a mulligan (%1).").arg(mulligan.number())
|
||||
: tr("took a mulligan."),
|
||||
tr("MULLIGAN"), ReplayEventKind::System, playerId, forcedByJudge);
|
||||
break;
|
||||
}
|
||||
case GameEventContext::SET_SIDEBOARD_LOCK:
|
||||
addDetail(details,
|
||||
properties.sideboard_locked() ? tr("locked their sideboard.")
|
||||
: tr("unlocked their sideboard."),
|
||||
tr("SIDEBOARD"), ReplayEventKind::System, playerId, forcedByJudge);
|
||||
break;
|
||||
case GameEventContext::CONNECTION_STATE_CHANGED:
|
||||
addDetail(details,
|
||||
properties.ping_seconds() != -1 ? tr("reconnected to the game.")
|
||||
: tr("lost connection to the game."),
|
||||
tr("CONNECTION"), ReplayEventKind::System, playerId, forcedByJudge);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case GameEvent::JOIN: {
|
||||
const Event_Join &join = event.GetExtension(Event_Join::ext);
|
||||
if (join.has_player_properties()) {
|
||||
rememberPlayer(join.player_properties());
|
||||
}
|
||||
const int playerId = join.player_properties().player_id() >= 0
|
||||
? join.player_properties().player_id()
|
||||
: eventPlayerId;
|
||||
addDetail(details, isSpectator(playerId) ? tr("joined as a spectator.") : tr("joined the game."),
|
||||
tr("JOIN"), ReplayEventKind::System, playerId, forcedByJudge);
|
||||
break;
|
||||
}
|
||||
case GameEvent::LEAVE: {
|
||||
const Event_Leave &leave = event.GetExtension(Event_Leave::ext);
|
||||
const QString reason = describeLeaveReason(leave.reason());
|
||||
if (eventPlayerId < 0) {
|
||||
addDetail(details, tr("A player left the game (%1).").arg(reason), tr("LEAVE"),
|
||||
ReplayEventKind::System, -1, forcedByJudge);
|
||||
} else {
|
||||
addDetail(details,
|
||||
isSpectator(eventPlayerId) ? tr("stopped spectating (%1).").arg(reason)
|
||||
: tr("left the game (%1).").arg(reason),
|
||||
tr("LEAVE"), ReplayEventKind::System, eventPlayerId, forcedByJudge);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case GameEvent::KICKED:
|
||||
addDetail(details,
|
||||
eventPlayerId >= 0 ? tr("was kicked from the game.")
|
||||
: tr("A player was kicked from the game."),
|
||||
tr("KICK"), ReplayEventKind::System, eventPlayerId, forcedByJudge);
|
||||
break;
|
||||
case GameEvent::GAME_CLOSED:
|
||||
addDetail(details, tr("Game closed."), tr("GAME"), ReplayEventKind::System, -1, forcedByJudge);
|
||||
break;
|
||||
case GameEvent::GAME_HOST_CHANGED:
|
||||
addDetail(details, eventPlayerId >= 0 ? tr("is now the game host.") : tr("The game host changed."),
|
||||
tr("HOST"), ReplayEventKind::System, eventPlayerId, forcedByJudge);
|
||||
break;
|
||||
case GameEvent::GAME_SAY: {
|
||||
const Event_GameSay &say = event.GetExtension(Event_GameSay::ext);
|
||||
addDetail(details, QString::fromStdString(say.message()), tr("CHAT"), ReplayEventKind::Chat,
|
||||
eventPlayerId, forcedByJudge);
|
||||
break;
|
||||
}
|
||||
case GameEvent::SET_ACTIVE_PLAYER: {
|
||||
const Event_SetActivePlayer &setActivePlayer = event.GetExtension(Event_SetActivePlayer::ext);
|
||||
activePlayerId = setActivePlayer.active_player_id();
|
||||
if (activePlayerId >= 0) {
|
||||
addDetail(details, tr("begins their turn."), tr("TURN"), ReplayEventKind::Turn, activePlayerId,
|
||||
forcedByJudge);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case GameEvent::SET_ACTIVE_PHASE: {
|
||||
const Event_SetActivePhase &setActivePhase = event.GetExtension(Event_SetActivePhase::ext);
|
||||
if (setActivePhase.phase() >= 0) {
|
||||
const QString phaseName = Phases::getPhase(setActivePhase.phase()).getName();
|
||||
addDetail(details, tr("entered the %1 phase.").arg(phaseName), tr("PHASE"),
|
||||
ReplayEventKind::Phase, activePlayerId >= 0 ? activePlayerId : eventPlayerId,
|
||||
forcedByJudge);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case GameEvent::REVERSE_TURN: {
|
||||
const Event_ReverseTurn &reverseTurn = event.GetExtension(Event_ReverseTurn::ext);
|
||||
addDetail(details,
|
||||
reverseTurn.reversed() ? tr("reversed the turn order.") : tr("restored the turn order."),
|
||||
tr("TURN"), ReplayEventKind::Turn, eventPlayerId, forcedByJudge);
|
||||
break;
|
||||
}
|
||||
case GameEvent::MOVE_CARD: {
|
||||
const Event_MoveCard &moveCard = event.GetExtension(Event_MoveCard::ext);
|
||||
const QString text = describeMoveCard(moveCard, eventPlayerId, context);
|
||||
const QString startZone = QString::fromStdString(moveCard.start_zone());
|
||||
const QString targetZone = QString::fromStdString(moveCard.target_zone());
|
||||
QString badge = tr("MOVE");
|
||||
if (context.HasExtension(Context_UndoDraw::ext)) {
|
||||
badge = tr("UNDO");
|
||||
} else if (startZone == ZoneNames::STACK) {
|
||||
badge = tr("RESOLVE");
|
||||
} else if (startZone == ZoneNames::HAND && targetZone == ZoneNames::TABLE) {
|
||||
badge = tr("PLAY");
|
||||
}
|
||||
addDetail(details, text, badge, ReplayEventKind::Action, eventPlayerId, forcedByJudge);
|
||||
break;
|
||||
}
|
||||
case GameEvent::DRAW_CARDS: {
|
||||
const Event_DrawCards &drawCards = event.GetExtension(Event_DrawCards::ext);
|
||||
QStringList names;
|
||||
for (int j = 0; j < drawCards.cards_size(); ++j) {
|
||||
const ServerInfo_Card &card = drawCards.cards(j);
|
||||
const QString name = QString::fromStdString(card.name());
|
||||
names.append(name);
|
||||
rememberCard(eventPlayerId, ZoneNames::HAND, card.id(), name);
|
||||
}
|
||||
if (drawCards.number() > 0) {
|
||||
addDetail(details, tr("drew %1.").arg(formatCardList(names, drawCards.number())), tr("DRAW"),
|
||||
ReplayEventKind::Action, eventPlayerId, forcedByJudge);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case GameEvent::REVEAL_CARDS: {
|
||||
const Event_RevealCards &revealCards = event.GetExtension(Event_RevealCards::ext);
|
||||
const QString zone = QString::fromStdString(revealCards.zone_name());
|
||||
QStringList names;
|
||||
for (int j = 0; j < revealCards.cards_size(); ++j) {
|
||||
const ServerInfo_Card &card = revealCards.cards(j);
|
||||
const QString name = QString::fromStdString(card.name());
|
||||
names.append(name);
|
||||
rememberCard(eventPlayerId, zone, card.id(), name);
|
||||
}
|
||||
const int total =
|
||||
revealCards.number_of_cards() > 0 ? revealCards.number_of_cards() : revealCards.cards_size();
|
||||
QString text =
|
||||
tr("revealed %1 from %2.")
|
||||
.arg(formatCardList(names, total), zonePhrase(eventPlayerId, zone, eventPlayerId));
|
||||
if (revealCards.other_player_id() >= 0 && revealCards.other_player_id() != eventPlayerId) {
|
||||
text = tr("revealed %1 from %2 to %3.")
|
||||
.arg(formatCardList(names, total), zonePhrase(eventPlayerId, zone, eventPlayerId),
|
||||
playerName(revealCards.other_player_id()));
|
||||
}
|
||||
addDetail(details, text, tr("REVEAL"), ReplayEventKind::Action, eventPlayerId, forcedByJudge);
|
||||
break;
|
||||
}
|
||||
case GameEvent::SHUFFLE: {
|
||||
const Event_Shuffle &shuffle = event.GetExtension(Event_Shuffle::ext);
|
||||
addDetail(
|
||||
details,
|
||||
tr("shuffled %1.")
|
||||
.arg(zonePhrase(eventPlayerId, QString::fromStdString(shuffle.zone_name()), eventPlayerId)),
|
||||
tr("SHUFFLE"), ReplayEventKind::Action, eventPlayerId, forcedByJudge);
|
||||
break;
|
||||
}
|
||||
case GameEvent::ROLL_DIE: {
|
||||
const Event_RollDie &rollDie = event.GetExtension(Event_RollDie::ext);
|
||||
QStringList rolls;
|
||||
for (int j = 0; j < rollDie.values_size(); ++j) {
|
||||
rolls.append(QString::number(rollDie.values(j)));
|
||||
}
|
||||
if (rolls.isEmpty() && rollDie.value() > 0) {
|
||||
rolls.append(QString::number(rollDie.value()));
|
||||
}
|
||||
const QString sides = rollDie.sides() > 0 ? QString::number(rollDie.sides()) : QStringLiteral("?");
|
||||
addDetail(details, tr("rolled a d%1: %2.").arg(sides, rolls.join(QStringLiteral(", "))), tr("DICE"),
|
||||
ReplayEventKind::Action, eventPlayerId, forcedByJudge);
|
||||
break;
|
||||
}
|
||||
case GameEvent::FLIP_CARD: {
|
||||
const Event_FlipCard &flipCard = event.GetExtension(Event_FlipCard::ext);
|
||||
const QString zone = QString::fromStdString(flipCard.zone_name());
|
||||
if (flipCard.has_card_name()) {
|
||||
rememberCard(eventPlayerId, zone, flipCard.card_id(),
|
||||
QString::fromStdString(flipCard.card_name()));
|
||||
}
|
||||
QString name = flipCard.has_card_name() ? QString::fromStdString(flipCard.card_name())
|
||||
: cardName(eventPlayerId, zone, flipCard.card_id());
|
||||
if (name.isEmpty()) {
|
||||
name = tr("a card");
|
||||
}
|
||||
addDetail(details,
|
||||
flipCard.face_down() ? tr("flipped %1 face down.").arg(name)
|
||||
: tr("flipped %1 face up.").arg(name),
|
||||
tr("FLIP"), ReplayEventKind::Action, eventPlayerId, forcedByJudge);
|
||||
break;
|
||||
}
|
||||
case GameEvent::DESTROY_CARD: {
|
||||
const Event_DestroyCard &destroyCard = event.GetExtension(Event_DestroyCard::ext);
|
||||
const QString zone = QString::fromStdString(destroyCard.zone_name());
|
||||
const QString name = cardName(eventPlayerId, zone, destroyCard.card_id());
|
||||
forgetCard(eventPlayerId, zone, destroyCard.card_id());
|
||||
addDetail(details, tr("destroyed %1.").arg(name.isEmpty() ? tr("a card") : name), tr("DESTROY"),
|
||||
ReplayEventKind::Action, eventPlayerId, forcedByJudge);
|
||||
break;
|
||||
}
|
||||
case GameEvent::ATTACH_CARD: {
|
||||
const Event_AttachCard &attachCard = event.GetExtension(Event_AttachCard::ext);
|
||||
const QString startZone = QString::fromStdString(attachCard.start_zone());
|
||||
const QString source = cardName(eventPlayerId, startZone, attachCard.card_id());
|
||||
const QString sourceText = source.isEmpty() ? tr("a card") : source;
|
||||
// An unattach event carries no target at all, so the target fields default to player 0 / no zone
|
||||
// / card 0; only a present target_player_id means the card was attached to something.
|
||||
if (!attachCard.has_target_player_id()) {
|
||||
addDetail(details, tr("unattached %1.").arg(sourceText), tr("ATTACH"), ReplayEventKind::Action,
|
||||
eventPlayerId, forcedByJudge);
|
||||
break;
|
||||
}
|
||||
const QString targetZone = QString::fromStdString(attachCard.target_zone());
|
||||
const int targetPlayerId =
|
||||
attachCard.target_player_id() >= 0 ? attachCard.target_player_id() : eventPlayerId;
|
||||
const QString target = cardName(targetPlayerId, targetZone, attachCard.target_card_id());
|
||||
addDetail(details,
|
||||
tr("attached %1 to %2.").arg(sourceText, target.isEmpty() ? tr("a card") : target),
|
||||
tr("ATTACH"), ReplayEventKind::Action, eventPlayerId, forcedByJudge);
|
||||
break;
|
||||
}
|
||||
case GameEvent::CREATE_TOKEN: {
|
||||
const Event_CreateToken &createToken = event.GetExtension(Event_CreateToken::ext);
|
||||
const QString name = QString::fromStdString(createToken.card_name());
|
||||
rememberCard(eventPlayerId, QString::fromStdString(createToken.zone_name()), createToken.card_id(),
|
||||
name);
|
||||
const QString pt = QString::fromStdString(createToken.pt());
|
||||
addDetail(
|
||||
details,
|
||||
tr("created a token: %1%2.")
|
||||
.arg(name.isEmpty() ? tr("token") : name, pt.isEmpty() ? QString() : tr(" (%1)").arg(pt)),
|
||||
tr("TOKEN"), ReplayEventKind::Action, eventPlayerId, forcedByJudge);
|
||||
break;
|
||||
}
|
||||
case GameEvent::SET_CARD_ATTR: {
|
||||
const Event_SetCardAttr &setCardAttr = event.GetExtension(Event_SetCardAttr::ext);
|
||||
addDetail(details, describeSetCardAttribute(setCardAttr, eventPlayerId),
|
||||
badgeForCardAttribute(setCardAttr.attribute()), ReplayEventKind::Action, eventPlayerId,
|
||||
forcedByJudge);
|
||||
break;
|
||||
}
|
||||
case GameEvent::SET_CARD_COUNTER: {
|
||||
const Event_SetCardCounter &setCardCounter = event.GetExtension(Event_SetCardCounter::ext);
|
||||
const QString name = cardName(eventPlayerId, QString::fromStdString(setCardCounter.zone_name()),
|
||||
setCardCounter.card_id());
|
||||
addDetail(
|
||||
details,
|
||||
tr("set a counter on %1 to %2.")
|
||||
.arg(name.isEmpty() ? tr("a card") : name, QString::number(setCardCounter.counter_value())),
|
||||
tr("COUNTER"), ReplayEventKind::Action, eventPlayerId, forcedByJudge);
|
||||
break;
|
||||
}
|
||||
case GameEvent::CREATE_COUNTER: {
|
||||
const Event_CreateCounter &createCounter = event.GetExtension(Event_CreateCounter::ext);
|
||||
const ServerInfo_Counter &counter = createCounter.counter_info();
|
||||
rememberCounter(eventPlayerId, counter);
|
||||
const QString name = QString::fromStdString(counter.name());
|
||||
addDetail(details,
|
||||
tr("created the %1 counter (%2).")
|
||||
.arg(name.isEmpty() ? tr("new") : name, QString::number(counter.count())),
|
||||
tr("COUNTER"), ReplayEventKind::Action, eventPlayerId, forcedByJudge);
|
||||
break;
|
||||
}
|
||||
case GameEvent::SET_COUNTER: {
|
||||
const Event_SetCounter &setCounter = event.GetExtension(Event_SetCounter::ext);
|
||||
const QString name = counterName(eventPlayerId, setCounter.counter_id());
|
||||
addDetail(details,
|
||||
tr("set %1 to %2.")
|
||||
.arg(name.isEmpty() ? tr("a counter") : tr("the %1 counter").arg(name),
|
||||
QString::number(setCounter.value())),
|
||||
tr("COUNTER"), ReplayEventKind::Action, eventPlayerId, forcedByJudge);
|
||||
break;
|
||||
}
|
||||
case GameEvent::DEL_COUNTER: {
|
||||
const Event_DelCounter &delCounter = event.GetExtension(Event_DelCounter::ext);
|
||||
const QString name = counterName(eventPlayerId, delCounter.counter_id());
|
||||
counterNames.remove(counterKey(eventPlayerId, delCounter.counter_id()));
|
||||
addDetail(details,
|
||||
tr("removed %1.").arg(name.isEmpty() ? tr("a counter") : tr("the %1 counter").arg(name)),
|
||||
tr("COUNTER"), ReplayEventKind::Action, eventPlayerId, forcedByJudge);
|
||||
break;
|
||||
}
|
||||
case GameEvent::DUMP_ZONE: {
|
||||
const Event_DumpZone &dumpZone = event.GetExtension(Event_DumpZone::ext);
|
||||
const QString zoneOwnerPhrase = zonePhrase(
|
||||
dumpZone.zone_owner_id(), QString::fromStdString(dumpZone.zone_name()), eventPlayerId);
|
||||
if (dumpZone.number_cards() < 0) {
|
||||
addDetail(details, tr("looked at %1.").arg(zoneOwnerPhrase), tr("LOOK"),
|
||||
ReplayEventKind::Action, eventPlayerId, forcedByJudge);
|
||||
} else {
|
||||
addDetail(details,
|
||||
tr("looked at %1 from %2.")
|
||||
.arg(formatCardList(QStringList(), dumpZone.number_cards()), zoneOwnerPhrase),
|
||||
tr("LOOK"), ReplayEventKind::Action, eventPlayerId, forcedByJudge);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case GameEvent::CHANGE_ZONE_PROPERTIES: {
|
||||
const Event_ChangeZoneProperties &changeZone = event.GetExtension(Event_ChangeZoneProperties::ext);
|
||||
const QString zone = QString::fromStdString(changeZone.zone_name());
|
||||
QStringList changes;
|
||||
if (changeZone.always_reveal_top_card()) {
|
||||
changes.append(tr("top card revealed to all"));
|
||||
}
|
||||
if (changeZone.always_look_at_top_card()) {
|
||||
changes.append(tr("top card visible to its owner"));
|
||||
}
|
||||
addDetail(
|
||||
details,
|
||||
changes.isEmpty()
|
||||
? tr("changed the properties of %1.").arg(zonePhrase(eventPlayerId, zone, eventPlayerId))
|
||||
: tr("changed %1: %2.")
|
||||
.arg(zonePhrase(eventPlayerId, zone, eventPlayerId),
|
||||
changes.join(QStringLiteral(", "))),
|
||||
tr("ZONE"), ReplayEventKind::Action, eventPlayerId, forcedByJudge);
|
||||
break;
|
||||
}
|
||||
case GameEvent::GAME_LOG_NOTICE: {
|
||||
const Event_GameLogNotice ¬ice = event.GetExtension(Event_GameLogNotice::ext);
|
||||
switch (notice.notice_type()) {
|
||||
case Event_GameLogNotice::UNDO_DRAW_FAILED:
|
||||
addDetail(details, tr("failed to undo a draw."), tr("NOTICE"), ReplayEventKind::System,
|
||||
eventPlayerId, forcedByJudge);
|
||||
break;
|
||||
default:
|
||||
addDetail(details, tr("A game notice was logged."), tr("NOTICE"), ReplayEventKind::System,
|
||||
-1, forcedByJudge);
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case GameEvent::CREATE_ARROW:
|
||||
case GameEvent::DELETE_ARROW:
|
||||
// Arrows are transient visual annotations and would make the history very noisy.
|
||||
break;
|
||||
default:
|
||||
addDetail(details, tr("Unrecognized game event."), tr("EVENT"), ReplayEventKind::Action,
|
||||
eventPlayerId, forcedByJudge);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return details;
|
||||
}
|
||||
};
|
||||
} // namespace
|
||||
|
||||
QList<ReplayEventEntry> buildReplayEventEntries(const GameReplay *replay, const QList<int> &timeline)
|
||||
{
|
||||
if (!replay) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return ReplayEventIndexBuilder(replay, timeline).build();
|
||||
}
|
||||
43
cockatrice/src/interface/widgets/replay/replay_event_index.h
Normal file
43
cockatrice/src/interface/widgets/replay/replay_event_index.h
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
#ifndef COCKATRICE_REPLAY_EVENT_INDEX_H
|
||||
#define COCKATRICE_REPLAY_EVENT_INDEX_H
|
||||
|
||||
#include <QList>
|
||||
#include <QString>
|
||||
|
||||
class GameReplay;
|
||||
|
||||
enum class ReplayEventKind
|
||||
{
|
||||
Action,
|
||||
Turn,
|
||||
Phase,
|
||||
Chat,
|
||||
System
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief One user-visible entry in the replay history sidebar.
|
||||
*
|
||||
* The event index refers to the corresponding GameEventContainer in the replay. It is used to seek the replay to that
|
||||
* container.
|
||||
*/
|
||||
struct ReplayEventEntry
|
||||
{
|
||||
int eventIndex = -1;
|
||||
int timeMs = 0;
|
||||
QString timeLabel;
|
||||
QString actor;
|
||||
QString badge;
|
||||
QString detail;
|
||||
ReplayEventKind kind = ReplayEventKind::Action;
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Builds a compact, human-readable index of the events in a replay.
|
||||
*
|
||||
* The returned entries copy all the data they need, so the caller does not need to keep the replay object alive after
|
||||
* this function returns.
|
||||
*/
|
||||
QList<ReplayEventEntry> buildReplayEventEntries(const GameReplay *replay, const QList<int> &timeline);
|
||||
|
||||
#endif // COCKATRICE_REPLAY_EVENT_INDEX_H
|
||||
|
|
@ -57,7 +57,7 @@ ReplayManager::ReplayManager(QObject *parent, GameReplay *replay)
|
|||
|
||||
rewindBufferingTimer = new QTimer(this);
|
||||
rewindBufferingTimer->setSingleShot(true);
|
||||
connect(rewindBufferingTimer, &QTimer::timeout, this, &ReplayManager::processRewind);
|
||||
connect(rewindBufferingTimer, &QTimer::timeout, this, [this] { processRewind(); });
|
||||
}
|
||||
|
||||
ReplayManager::~ReplayManager()
|
||||
|
|
@ -65,7 +65,7 @@ ReplayManager::~ReplayManager()
|
|||
delete replay;
|
||||
}
|
||||
|
||||
void ReplayManager::skipToTime(int newTime, bool doRewindBuffering)
|
||||
void ReplayManager::skipToTime(int newTime, bool doRewindBuffering, bool includeEventsAtTime)
|
||||
{
|
||||
// check boundary conditions
|
||||
if (newTime < 0) {
|
||||
|
|
@ -75,15 +75,17 @@ void ReplayManager::skipToTime(int newTime, bool doRewindBuffering)
|
|||
newTime = maxTime;
|
||||
}
|
||||
|
||||
newTime -= newTime % TIMER_INTERVAL_MS; // Time should always be a multiple of the interval
|
||||
if (!includeEventsAtTime) {
|
||||
newTime -= newTime % TIMER_INTERVAL_MS; // Time should always be a multiple of the interval
|
||||
}
|
||||
|
||||
const bool isBackwardsSkip = newTime < currentProcessedTime;
|
||||
currentVisualTime = newTime;
|
||||
|
||||
if (isBackwardsSkip) {
|
||||
handleBackwardsSkip(doRewindBuffering);
|
||||
handleBackwardsSkip(doRewindBuffering, includeEventsAtTime);
|
||||
} else {
|
||||
processNewEvents(FORWARD_SKIP);
|
||||
processNewEvents(FORWARD_SKIP, includeEventsAtTime);
|
||||
}
|
||||
|
||||
timeChanged(currentVisualTime);
|
||||
|
|
@ -95,7 +97,7 @@ void ReplayManager::skipToTime(int newTime, bool doRewindBuffering)
|
|||
* @param doRewindBuffering When true, if multiple backward skips are made in quick succession, only a single rewind
|
||||
* is processed at the end. When false, the backwards skip will always cause an immediate rewind.
|
||||
*/
|
||||
void ReplayManager::handleBackwardsSkip(bool doRewindBuffering)
|
||||
void ReplayManager::handleBackwardsSkip(bool doRewindBuffering, bool includeEventsAtTime)
|
||||
{
|
||||
if (doRewindBuffering) {
|
||||
// We use a one-shot timer to implement the rewind buffering.
|
||||
|
|
@ -105,11 +107,11 @@ void ReplayManager::handleBackwardsSkip(bool doRewindBuffering)
|
|||
rewindBufferingTimer->start(SettingsCache::instance().userInterface().getRewindBufferingMs());
|
||||
} else {
|
||||
// otherwise, process the rewind immediately
|
||||
processRewind();
|
||||
processRewind(includeEventsAtTime);
|
||||
}
|
||||
}
|
||||
|
||||
void ReplayManager::processRewind()
|
||||
void ReplayManager::processRewind(bool includeEventsAtTime)
|
||||
{
|
||||
// stop any queued-up rewinds
|
||||
rewindBufferingTimer->stop();
|
||||
|
|
@ -117,7 +119,7 @@ void ReplayManager::processRewind()
|
|||
// process the rewind
|
||||
currentEvent = 0;
|
||||
emit rewound();
|
||||
processNewEvents(BACKWARD_SKIP);
|
||||
processNewEvents(BACKWARD_SKIP, includeEventsAtTime);
|
||||
}
|
||||
|
||||
void ReplayManager::replayTimerTimeout()
|
||||
|
|
@ -134,11 +136,13 @@ void ReplayManager::replayTimerTimeout()
|
|||
}
|
||||
|
||||
/** @brief Processes all unprocessed events up to the current time. */
|
||||
void ReplayManager::processNewEvents(PlaybackMode playbackMode)
|
||||
void ReplayManager::processNewEvents(PlaybackMode playbackMode, bool includeEventsAtTime)
|
||||
{
|
||||
currentProcessedTime = currentVisualTime;
|
||||
|
||||
while (currentEvent < replayTimeline.size() && replayTimeline[currentEvent] < currentProcessedTime) {
|
||||
while (currentEvent < replayTimeline.size() &&
|
||||
(replayTimeline[currentEvent] < currentProcessedTime ||
|
||||
(includeEventsAtTime && replayTimeline[currentEvent] == currentProcessedTime))) {
|
||||
EventProcessingOptions options;
|
||||
|
||||
// backwards skip => always skip reveal windows
|
||||
|
|
@ -236,6 +240,24 @@ void ReplayManager::setTime(int time)
|
|||
skipToTime(time, false);
|
||||
}
|
||||
|
||||
void ReplayManager::setTimeToEvent(int eventIndex)
|
||||
{
|
||||
if (eventIndex < 0 || eventIndex >= replayTimeline.size()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Flush a pending buffered rewind before seeking, otherwise its timer fires after this seek and rebuilds the
|
||||
// replay state from an outdated currentVisualTime, dropping the event we seek to. Stopping the timer instead
|
||||
// of processing it is not enough: playback ticks advance currentProcessedTime without rewinding currentEvent,
|
||||
// so the board could still be ahead of the target.
|
||||
if (rewindBufferingTimer->isActive()) {
|
||||
processRewind();
|
||||
}
|
||||
|
||||
// Unlike a timeline seek, jumping to an event should apply that event immediately.
|
||||
skipToTime(replayTimeline.at(eventIndex), false, true);
|
||||
}
|
||||
|
||||
void ReplayManager::skipByAmount(int amount)
|
||||
{
|
||||
skipToTime(currentVisualTime + amount, amount < 0);
|
||||
|
|
|
|||
|
|
@ -40,10 +40,10 @@ class ReplayManager : public QObject
|
|||
///< to rewind buffering
|
||||
int currentEvent = 0; ///< current event's index
|
||||
|
||||
void skipToTime(int newTime, bool doRewindBuffering);
|
||||
void handleBackwardsSkip(bool doRewindBuffering);
|
||||
void processRewind();
|
||||
void processNewEvents(PlaybackMode playbackMode);
|
||||
void skipToTime(int newTime, bool doRewindBuffering, bool includeEventsAtTime = false);
|
||||
void handleBackwardsSkip(bool doRewindBuffering, bool includeEventsAtTime = false);
|
||||
void processRewind(bool includeEventsAtTime = false);
|
||||
void processNewEvents(PlaybackMode playbackMode, bool includeEventsAtTime = false);
|
||||
void handleSkipEmptySection();
|
||||
|
||||
private slots:
|
||||
|
|
@ -73,6 +73,7 @@ public slots:
|
|||
void startReplay();
|
||||
void stopReplay();
|
||||
void setTime(int time);
|
||||
void setTimeToEvent(int eventIndex);
|
||||
void skipByAmount(int amount); // use a negative amount to skip backwards
|
||||
|
||||
signals:
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ ReplayWidget::ReplayWidget(QWidget *parent, GameReplay *replay)
|
|||
connect(replayManager, &ReplayManager::eventReplayed, this, &ReplayWidget::eventReplayed);
|
||||
connect(replayManager, &ReplayManager::replayFinished, this, &ReplayWidget::replayFinished);
|
||||
connect(replayManager, &ReplayManager::rewound, this, &ReplayWidget::rewound);
|
||||
connect(replayManager, &ReplayManager::timeChanged, this, &ReplayWidget::timeChanged);
|
||||
|
||||
// timeline widget
|
||||
timelineWidget = new ReplayTimelineWidget;
|
||||
|
|
@ -96,6 +97,16 @@ ReplayWidget::ReplayWidget(QWidget *parent, GameReplay *replay)
|
|||
refreshShortcuts();
|
||||
}
|
||||
|
||||
const QList<int> &ReplayWidget::getReplayTimeline() const
|
||||
{
|
||||
return replayManager->getReplayTimeline();
|
||||
}
|
||||
|
||||
void ReplayWidget::seekToEvent(int eventIndex)
|
||||
{
|
||||
replayManager->setTimeToEvent(eventIndex);
|
||||
}
|
||||
|
||||
void ReplayWidget::replayFinished()
|
||||
{
|
||||
replayPlayButton->setChecked(false);
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
#ifndef REPLAY_WIDGET_H
|
||||
#define REPLAY_WIDGET_H
|
||||
|
||||
#include <QList>
|
||||
#include <QWidget>
|
||||
#include <libcockatrice/game/player/event_processing_options.h>
|
||||
#include <qtmetamacros.h>
|
||||
|
|
@ -35,8 +36,14 @@ public:
|
|||
*/
|
||||
ReplayWidget(QWidget *parent, GameReplay *replay);
|
||||
|
||||
[[nodiscard]] const QList<int> &getReplayTimeline() const;
|
||||
|
||||
public slots:
|
||||
void seekToEvent(int eventIndex);
|
||||
|
||||
signals:
|
||||
void rewound();
|
||||
void timeChanged(int time);
|
||||
void eventReplayed(const GameEventContainer &cont, EventProcessingOptions options);
|
||||
|
||||
private:
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -5,40 +5,29 @@
|
|||
#include "../../../filters/syntax_help.h"
|
||||
#include "../../key_signals.h"
|
||||
#include "../../pixel_map_generator.h"
|
||||
#include "../cards/card_info_picture_widget.h"
|
||||
#include "../cards/card_info_picture_with_text_overlay_widget.h"
|
||||
#include "../cards/card_size_widget.h"
|
||||
#include "../deck_editor/card_database_view.h"
|
||||
#include "../general/layout_containers/flow_widget.h"
|
||||
#include "../utility/custom_line_edit.h"
|
||||
#include "card_database_display_model.h"
|
||||
#include "card_database_model.h"
|
||||
#include "card_database_visual_display_model.h"
|
||||
#include "card_database_visual_view.h"
|
||||
#include "deck_list_model.h"
|
||||
#include "libcockatrice/card/card_localization.h"
|
||||
#include "libcockatrice/card/database/card_database_loader.h"
|
||||
#include "libcockatrice/card/database/card_database_querier.h"
|
||||
#include "libcockatrice/card/printing/exact_card.h"
|
||||
#include "libcockatrice/card/printing/printing_info.h"
|
||||
#include "libcockatrice/filters/filter_card.h"
|
||||
#include "libcockatrice/filters/filter_tree.h"
|
||||
#include "visual_database_display_color_filter_widget.h"
|
||||
#include "visual_database_display_filter_toolbar_widget.h"
|
||||
|
||||
#include <QAbstractItemModel>
|
||||
#include <QAction>
|
||||
#include <QDebug>
|
||||
#include <QIcon>
|
||||
#include <QLabel>
|
||||
#include <QLineEdit>
|
||||
#include <QList>
|
||||
#include <QMessageLogger>
|
||||
#include <QModelIndex>
|
||||
#include <QPair>
|
||||
#include <QPersistentModelIndex>
|
||||
#include <QPushButton>
|
||||
#include <QScrollArea>
|
||||
#include <QScrollBar>
|
||||
#include <QSharedPointer>
|
||||
#include <QSizePolicy>
|
||||
#include <QSlider>
|
||||
#include <QStringLiteral>
|
||||
|
|
@ -48,14 +37,11 @@
|
|||
#include <QVBoxLayout>
|
||||
#include <QVariant>
|
||||
#include <QtGlobal>
|
||||
#include <algorithm>
|
||||
#include <libcockatrice/card/card_info_comparator.h>
|
||||
#include <libcockatrice/card/database/card_database.h>
|
||||
#include <libcockatrice/card/database/card_database_manager.h>
|
||||
#include <libcockatrice/deck_list/tree/inner_deck_list_node.h>
|
||||
#include <libcockatrice/settings/cards_display_settings.h>
|
||||
#include <libcockatrice/utility/qt_utils.h>
|
||||
#include <utility>
|
||||
|
||||
class QMouseEvent;
|
||||
|
||||
|
|
@ -64,11 +50,6 @@ VisualDatabaseDisplayWidget::VisualDatabaseDisplayWidget(QWidget *parent,
|
|||
DeckListModel *deckListModel)
|
||||
: QWidget(parent)
|
||||
{
|
||||
debounceTimer = new QTimer(this);
|
||||
debounceTimer->setSingleShot(true); // Ensure it only fires once after the timeout
|
||||
|
||||
connect(debounceTimer, &QTimer::timeout, this, &VisualDatabaseDisplayWidget::onSearchModelChanged);
|
||||
|
||||
// Create display model
|
||||
databaseDisplayModel = new CardDatabaseDisplayModel(this);
|
||||
databaseDisplayModel->setObjectName("databaseDisplayModel");
|
||||
|
|
@ -86,23 +67,12 @@ VisualDatabaseDisplayWidget::VisualDatabaseDisplayWidget(QWidget *parent,
|
|||
connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardSearchLanguageChanged, this,
|
||||
applyCardSearchLanguage);
|
||||
|
||||
cards = new QList<ExactCard>;
|
||||
connect(databaseDisplayModel, &CardDatabaseDisplayModel::modelDirty, this,
|
||||
&VisualDatabaseDisplayWidget::modelDirty);
|
||||
|
||||
// Set up main layout and widgets
|
||||
setMinimumSize(0, 0);
|
||||
setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
|
||||
mainLayout = QtUtils::makeLayout<QVBoxLayout>(this, 0, 1);
|
||||
setLayout(mainLayout);
|
||||
|
||||
flowWidget = new FlowWidget(this, Qt::Horizontal, Qt::ScrollBarAlwaysOff, Qt::ScrollBarPolicy::ScrollBarAsNeeded);
|
||||
cardSizeWidget = new CardSizeWidget(this, flowWidget,
|
||||
SettingsCache::instance().cardsDisplay().getVisualDatabaseDisplayCardSize());
|
||||
connect(cardSizeWidget, &CardSizeWidget::cardSizeSettingUpdated, &SettingsCache::instance().cardsDisplay(),
|
||||
&CardsDisplaySettings::setVisualDatabaseDisplayCardSize);
|
||||
cardSizeWidget->enableCtrlScrollResize(flowWidget);
|
||||
|
||||
searchContainer = new FlowWidget(this, Qt::Horizontal, Qt::ScrollBarAlwaysOff, Qt::ScrollBarAlwaysOff);
|
||||
|
||||
searchEdit = new SearchLineEdit();
|
||||
|
|
@ -159,6 +129,25 @@ VisualDatabaseDisplayWidget::VisualDatabaseDisplayWidget(QWidget *parent,
|
|||
connect(databaseView, &CardDatabaseView::relatedCardClicked, this,
|
||||
&VisualDatabaseDisplayWidget::onRelatedCardClicked);
|
||||
|
||||
// The visual grid is a real view on a flattened model: one row per displayed
|
||||
// card picture, painted on demand.
|
||||
visualDisplayModel = new CardDatabaseVisualDisplayModel(databaseDisplayModel, CardDatabaseManager::query(), this);
|
||||
|
||||
cardView = new CardDatabaseVisualView(visualDisplayModel, this);
|
||||
cardView->setObjectName("visualCardView");
|
||||
connect(cardView, &CardDatabaseVisualView::cardClicked, this,
|
||||
&VisualDatabaseDisplayWidget::cardClickedDatabaseDisplay);
|
||||
connect(cardView, &CardDatabaseVisualView::cardHovered, this,
|
||||
&VisualDatabaseDisplayWidget::cardHoveredDatabaseDisplay);
|
||||
|
||||
const int cardSize = SettingsCache::instance().cardsDisplay().getVisualDatabaseDisplayCardSize();
|
||||
cardSizeWidget = new CardSizeWidget(this, nullptr, cardSize);
|
||||
connect(cardSizeWidget, &CardSizeWidget::cardSizeSettingUpdated, &SettingsCache::instance().cardsDisplay(),
|
||||
&CardsDisplaySettings::setVisualDatabaseDisplayCardSize);
|
||||
connect(cardSizeWidget->getSlider(), &QSlider::valueChanged, cardView, &CardDatabaseVisualView::setCardSizeFactor);
|
||||
cardView->setCardSizeFactor(cardSize);
|
||||
cardSizeWidget->enableCtrlScrollResize(cardView);
|
||||
|
||||
colorFilterWidget = new VisualDatabaseDisplayColorFilterWidget(this, filterModel);
|
||||
|
||||
filterContainer = new VisualDatabaseDisplayFilterToolbarWidget(this, deckListModel);
|
||||
|
|
@ -190,8 +179,6 @@ VisualDatabaseDisplayWidget::VisualDatabaseDisplayWidget(QWidget *parent,
|
|||
databaseLoadIndicator->setVisible(false);
|
||||
}
|
||||
|
||||
QScrollBar *scrollBar = flowWidget->scrollArea->verticalScrollBar();
|
||||
connect(scrollBar, &QScrollBar::valueChanged, [this](int /*value*/) { loadCurrentPage(); });
|
||||
retranslateUi();
|
||||
}
|
||||
|
||||
|
|
@ -210,7 +197,7 @@ void VisualDatabaseDisplayWidget::initialize()
|
|||
|
||||
mainLayout->addWidget(databaseView);
|
||||
|
||||
mainLayout->addWidget(flowWidget);
|
||||
mainLayout->addWidget(cardView);
|
||||
|
||||
mainLayout->addWidget(cardSizeWidget);
|
||||
|
||||
|
|
@ -241,8 +228,6 @@ void VisualDatabaseDisplayWidget::initializeFilters()
|
|||
|
||||
databaseDisplayModel->setFilterTree(filterModel->filterTree());
|
||||
|
||||
QTimer::singleShot(5000, this, [this] { loadCurrentPage(); });
|
||||
|
||||
retranslateUi();
|
||||
});
|
||||
}
|
||||
|
|
@ -278,71 +263,36 @@ void VisualDatabaseDisplayWidget::updateDisplayModeButton()
|
|||
displayModeButton->setText(displayModeButton->isChecked() ? tr("Table") : tr("Visual"));
|
||||
}
|
||||
|
||||
void VisualDatabaseDisplayWidget::resizeEvent(QResizeEvent *event)
|
||||
{
|
||||
QWidget::resizeEvent(event);
|
||||
loadCurrentPage();
|
||||
}
|
||||
|
||||
void VisualDatabaseDisplayWidget::onDisplayModeChanged(bool checked)
|
||||
{
|
||||
updateDisplayModeButton();
|
||||
if (checked) {
|
||||
// Table mode
|
||||
flowWidget->setVisible(false);
|
||||
cardView->setVisible(false);
|
||||
cardSizeWidget->setVisible(false);
|
||||
databaseView->setItemDelegate(new QStyledItemDelegate(databaseView));
|
||||
databaseView->setVisible(true);
|
||||
} else {
|
||||
// Visual mode
|
||||
flowWidget->setVisible(true);
|
||||
cardView->setVisible(true);
|
||||
cardSizeWidget->setVisible(true);
|
||||
databaseView->setVisible(false);
|
||||
populateCards();
|
||||
}
|
||||
}
|
||||
|
||||
void VisualDatabaseDisplayWidget::onClick(QMouseEvent *event, const ExactCard &card)
|
||||
{
|
||||
emit cardClickedDatabaseDisplay(event, card);
|
||||
}
|
||||
|
||||
void VisualDatabaseDisplayWidget::onHover(const ExactCard &hoveredCard)
|
||||
{
|
||||
emit cardHoveredDatabaseDisplay(hoveredCard);
|
||||
}
|
||||
|
||||
void VisualDatabaseDisplayWidget::addCardToDisplay(const ExactCard &cardToAdd)
|
||||
{
|
||||
cards->append(cardToAdd);
|
||||
auto *display = new CardInfoPictureWithTextOverlayWidget(flowWidget, false);
|
||||
display->setScaleFactor(cardSizeWidget->getSlider()->value());
|
||||
display->setCard(cardToAdd);
|
||||
flowWidget->addWidget(display);
|
||||
connect(display, &CardInfoPictureWithTextOverlayWidget::cardClicked, this, &VisualDatabaseDisplayWidget::onClick);
|
||||
connect(display, &CardInfoPictureWithTextOverlayWidget::hoveredOnCard, this, &VisualDatabaseDisplayWidget::onHover);
|
||||
connect(cardSizeWidget->getSlider(), &QSlider::valueChanged, display, &CardInfoPictureWidget::setScaleFactor);
|
||||
}
|
||||
|
||||
bool VisualDatabaseDisplayWidget::isVisualDisplayMode() const
|
||||
{
|
||||
return !displayModeButton->isChecked();
|
||||
}
|
||||
|
||||
QStringList VisualDatabaseDisplayWidget::collectSetFilterTerms() const
|
||||
{
|
||||
return filterModel->getFilterTermsOfType(CardFilter::AttrSet);
|
||||
}
|
||||
|
||||
void VisualDatabaseDisplayWidget::onSearchModelChanged()
|
||||
{
|
||||
if (flowWidget->isVisible()) {
|
||||
// Clear the current page and prepare for new data
|
||||
flowWidget->clearLayout(); // Clear existing cards
|
||||
cards->clear(); // Clear the card list
|
||||
// Reset scrollbar position to the top after loading new cards
|
||||
QScrollBar *scrollBar = flowWidget->scrollArea->verticalScrollBar();
|
||||
scrollBar->setValue(0); // Reset scrollbar to top
|
||||
|
||||
currentPage = 0;
|
||||
loadCurrentPage();
|
||||
qCDebug(VisualDatabaseDisplayLog) << "Search model changed";
|
||||
}
|
||||
visualDisplayModel->setSetFilterTerms(collectSetFilterTerms());
|
||||
}
|
||||
|
||||
void VisualDatabaseDisplayWidget::onSelectedCardChanged(const QString &cardName)
|
||||
|
|
@ -368,113 +318,3 @@ void VisualDatabaseDisplayWidget::onRelatedCardClicked(const QString &relatedCar
|
|||
ExactCard exactCard = CardDatabaseManager::query()->guessCard({relatedCard});
|
||||
emit cardInfoRequested(exactCard);
|
||||
}
|
||||
|
||||
bool VisualDatabaseDisplayWidget::nearEndOfPage() const
|
||||
{
|
||||
if (!flowWidget->isVisible()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
QScrollBar *scrollBar = flowWidget->scrollArea->verticalScrollBar();
|
||||
if (scrollBar->value() + scrollBar->pageStep() * 2 < scrollBar->maximum()) {
|
||||
return false; // there is at least two pages of space to scroll remaining
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
void VisualDatabaseDisplayWidget::loadCurrentPage()
|
||||
{
|
||||
// Ensure only the initial page is loaded
|
||||
if (currentPage == 0) {
|
||||
if (!initialLoadScheduled) {
|
||||
initialLoadScheduled = true;
|
||||
qCDebug(VisualDatabaseDisplayLog) << "Loading the first page";
|
||||
// Defer the first page so the tab switch stays responsive. The card
|
||||
// grid builds one event loop turn later. This also applies to
|
||||
// search-driven reloads, which reset currentPage back to 0.
|
||||
QTimer::singleShot(0, this, [this] {
|
||||
initialLoadScheduled = false;
|
||||
populateCards();
|
||||
});
|
||||
}
|
||||
} else if (nearEndOfPage()) {
|
||||
// If not the first page, just load the next page and append to the flow widget
|
||||
loadNextPage();
|
||||
}
|
||||
}
|
||||
|
||||
void VisualDatabaseDisplayWidget::populateCards()
|
||||
{
|
||||
cards->clear();
|
||||
loadNextPage();
|
||||
}
|
||||
|
||||
void VisualDatabaseDisplayWidget::loadNextPage()
|
||||
{
|
||||
// Calculate the start and end indices for the next page
|
||||
int rowCount = databaseDisplayModel->rowCount();
|
||||
int start = currentPage * cardsPerPage;
|
||||
int end = qMin(start + cardsPerPage, rowCount);
|
||||
|
||||
// Load more cards if we are at the end of the current list and can fetch more
|
||||
if (end >= rowCount && databaseDisplayModel->canFetchMore(QModelIndex())) {
|
||||
databaseDisplayModel->fetchMore(QModelIndex());
|
||||
}
|
||||
|
||||
// Load the next page of cards and add them to the flow widget
|
||||
flowWidget->setUpdatesEnabled(false);
|
||||
loadPage(start, end);
|
||||
flowWidget->setUpdatesEnabled(true);
|
||||
}
|
||||
|
||||
void VisualDatabaseDisplayWidget::loadPage(int start, int end)
|
||||
{
|
||||
QList<const CardFilter *> setFilters = filterModel->getFiltersOfType(CardFilter::AttrSet);
|
||||
for (int row = start; row < end; ++row) {
|
||||
qCDebug(VisualDatabaseDisplayLog) << "Adding " << row;
|
||||
QModelIndex index = databaseDisplayModel->index(row, CardDatabaseModel::NameColumn);
|
||||
QVariant name = databaseDisplayModel->data(index, Qt::DisplayRole);
|
||||
qCDebug(VisualDatabaseDisplayLog) << name.toString();
|
||||
|
||||
if (CardInfoPtr info = CardDatabaseManager::query()->getCardInfo(name.toString())) {
|
||||
if (!setFilters.empty()) {
|
||||
const auto &setMap = info->getSets();
|
||||
for (const CardFilter *setFilter : setFilters) {
|
||||
const auto it = setMap.constFind(setFilter->term());
|
||||
if (it != setMap.constEnd()) {
|
||||
for (const PrintingInfo &printing : it.value()) {
|
||||
addCardToDisplay(ExactCard(info, printing));
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
addCardToDisplay(CardDatabaseManager::query()->getPreferredCard(info));
|
||||
}
|
||||
} else {
|
||||
qCDebug(VisualDatabaseDisplayLog) << "Card not found in database!";
|
||||
}
|
||||
}
|
||||
currentPage++;
|
||||
}
|
||||
|
||||
void VisualDatabaseDisplayWidget::modelDirty() const
|
||||
{
|
||||
debounceTimer->start(debounceTime);
|
||||
}
|
||||
|
||||
void VisualDatabaseDisplayWidget::sortCardList(const QStringList &properties,
|
||||
Qt::SortOrder order = Qt::AscendingOrder) const
|
||||
{
|
||||
CardInfoComparator comparator(properties, order);
|
||||
std::sort(cards->begin(), cards->end(), [comparator](const ExactCard &a, const ExactCard &b) {
|
||||
return comparator(a.getCardPtr(), b.getCardPtr());
|
||||
});
|
||||
}
|
||||
|
||||
void VisualDatabaseDisplayWidget::databaseDataChanged(const QModelIndex &topLeft, const QModelIndex &bottomRight)
|
||||
{
|
||||
(void)topLeft;
|
||||
(void)bottomRight;
|
||||
qCDebug(VisualDatabaseDisplayLog) << "Database Data changed";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ private:
|
|||
enum Attr a;
|
||||
|
||||
public:
|
||||
CardFilter(QString &term, Type type, Attr attr) : trm(term), t(type), a(attr)
|
||||
CardFilter(const QString &term, Type type, Attr attr) : trm(term), t(type), a(attr)
|
||||
{
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 {};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,7 +1,3 @@
|
|||
set(CMAKE_AUTOMOC ON)
|
||||
set(CMAKE_AUTOUIC ON)
|
||||
set(CMAKE_AUTORCC ON)
|
||||
|
||||
add_subdirectory(local)
|
||||
add_subdirectory(remote)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -35,6 +35,21 @@ if(WITH_ORACLE OR WITH_CLIENT)
|
|||
LIBS
|
||||
libcockatrice_filters
|
||||
)
|
||||
|
||||
# ------------------------
|
||||
# Visual Display Model Test
|
||||
# ------------------------
|
||||
cockatrice_add_test(
|
||||
NAME
|
||||
visual_display_model_test
|
||||
SOURCES
|
||||
${VERSION_STRING_CPP}
|
||||
visual_display_model_test.cpp
|
||||
mocks.cpp
|
||||
LIBS
|
||||
libcockatrice_card
|
||||
libcockatrice_models
|
||||
)
|
||||
endif()
|
||||
|
||||
# ------------------------
|
||||
|
|
|
|||
188
tests/carddatabase/visual_display_model_test.cpp
Normal file
188
tests/carddatabase/visual_display_model_test.cpp
Normal 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
|
||||
116
tests/client_login_status_test.cpp
Normal file
116
tests/client_login_status_test.cpp
Normal 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();
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue