mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-09-21 09:05:10 -07:00
Compare commits
36 commits
6c9c144318
...
55099ab82c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55099ab82c | ||
|
|
c5fd10087a | ||
|
|
8f6d18df10 | ||
|
|
2d5734de21 | ||
|
|
de03428e51 | ||
|
|
80703adbbb | ||
|
|
99a409de09 | ||
|
|
18fac5c5b9 | ||
|
|
5cb90c29a6 | ||
|
|
7d1ad2191a | ||
|
|
5cea39a1fc | ||
|
|
dca6fdf17f | ||
|
|
e82b311c8b | ||
|
|
048fe247f4 | ||
|
|
0f003eabf9 | ||
|
|
ada774f5cc | ||
|
|
b0e566ed54 | ||
|
|
0d09e633e3 | ||
|
|
9677fad342 | ||
|
|
e8ec28572f | ||
|
|
0c725f9a03 | ||
|
|
c011ea7ceb | ||
|
|
1dc54617ba | ||
|
|
aa96d81e4b | ||
|
|
14ecfff700 | ||
|
|
61e6a9913e | ||
|
|
4d4ddd4278 | ||
|
|
d6fbfb32a1 | ||
|
|
3ec62df3e7 | ||
|
|
fcfb14cf56 | ||
|
|
35ebae8d7f | ||
|
|
4e9d148163 | ||
|
|
425b16ea0d | ||
|
|
45c7ff6f87 | ||
|
|
d974501277 | ||
|
|
9bf2202739 |
132 changed files with 6710 additions and 1167 deletions
|
|
@ -8,6 +8,7 @@ RUN pacman --sync --refresh --sysupgrade --needed --noconfirm \
|
|||
gtest \
|
||||
mariadb-libs \
|
||||
ninja \
|
||||
openssl \
|
||||
protobuf \
|
||||
qt6-base \
|
||||
qt6-declarative \
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ RUN apt-get update && \
|
|||
libprotobuf-dev \
|
||||
libqt6multimedia6 \
|
||||
libqt6sql6-mysql \
|
||||
libssl-dev \
|
||||
ninja-build \
|
||||
protobuf-compiler \
|
||||
qt6-image-formats-plugins \
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ RUN apt-get update && \
|
|||
libprotobuf-dev \
|
||||
libqt6multimedia6 \
|
||||
libqt6sql6-mysql \
|
||||
libssl-dev \
|
||||
ninja-build \
|
||||
protobuf-compiler \
|
||||
qt6-image-formats-plugins \
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ RUN dnf install -y \
|
|||
git \
|
||||
mariadb-devel \
|
||||
ninja-build \
|
||||
openssl-devel \
|
||||
protobuf-devel \
|
||||
qt6-{qtdeclarative,qtshadertools,qttools,qtsvg,qtmultimedia,qtwebsockets}-devel \
|
||||
qt6-qtimageformats \
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ RUN dnf install -y \
|
|||
git \
|
||||
mariadb-devel \
|
||||
ninja-build \
|
||||
openssl-devel \
|
||||
protobuf-devel \
|
||||
qt6-{qtdeclarative,qtshadertools,qttools,qtsvg,qtmultimedia,qtwebsockets}-devel \
|
||||
qt6-qtimageformats \
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ RUN apt-get update && \
|
|||
libmariadb-dev-compat \
|
||||
libprotobuf-dev \
|
||||
libqt6sql6-mysql \
|
||||
libssl-dev \
|
||||
ninja-build \
|
||||
protobuf-compiler \
|
||||
qt6-tools-dev \
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ RUN apt-get update && \
|
|||
libprotobuf-dev \
|
||||
libqt6multimedia6 \
|
||||
libqt6sql6-mysql \
|
||||
libssl-dev \
|
||||
ninja-build \
|
||||
protobuf-compiler \
|
||||
qt6-image-formats-plugins \
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ RUN apt-get update && \
|
|||
libprotobuf-dev \
|
||||
libqt6multimedia6 \
|
||||
libqt6sql6-mysql \
|
||||
libssl-dev \
|
||||
ninja-build \
|
||||
protobuf-compiler \
|
||||
qt6-image-formats-plugins \
|
||||
|
|
|
|||
|
|
@ -149,6 +149,9 @@ if [[ $MAKE_TEST ]]; then
|
|||
fi
|
||||
if [[ $USE_CCACHE ]]; then
|
||||
flags+=("-DUSE_CCACHE=1")
|
||||
# PCH-aware caching is required or ccache refuses to cache any TU that
|
||||
# consumes a precompiled header, silently recompiling everything on every run.
|
||||
ccache --set-config sloppiness=pch_defines,time_macros
|
||||
if [[ $CCACHE_SIZE ]]; then
|
||||
# note, this setting persists after running the script
|
||||
ccache --max-size "$CCACHE_SIZE"
|
||||
|
|
|
|||
8
.github/workflows/desktop-build.yml
vendored
8
.github/workflows/desktop-build.yml
vendored
|
|
@ -176,8 +176,12 @@ jobs:
|
|||
shell: bash
|
||||
run: |
|
||||
source .ci/docker.sh
|
||||
RUN --server --debug --test --ccache "$CCACHE_SIZE" \
|
||||
--cmake-generator "$CMAKE_GENERATOR"
|
||||
args=()
|
||||
[[ $GITHUB_REF == "refs/heads/master" ]] && args+=(--evict-ccache "$CCACHE_EVICTION_AGE")
|
||||
args+=(--ccache "$CCACHE_SIZE")
|
||||
args+=(--cmake-generator "$CMAKE_GENERATOR")
|
||||
|
||||
RUN --server --debug --test "${args[@]}"
|
||||
|
||||
- name: "Build release package"
|
||||
id: build
|
||||
|
|
|
|||
|
|
@ -5,11 +5,11 @@
|
|||
# This file sets all the variables shared between the projects
|
||||
# like the installation path, compilation flags etc..
|
||||
|
||||
# cmake 3.16 is required if using qt6
|
||||
cmake_minimum_required(VERSION 3.10)
|
||||
# 3.16 required for Qt6 and target_precompile_headers()
|
||||
cmake_minimum_required(VERSION 3.16)
|
||||
|
||||
# Use compiler cache (ccache)
|
||||
option(USE_CCACHE "Cache the build results with ccache" OFF)
|
||||
option(USE_CCACHE "Cache the build results with ccache" ON)
|
||||
# Treat warnings as errors (Debug builds only)
|
||||
option(WARNING_AS_ERROR "Treat warnings as errors in debug builds" ON)
|
||||
# Check for translation updates
|
||||
|
|
@ -39,13 +39,24 @@ else()
|
|||
)
|
||||
endif()
|
||||
|
||||
if(USE_CCACHE)
|
||||
# ccache does not support MSVC and must not auto-engage on Windows
|
||||
# (it is installed unintentionally on the Windows CI runner).
|
||||
# NOTE: this keys off the target OS, so a mingw/Ninja configuration on Windows
|
||||
# also opts out of ccache even though the GNUCXX branch below supports it.
|
||||
if(USE_CCACHE AND NOT WIN32)
|
||||
find_program(CCACHE_PROGRAM ccache)
|
||||
if(CCACHE_PROGRAM)
|
||||
# Support Unix Makefiles and Ninja
|
||||
set_property(GLOBAL PROPERTY RULE_LAUNCH_COMPILE "${CCACHE_PROGRAM}")
|
||||
# PCH-aware caching, matching .ci/compile.sh: without this ccache refuses
|
||||
# to cache any TU that consumes a precompiled header, so every PCH-backed
|
||||
# target recompiles from scratch on each build.
|
||||
execute_process(COMMAND ${CCACHE_PROGRAM} --set-config sloppiness=pch_defines,time_macros)
|
||||
message(STATUS "Found CCache ${CCACHE_PROGRAM}")
|
||||
endif()
|
||||
elseif(USE_CCACHE AND WIN32)
|
||||
# An explicit opt-in must not disappear silently on Windows.
|
||||
message(STATUS "ccache disabled: not supported for the MSVC toolchain on Windows")
|
||||
endif()
|
||||
|
||||
if(WIN32 OR USE_VCPKG)
|
||||
|
|
@ -184,6 +195,9 @@ elseif(CMAKE_COMPILER_IS_GNUCXX)
|
|||
set(CMAKE_CXX_FLAGS_DEBUG "${CMAKE_CXX_FLAGS_DEBUG} ${FLAG}")
|
||||
endif()
|
||||
endforeach()
|
||||
|
||||
# Reduce compiler I/O by using pipes between stages instead of temp files
|
||||
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -pipe")
|
||||
else()
|
||||
# other: osx/llvm, bsd/llvm
|
||||
set(CMAKE_CXX_FLAGS_RELEASE "-O2")
|
||||
|
|
@ -192,6 +206,9 @@ else()
|
|||
else()
|
||||
set(CMAKE_CXX_FLAGS_DEBUG "-g -O0 -Wall -Wextra")
|
||||
endif()
|
||||
|
||||
# Reduce compiler I/O by using pipes between stages instead of temp files
|
||||
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -pipe")
|
||||
endif()
|
||||
|
||||
# GNU systems need to define the Mersenne exponent for the RNG to compile w/o warning
|
||||
|
|
@ -239,11 +256,6 @@ if(WIN32)
|
|||
find_package(OpenSSL REQUIRED)
|
||||
if(OPENSSL_FOUND)
|
||||
include_directories(${OPENSSL_INCLUDE_DIRS})
|
||||
else()
|
||||
message(
|
||||
WARNING
|
||||
"Could not find OpenSSL runtime libraries. They are not required for compiling, but needs to be available at runtime."
|
||||
)
|
||||
endif()
|
||||
endif()
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ RUN apt-get update \
|
|||
libmariadb-dev-compat \
|
||||
libprotobuf-dev \
|
||||
libqt6sql6-mysql \
|
||||
libssl-dev \
|
||||
qt6-websockets-dev \
|
||||
protobuf-compiler \
|
||||
qt6-tools-dev \
|
||||
|
|
@ -42,6 +43,7 @@ RUN apt-get update \
|
|||
libprotobuf32t64 \
|
||||
libqt6sql6-mysql \
|
||||
libqt6websockets6 \
|
||||
libssl3 \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
|
|
|
|||
24
cmake/pch/qtcore_pch.h
Normal file
24
cmake/pch/qtcore_pch.h
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
/** @file qtcore_pch.h
|
||||
* @brief Precompiled header for all Qt targets (Qt Core only).
|
||||
*
|
||||
* Safe for every target that links Qt Core, including the headless
|
||||
* Servatrice binary. Keep this header free of any widget/gui types.
|
||||
*/
|
||||
|
||||
#include <QBasicTimer>
|
||||
#include <QByteArray>
|
||||
#include <QDateTime>
|
||||
#include <QDebug>
|
||||
#include <QFile>
|
||||
#include <QHash>
|
||||
#include <QList>
|
||||
#include <QLoggingCategory>
|
||||
#include <QMap>
|
||||
#include <QMetaObject>
|
||||
#include <QObject>
|
||||
#include <QRandomGenerator>
|
||||
#include <QSharedPointer>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
#include <QTimer>
|
||||
#include <QVariant>
|
||||
30
cmake/pch/qtwidgets_pch.h
Normal file
30
cmake/pch/qtwidgets_pch.h
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
/** @file qtwidgets_pch.h
|
||||
* @brief Precompiled header for GUI targets (Cockatrice client, Oracle).
|
||||
*
|
||||
* Includes the Qt Core precompiled header plus the heavy Gui, Widgets and
|
||||
* Network layers that virtually every client translation unit re-parses.
|
||||
* Do not use on Servatrice (headless, QT_DONT_USE_QTGUI).
|
||||
*/
|
||||
|
||||
#include "qtcore_pch.h"
|
||||
|
||||
#include <QAction>
|
||||
#include <QApplication>
|
||||
#include <QFrame>
|
||||
#include <QGraphicsItem>
|
||||
#include <QGraphicsScene>
|
||||
#include <QGraphicsView>
|
||||
#include <QImage>
|
||||
#include <QLabel>
|
||||
#include <QLayout>
|
||||
#include <QMainWindow>
|
||||
#include <QMenu>
|
||||
#include <QNetworkAccessManager>
|
||||
#include <QNetworkReply>
|
||||
#include <QPainter>
|
||||
#include <QPushButton>
|
||||
#include <QScrollArea>
|
||||
#include <QTabWidget>
|
||||
#include <QToolBar>
|
||||
#include <QTreeWidget>
|
||||
#include <QWidget>
|
||||
|
|
@ -53,6 +53,7 @@ set(cockatrice_SOURCES
|
|||
src/interface/widgets/dialogs/dlg_settings.cpp
|
||||
src/interface/widgets/dialogs/dlg_startup_card_check.cpp
|
||||
src/interface/widgets/dialogs/dlg_tip_of_the_day.cpp
|
||||
src/interface/widgets/dialogs/dlg_tournament_settings.cpp
|
||||
src/interface/widgets/dialogs/dlg_update.cpp
|
||||
src/interface/widgets/dialogs/dlg_view_log.cpp
|
||||
src/interface/widgets/dialogs/override_printing_warning.cpp
|
||||
|
|
@ -214,7 +215,9 @@ set(cockatrice_SOURCES
|
|||
src/interface/widgets/deck_editor/deck_editor_printing_selector_dock_widget.cpp
|
||||
src/interface/widgets/deck_editor/deck_list_style_proxy.cpp
|
||||
src/interface/widgets/deck_editor/deck_state_manager.cpp
|
||||
src/interface/widgets/deck_editor/deck_zone_dialog.cpp
|
||||
src/interface/widgets/deck_editor/printing_disabled_info_widget.cpp
|
||||
src/interface/widgets/draft/tournament_widget.cpp
|
||||
src/interface/widgets/general/background_sources.cpp
|
||||
src/interface/widgets/general/display/background_plate_widget.cpp
|
||||
src/interface/widgets/general/display/banner_widget.cpp
|
||||
|
|
@ -391,6 +394,7 @@ set(cockatrice_SOURCES
|
|||
src/interface/widgets/tabs/tab_server.cpp
|
||||
src/interface/widgets/tabs/tab_supervisor.cpp
|
||||
src/interface/widgets/tabs/tab_visual_database_display.cpp
|
||||
src/interface/widgets/tabs/tournament_tab_game_extension.cpp
|
||||
src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual.cpp
|
||||
src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual_tab_widget.cpp
|
||||
src/interface/widgets/tabs/visual_deck_storage/tab_deck_storage_visual.cpp
|
||||
|
|
@ -516,6 +520,8 @@ qt6_add_executable(
|
|||
MANUAL_FINALIZATION
|
||||
)
|
||||
|
||||
target_precompile_headers(cockatrice PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtwidgets_pch.h")
|
||||
|
||||
qt6_add_shaders(
|
||||
cockatrice
|
||||
"onboarding_shaders"
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ In this list of examples below, each entry has an explanation and can be clicked
|
|||
<dt><u>E</u>dition:</dt>
|
||||
<dd>[set:lea](#set:lea) <small>(Cards that appear in Alpha, which has the set code LEA)</small></dd>
|
||||
<dd>[e:lea OR e:leb](#e:lea OR e:leb) <small>(Cards that appear in Alpha or Beta)</small></dd>
|
||||
<dd>[e<8ED](#e<8ED) <small>(Cards that appear before 8th edition)</small></dd>
|
||||
|
||||
<dt>Negate:</dt>
|
||||
<dd>[c:wu -c:m](#c:wu -c:m) <small>(Any card that is white or blue, but not multicolored)</small></dd>
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@
|
|||
#include <libcockatrice/protocol/pb/event_reverse_turn.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_tournament_state.pb.h>
|
||||
#include <libcockatrice/protocol/pb/game_event_container.pb.h>
|
||||
#include <libcockatrice/protocol/pending_command.h>
|
||||
|
||||
|
|
@ -158,6 +159,9 @@ void GameEventHandler::processGameEventContainer(const GameEventContainer &cont,
|
|||
case GameEvent::REVERSE_TURN:
|
||||
eventReverseTurn(event.GetExtension(Event_ReverseTurn::ext), playerId, context);
|
||||
break;
|
||||
case GameEvent::TOURNAMENT_STATE:
|
||||
emit tournamentStateChanged(event.GetExtension(Event_TournamentState::ext));
|
||||
break;
|
||||
|
||||
default: {
|
||||
PlayerLogic *player = game->getPlayerManager()->getPlayers().value(playerId, 0);
|
||||
|
|
@ -263,6 +267,13 @@ void GameEventHandler::eventGameStateChanged(const Event_GameStateChanged &event
|
|||
int /*eventPlayerId*/,
|
||||
const GameEventContext & /*context*/)
|
||||
{
|
||||
// Sub-games of a tournament report their parent hub game so the client can
|
||||
// route "close game" back to the parent tab. Use has_parent_game_id rather
|
||||
// than comparing the default so a future drop of the proto default still works.
|
||||
if (event.has_parent_game_id()) {
|
||||
game->getGameMetaInfo()->setParentGameId(event.parent_game_id());
|
||||
}
|
||||
|
||||
const int playerListSize = event.player_list_size();
|
||||
|
||||
QVector<QPair<int, QPair<QString, QString>>> opponentDecksToDisplay;
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ class Event_SetActivePhase;
|
|||
class Event_GameSay;
|
||||
class Event_Kicked;
|
||||
class Event_ReverseTurn;
|
||||
class Event_TournamentState;
|
||||
class Event_Ping;
|
||||
|
||||
inline Q_LOGGING_CATEGORY(GameEventHandlerLog, "game_event_handler");
|
||||
|
|
@ -329,6 +330,7 @@ signals:
|
|||
|
||||
void gameStopped();
|
||||
void gameClosed();
|
||||
void tournamentStateChanged(const Event_TournamentState &state);
|
||||
void playerPropertiesChanged(const ServerInfo_PlayerProperties &prop, int playerId);
|
||||
void playerJoined(const ServerInfo_PlayerProperties &playerInfo);
|
||||
void playerLeft(int leavingPlayerId);
|
||||
|
|
|
|||
|
|
@ -84,6 +84,21 @@ public:
|
|||
return roomGameTypes.find(gameInfo_.game_types(index)).value();
|
||||
}
|
||||
|
||||
bool isTournament() const
|
||||
{
|
||||
return gameInfo_.is_tournament();
|
||||
}
|
||||
|
||||
int parentGameId() const
|
||||
{
|
||||
return parentGameId_;
|
||||
}
|
||||
|
||||
void setParentGameId(int id)
|
||||
{
|
||||
parentGameId_ = id;
|
||||
}
|
||||
|
||||
public slots:
|
||||
void setStarted(bool s)
|
||||
{
|
||||
|
|
@ -108,6 +123,7 @@ signals:
|
|||
|
||||
private:
|
||||
ServerInfo_Game gameInfo_;
|
||||
int parentGameId_ = -1;
|
||||
};
|
||||
|
||||
#endif // GAME_META_INFO_H
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@
|
|||
#include <algorithm>
|
||||
#include <libcockatrice/card/card_info.h>
|
||||
#include <libcockatrice/deck_list/deck_list.h>
|
||||
#include <libcockatrice/deck_list/tree/deck_list_card_node.h>
|
||||
#include <libcockatrice/settings/cards_display_settings.h>
|
||||
|
||||
DeckViewCardDragItem::DeckViewCardDragItem(DeckViewCard *_item,
|
||||
|
|
@ -381,12 +380,10 @@ void DeckViewScene::rebuildTree()
|
|||
addItem(container);
|
||||
}
|
||||
|
||||
for (int j = 0; j < currentZone->size(); j++) {
|
||||
auto *currentCard = dynamic_cast<DecklistCardNode *>(currentZone->at(j));
|
||||
if (!currentCard) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Cards in custom zones nested under a board are regular board cards in-game.
|
||||
// They are collected recursively (like every other consumer) and reported with
|
||||
// the top-level board zone as their origin, so that sideboard plans keep working.
|
||||
for (auto *currentCard : deck->getCardNodes({currentZone->getName()})) {
|
||||
for (int k = 0; k < currentCard->getNumber(); ++k) {
|
||||
auto *newCard = new DeckViewCard(container, currentCard->toCardRef(), currentZone->getName());
|
||||
container->addCard(newCard);
|
||||
|
|
|
|||
|
|
@ -44,11 +44,16 @@ GameScene::GameScene(PhasesToolbar *_phasesToolbar, QObject *parent)
|
|||
|
||||
GameScene::~GameScene()
|
||||
{
|
||||
// Sever all incoming connections (animated item destroy-tracking) before the
|
||||
// members below are destroyed: the base QGraphicsScene destructor destroys the
|
||||
// remaining items, and their destroyed() signals must not reach slots that
|
||||
// reference members that no longer exist.
|
||||
QObject::disconnect(nullptr, nullptr, this, nullptr);
|
||||
// Sever all destroyed->removeAnimatedItem connections before the members below
|
||||
// are destroyed: the base QGraphicsScene destructor destroys the remaining items,
|
||||
// and their destroyed() signals must not reach slots that reference members that
|
||||
// no longer exist. The connection handle overload is used because the string-based
|
||||
// disconnect(nullptr, nullptr, this, nullptr) is invalid (the sender must never be
|
||||
// nullptr) and would otherwise fail to sever these pointer-to-member connections.
|
||||
for (auto it = animationItemConnections.constBegin(); it != animationItemConnections.constEnd(); ++it) {
|
||||
QObject::disconnect(*it);
|
||||
}
|
||||
animationItemConnections.clear();
|
||||
|
||||
delete animationTimer;
|
||||
animationTimer = nullptr;
|
||||
|
|
@ -777,8 +782,15 @@ void GameScene::registerAnimationItem(IAnimatedItem *item)
|
|||
if (!object) {
|
||||
return;
|
||||
}
|
||||
if (!animatedItems.contains(object)) {
|
||||
connect(object, &QObject::destroyed, this, &GameScene::removeAnimatedItem);
|
||||
// Guard against duplicate connections using the connection map, not
|
||||
// animatedItems: the animation timer removes entries from animatedItems when an
|
||||
// animation completes, but the destroyed->removeAnimatedItem connection must
|
||||
// persist until the object is destroyed. Relying on animatedItems here would let
|
||||
// a re-registered item (e.g. a life counter that flashes repeatedly) accumulate
|
||||
// duplicate destroyed connections, the older ones of which would survive teardown.
|
||||
if (!animationItemConnections.contains(object)) {
|
||||
animationItemConnections.insert(object,
|
||||
connect(object, &QObject::destroyed, this, &GameScene::removeAnimatedItem));
|
||||
}
|
||||
animatedItems.insert(object, item);
|
||||
if (animationTimer && !animationTimer->isActive()) {
|
||||
|
|
@ -797,6 +809,7 @@ void GameScene::unregisterAnimationItem(IAnimatedItem *item)
|
|||
void GameScene::removeAnimatedItem(QObject *item)
|
||||
{
|
||||
animatedItems.remove(item);
|
||||
animationItemConnections.remove(item);
|
||||
if (animationTimer && animatedItems.isEmpty()) {
|
||||
animationTimer->stop();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,9 +54,11 @@ private:
|
|||
QPointer<CardItem> hoveredCard; ///< Currently hovered card
|
||||
QBasicTimer *animationTimer; ///< Timer for scene animations
|
||||
QHash<QObject *, IAnimatedItem *> animatedItems; ///< Items currently animating
|
||||
int playerRotation; ///< Rotation offset for player layout
|
||||
bool rearranging = false; ///< Guard against re-entrant rearrange
|
||||
bool needsReArrange = false; ///< Pending rearrange requested during a pass
|
||||
QHash<QObject *, QMetaObject::Connection>
|
||||
animationItemConnections; ///< destroyed->removeAnimatedItem handles per animated item
|
||||
int playerRotation; ///< Rotation offset for player layout
|
||||
bool rearranging = false; ///< Guard against re-entrant rearrange
|
||||
bool needsReArrange = false; ///< Pending rearrange requested during a pass
|
||||
|
||||
/**
|
||||
* @brief Updates which card is currently hovered based on scene coordinates.
|
||||
|
|
|
|||
|
|
@ -12,11 +12,13 @@ TallyMenu::TallyMenu()
|
|||
aTallyNone = createTallyAction(TallyType::None);
|
||||
aTallySubtypes = createTallyAction(TallyType::Subtypes);
|
||||
aTallyTotalPower = createTallyAction(TallyType::TotalPower);
|
||||
aTallyTotalToughness = createTallyAction(TallyType::TotalToughness);
|
||||
|
||||
addAction(aTallyNone);
|
||||
addSeparator();
|
||||
addAction(aTallySubtypes);
|
||||
addAction(aTallyTotalPower);
|
||||
addAction(aTallyTotalToughness);
|
||||
|
||||
retranslateUi();
|
||||
}
|
||||
|
|
@ -54,4 +56,5 @@ void TallyMenu::retranslateUi()
|
|||
aTallyNone->setText(tr("None"));
|
||||
aTallySubtypes->setText(tr("Subtypes"));
|
||||
aTallyTotalPower->setText(tr("Total Power"));
|
||||
aTallyTotalToughness->setText(tr("Total Toughness"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ private:
|
|||
QAction *aTallyNone = nullptr;
|
||||
QAction *aTallySubtypes = nullptr;
|
||||
QAction *aTallyTotalPower = nullptr;
|
||||
QAction *aTallyTotalToughness = nullptr;
|
||||
|
||||
QAction *createTallyAction(TallyType tallyType);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -34,3 +34,31 @@ QList<TallyRow> StatsTally::computeTotalPower(const QList<CardItem *> &cards)
|
|||
QString name = QCoreApplication::translate("StatsTally", "Total Power");
|
||||
return {TallyRow{name, QString::number(total)}};
|
||||
}
|
||||
|
||||
static int sumToughness(const QList<CardItem *> &cards)
|
||||
{
|
||||
int total = 0;
|
||||
for (auto card : cards) {
|
||||
QVariantList parsed = CardItem::parsePT(card->getPT());
|
||||
if (parsed.size() == 2) {
|
||||
int toughness = parsed.at(1).toInt(); // toInt will default to 0 if it's not an int
|
||||
total += qMax(toughness, 0);
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
QList<TallyRow> StatsTally::computeTotalToughness(const QList<CardItem *> &cards)
|
||||
{
|
||||
// don't bother if none of the cards have pt
|
||||
bool hasPT =
|
||||
std::any_of(cards.cbegin(), cards.cend(), [](const CardItem *card) { return !card->getPT().isEmpty(); });
|
||||
if (!hasPT) {
|
||||
return {};
|
||||
}
|
||||
|
||||
int total = sumToughness(cards);
|
||||
|
||||
QString name = QCoreApplication::translate("StatsTally", "Total Toughness");
|
||||
return {TallyRow{name, QString::number(total)}};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,14 @@ namespace StatsTally
|
|||
*/
|
||||
QList<TallyRow> computeTotalPower(const QList<CardItem *> &cards);
|
||||
|
||||
/**
|
||||
* @brief Sums the toughness of all selected cards
|
||||
*
|
||||
* @param cards The list of selected card items to analyze.
|
||||
* @return A single row containing the total, or an empty list if none of the cards have pt
|
||||
*/
|
||||
QList<TallyRow> computeTotalToughness(const QList<CardItem *> &cards);
|
||||
|
||||
} // namespace StatsTally
|
||||
|
||||
#endif // COCKATRICE_STATS_TALLY_H
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ QList<TallyRow> Tally::compute(const QList<CardItem *> &cards, const TallyType t
|
|||
return SubtypeTally::countSubtypes(cards);
|
||||
case TallyType::TotalPower:
|
||||
return StatsTally::computeTotalPower(cards);
|
||||
case TallyType::TotalToughness:
|
||||
return StatsTally::computeTotalToughness(cards);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,8 @@ enum class TallyType
|
|||
None,
|
||||
Subtypes,
|
||||
TotalPower,
|
||||
MaxValue = TotalPower // sentinel value
|
||||
TotalToughness,
|
||||
MaxValue = TotalToughness // sentinel value
|
||||
};
|
||||
|
||||
namespace Tally
|
||||
|
|
|
|||
|
|
@ -41,7 +41,8 @@ void HandZone::handleDropEvent(const QList<CardDragItem *> &dragItems,
|
|||
}
|
||||
}
|
||||
} else {
|
||||
x = calcDropIndexFromY(dropPoint.y());
|
||||
bool sameZone = startZone == getLogic();
|
||||
x = calcDropIndexFromY(dropPoint.y(), !sameZone);
|
||||
}
|
||||
|
||||
Command_MoveCard cmd;
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ SelectZone::StackLayoutParams SelectZone::buildStackParams(qreal minOffset) cons
|
|||
return {cardCount, boundingRect().height(), cardHeight, offset, minOffset};
|
||||
}
|
||||
|
||||
int SelectZone::calcDropIndexFromY(qreal dropY, qreal minOffset) const
|
||||
int SelectZone::calcDropIndexFromY(qreal dropY, bool allowCountExpand, qreal minOffset) const
|
||||
{
|
||||
const auto &cards = getLogic()->getCards();
|
||||
if (cards.isEmpty()) {
|
||||
|
|
@ -94,7 +94,8 @@ int SelectZone::calcDropIndexFromY(qreal dropY, qreal minOffset) const
|
|||
if (effectiveOffset <= 0.0) {
|
||||
return 0;
|
||||
}
|
||||
return qBound(0, qRound((dropY - start) / effectiveOffset), params.cardCount - 1);
|
||||
int max = allowCountExpand ? params.cardCount : params.cardCount - 1;
|
||||
return qBound(0, qRound((dropY - start) / effectiveOffset), max);
|
||||
}
|
||||
|
||||
void SelectZone::restoreStaleEscapedCards()
|
||||
|
|
|
|||
|
|
@ -104,8 +104,12 @@ protected:
|
|||
/**
|
||||
* @brief Computes the card index at a given y-coordinate within the zone's vertical layout.
|
||||
* Returns 0 if the zone has no cards or the offset is zero.
|
||||
*
|
||||
* @param dropY The y-coordinate that the card was dropped at
|
||||
* @param allowCountExpand If false, clamps the index at the number of cards minus 1
|
||||
* @param minOffset Minimum offset to preserve
|
||||
*/
|
||||
int calcDropIndexFromY(qreal dropY, qreal minOffset = 0.0) const;
|
||||
int calcDropIndexFromY(qreal dropY, bool allowCountExpand, qreal minOffset = 0.0) const;
|
||||
|
||||
/**
|
||||
* @brief Positions cards vertically with alternating left/right x-offsets.
|
||||
|
|
|
|||
|
|
@ -57,18 +57,14 @@ void StackZone::handleDropEvent(const QList<CardDragItem *> &dragItems,
|
|||
return;
|
||||
}
|
||||
|
||||
const auto &cards = getLogic()->getCards();
|
||||
int index;
|
||||
if (startZone == getLogic()) {
|
||||
// Reordering within the zone: use drop position
|
||||
index = calcDropIndexFromY(dropPoint.y(), MIN_CARD_VISIBLE);
|
||||
bool sameZone = startZone == getLogic();
|
||||
int index = calcDropIndexFromY(dropPoint.y(), !sameZone, MIN_CARD_VISIBLE);
|
||||
if (sameZone) {
|
||||
// Same-zone no-op: don't move a card onto itself
|
||||
const auto &cards = getLogic()->getCards();
|
||||
if (!cards.isEmpty() && cards.at(index)->getId() == dragItems.at(0)->getId()) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Coming from another zone: append at end (top of stack, rendered on top)
|
||||
index = static_cast<int>(cards.size());
|
||||
}
|
||||
|
||||
Command_MoveCard cmd;
|
||||
|
|
|
|||
|
|
@ -375,15 +375,32 @@ void DeckLoader::saveToStream_DeckHeader(QTextStream &out, const DeckList &deckL
|
|||
void DeckLoader::saveToStream_DeckZone(QTextStream &out,
|
||||
const InnerDecklistNode *zoneNode,
|
||||
bool addComments,
|
||||
bool addSetNameAndNumber)
|
||||
bool addSetNameAndNumber,
|
||||
const QString &boardZoneName)
|
||||
{
|
||||
// Nested sub-zones keep their owning board's identity: the top-level call
|
||||
// passes no board, so the zone's own name is used; recursive calls carry the
|
||||
// owning board down so the sideboard marker survives sub-zone nesting.
|
||||
const QString owningBoardZoneName = boardZoneName.isEmpty() ? zoneNode->getName() : boardZoneName;
|
||||
|
||||
// group cards by card type and count the subtotals
|
||||
QMultiMap<QString, DecklistCardNode *> cardsByType;
|
||||
QMap<QString, int> cardTotalByType;
|
||||
int cardTotal = 0;
|
||||
QList<const InnerDecklistNode *> subZones;
|
||||
|
||||
for (int j = 0; j < zoneNode->size(); j++) {
|
||||
auto *card = dynamic_cast<DecklistCardNode *>(zoneNode->at(j));
|
||||
if (!card) {
|
||||
// Cards collected in nested sub-zones are exported by recursion so
|
||||
// they don't end up invisible in the plain text output. They are
|
||||
// deferred until after this zone's own header and cards so they read
|
||||
// as part of this zone's block.
|
||||
if (auto *subZone = dynamic_cast<const InnerDecklistNode *>(zoneNode->at(j))) {
|
||||
subZones.append(subZone);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
CardInfoPtr info = CardDatabaseManager::query()->getCardInfo(card->getName());
|
||||
QString cardType = info ? info->getMainCardType() : "unknown";
|
||||
|
|
@ -411,25 +428,30 @@ void DeckLoader::saveToStream_DeckZone(QTextStream &out,
|
|||
|
||||
QList<DecklistCardNode *> cards = cardsByType.values(cardType);
|
||||
|
||||
saveToStream_DeckZoneCards(out, zoneNode, cards, addComments, addSetNameAndNumber);
|
||||
saveToStream_DeckZoneCards(out, cards, addComments, addSetNameAndNumber, owningBoardZoneName);
|
||||
|
||||
if (addComments) {
|
||||
out << "\n";
|
||||
}
|
||||
}
|
||||
|
||||
// Nested sub-zones come last, after the parent's own header and cards.
|
||||
for (const auto *subZone : subZones) {
|
||||
saveToStream_DeckZone(out, subZone, addComments, addSetNameAndNumber, owningBoardZoneName);
|
||||
}
|
||||
}
|
||||
|
||||
void DeckLoader::saveToStream_DeckZoneCards(QTextStream &out,
|
||||
const InnerDecklistNode *zoneNode,
|
||||
QList<DecklistCardNode *> cards,
|
||||
bool addComments,
|
||||
bool addSetNameAndNumber)
|
||||
bool addSetNameAndNumber,
|
||||
const QString &boardZoneName)
|
||||
{
|
||||
// QMultiMap sorts values in reverse order
|
||||
for (int i = cards.size() - 1; i >= 0; --i) {
|
||||
DecklistCardNode *card = cards[i];
|
||||
|
||||
if (zoneNode->getName() == DECK_ZONE_SIDE && addComments) {
|
||||
if (boardZoneName == DECK_ZONE_SIDE && addComments) {
|
||||
out << "SB: ";
|
||||
}
|
||||
|
||||
|
|
@ -510,9 +532,26 @@ bool DeckLoader::convertToCockatriceFormat(LoadedDeck &deck)
|
|||
|
||||
void DeckLoader::printDeckListNode(QTextCursor *cursor, const InnerDecklistNode *node)
|
||||
{
|
||||
if (!node || node->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const int totalColumns = 2;
|
||||
|
||||
if (node->height() == 1) {
|
||||
// Dispatch children by type instead of trusting a whole-node height: a deck
|
||||
// node may hold direct cards and nested zones side by side (custom zones),
|
||||
// and an empty node would previously crash on at(0).
|
||||
QVector<const AbstractDecklistCardNode *> cards;
|
||||
QVector<const InnerDecklistNode *> subZones;
|
||||
for (int i = 0; i < node->size(); i++) {
|
||||
if (auto *card = dynamic_cast<const AbstractDecklistCardNode *>(node->at(i))) {
|
||||
cards.append(card);
|
||||
} else if (auto *zone = dynamic_cast<const InnerDecklistNode *>(node->at(i))) {
|
||||
subZones.append(zone);
|
||||
}
|
||||
}
|
||||
|
||||
if (!cards.isEmpty()) {
|
||||
QTextBlockFormat blockFormat;
|
||||
QTextCharFormat charFormat;
|
||||
charFormat.setFontPointSize(11);
|
||||
|
|
@ -523,9 +562,9 @@ void DeckLoader::printDeckListNode(QTextCursor *cursor, const InnerDecklistNode
|
|||
tableFormat.setCellPadding(0);
|
||||
tableFormat.setCellSpacing(0);
|
||||
tableFormat.setBorder(0);
|
||||
QTextTable *table = cursor->insertTable(node->size() + 1, totalColumns, tableFormat);
|
||||
for (int i = 0; i < node->size(); i++) {
|
||||
auto *card = dynamic_cast<AbstractDecklistCardNode *>(node->at(i));
|
||||
QTextTable *table = cursor->insertTable(cards.size() + 1, totalColumns, tableFormat);
|
||||
for (int i = 0; i < cards.size(); i++) {
|
||||
const AbstractDecklistCardNode *card = cards[i];
|
||||
|
||||
QTextCharFormat cellCharFormat;
|
||||
cellCharFormat.setFontPointSize(9);
|
||||
|
|
@ -540,7 +579,13 @@ void DeckLoader::printDeckListNode(QTextCursor *cursor, const InnerDecklistNode
|
|||
cellCursor = cell.firstCursorPosition();
|
||||
cellCursor.insertText(card->getName());
|
||||
}
|
||||
} else if (node->height() == 2) {
|
||||
}
|
||||
|
||||
for (const InnerDecklistNode *subZone : subZones) {
|
||||
if (subZone->isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
QTextBlockFormat blockFormat;
|
||||
QTextCharFormat charFormat;
|
||||
charFormat.setFontPointSize(14);
|
||||
|
|
@ -559,10 +604,8 @@ void DeckLoader::printDeckListNode(QTextCursor *cursor, const InnerDecklistNode
|
|||
tableFormat.setColumnWidthConstraints(constraints);
|
||||
|
||||
QTextTable *table = cursor->insertTable(1, totalColumns, tableFormat);
|
||||
for (int i = 0; i < node->size(); i++) {
|
||||
QTextCursor cellCursor = table->cellAt(0, (i * totalColumns) / node->size()).lastCursorPosition();
|
||||
printDeckListNode(&cellCursor, dynamic_cast<InnerDecklistNode *>(node->at(i)));
|
||||
}
|
||||
QTextCursor cellCursor = table->cellAt(0, 0).firstCursorPosition();
|
||||
printDeckListNode(&cellCursor, subZone);
|
||||
}
|
||||
|
||||
cursor->movePosition(QTextCursor::End);
|
||||
|
|
|
|||
|
|
@ -159,12 +159,13 @@ private:
|
|||
static void saveToStream_DeckZone(QTextStream &out,
|
||||
const InnerDecklistNode *zoneNode,
|
||||
bool addComments = true,
|
||||
bool addSetNameAndNumber = true);
|
||||
bool addSetNameAndNumber = true,
|
||||
const QString &boardZoneName = QString());
|
||||
static void saveToStream_DeckZoneCards(QTextStream &out,
|
||||
const InnerDecklistNode *zoneNode,
|
||||
QList<DecklistCardNode *> cards,
|
||||
bool addComments = true,
|
||||
bool addSetNameAndNumber = true);
|
||||
bool addSetNameAndNumber = true,
|
||||
const QString &boardZoneName = QString());
|
||||
};
|
||||
|
||||
#endif
|
||||
|
|
|
|||
|
|
@ -174,16 +174,18 @@ void CardGroupDisplayWidget::updateCardDisplays()
|
|||
QModelIndex sourceIndex = proxy.mapToSource(proxyIndex);
|
||||
|
||||
// 4. persist the source index
|
||||
QPersistentModelIndex persistent(sourceIndex);
|
||||
addCardWidgets(QPersistentModelIndex(sourceIndex));
|
||||
}
|
||||
}
|
||||
|
||||
// Get the card amount
|
||||
int cardAmount =
|
||||
sourceIndex.sibling(sourceIndex.row(), DeckListModelColumns::CARD_AMOUNT).data(Qt::EditRole).toInt();
|
||||
void CardGroupDisplayWidget::addCardWidgets(const QPersistentModelIndex &persistent)
|
||||
{
|
||||
// Get the card amount
|
||||
int cardAmount = persistent.sibling(persistent.row(), DeckListModelColumns::CARD_AMOUNT).data(Qt::EditRole).toInt();
|
||||
|
||||
// Create multiple widgets for the card count
|
||||
for (int copy = 0; copy < cardAmount; ++copy) {
|
||||
addToLayout(constructWidgetForIndex(persistent));
|
||||
}
|
||||
// Create multiple widgets for the card count
|
||||
for (int copy = 0; copy < cardAmount; ++copy) {
|
||||
addToLayout(constructWidgetForIndex(persistent));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ public:
|
|||
void onSelectionChanged(const QItemSelection &selected, const QItemSelection &deselected);
|
||||
void refreshSelectionForIndex(const QPersistentModelIndex &persistent);
|
||||
void clearAllDisplayWidgets();
|
||||
void addCardWidgets(const QPersistentModelIndex &persistent);
|
||||
|
||||
DeckListModel *deckListModel;
|
||||
QItemSelectionModel *selectionModel;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
#include "libcockatrice/card/database/card_database_manager.h"
|
||||
|
||||
#include <QResizeEvent>
|
||||
#include <algorithm>
|
||||
#include <libcockatrice/models/deck_list/deck_list_model.h>
|
||||
|
||||
DeckCardZoneDisplayWidget::DeckCardZoneDisplayWidget(QWidget *parent,
|
||||
|
|
@ -51,11 +52,6 @@ DeckCardZoneDisplayWidget::DeckCardZoneDisplayWidget(QWidget *parent,
|
|||
// User Interaction
|
||||
// =====================================================================================================================
|
||||
|
||||
void DeckCardZoneDisplayWidget::onClick(QMouseEvent *event, const ExactCard &card)
|
||||
{
|
||||
emit cardClicked(event, card, zoneName);
|
||||
}
|
||||
|
||||
void DeckCardZoneDisplayWidget::onHover(const ExactCard &card)
|
||||
{
|
||||
emit cardHovered(card);
|
||||
|
|
@ -95,12 +91,18 @@ void DeckCardZoneDisplayWidget::constructAppropriateWidget(QPersistentModelIndex
|
|||
}
|
||||
|
||||
auto categoryName = index.sibling(index.row(), DeckListModelColumns::CARD_NAME).data(Qt::EditRole).toString();
|
||||
// Cards in a custom zone belong to that zone, not the board zone, so that
|
||||
// increment/decrement/swap actions target the custom zone.
|
||||
const bool isCustomZone = index.data(DeckRoles::IsCustomZoneRole).toBool();
|
||||
const QString effectiveZoneName = isCustomZone ? categoryName : zoneName;
|
||||
const auto routeCardClick = [this, effectiveZoneName](QMouseEvent *event, const ExactCard &card) {
|
||||
emit cardClicked(event, card, effectiveZoneName);
|
||||
};
|
||||
if (displayType == DisplayType::Overlap) {
|
||||
auto *displayWidget = new OverlappedCardGroupDisplayWidget(
|
||||
cardGroupContainer, deckListModel, selectionModel, index, zoneName, categoryName, activeGroupCriteria,
|
||||
activeSortCriteria, subBannerOpacity, cardSizeWidget);
|
||||
connect(displayWidget, &OverlappedCardGroupDisplayWidget::cardClicked, this,
|
||||
&DeckCardZoneDisplayWidget::onClick);
|
||||
cardGroupContainer, deckListModel, selectionModel, index, effectiveZoneName, categoryName,
|
||||
activeGroupCriteria, activeSortCriteria, subBannerOpacity, cardSizeWidget);
|
||||
connect(displayWidget, &OverlappedCardGroupDisplayWidget::cardClicked, this, routeCardClick);
|
||||
connect(displayWidget, &OverlappedCardGroupDisplayWidget::cardHovered, this,
|
||||
&DeckCardZoneDisplayWidget::onHover);
|
||||
connect(displayWidget, &CardGroupDisplayWidget::cleanupRequested, this,
|
||||
|
|
@ -111,9 +113,9 @@ void DeckCardZoneDisplayWidget::constructAppropriateWidget(QPersistentModelIndex
|
|||
indexToWidgetMap.insert(index, displayWidget);
|
||||
} else if (displayType == DisplayType::Flat) {
|
||||
auto *displayWidget = new FlatCardGroupDisplayWidget(cardGroupContainer, deckListModel, selectionModel, index,
|
||||
zoneName, categoryName, activeGroupCriteria,
|
||||
effectiveZoneName, categoryName, activeGroupCriteria,
|
||||
activeSortCriteria, subBannerOpacity, cardSizeWidget);
|
||||
connect(displayWidget, &FlatCardGroupDisplayWidget::cardClicked, this, &DeckCardZoneDisplayWidget::onClick);
|
||||
connect(displayWidget, &FlatCardGroupDisplayWidget::cardClicked, this, routeCardClick);
|
||||
connect(displayWidget, &FlatCardGroupDisplayWidget::cardHovered, this, &DeckCardZoneDisplayWidget::onHover);
|
||||
connect(displayWidget, &CardGroupDisplayWidget::cleanupRequested, this,
|
||||
&DeckCardZoneDisplayWidget::cleanupInvalidCardGroup);
|
||||
|
|
@ -126,24 +128,18 @@ void DeckCardZoneDisplayWidget::constructAppropriateWidget(QPersistentModelIndex
|
|||
|
||||
void DeckCardZoneDisplayWidget::displayCards()
|
||||
{
|
||||
QSortFilterProxyModel proxy;
|
||||
proxy.setSourceModel(deckListModel);
|
||||
proxy.setSortRole(Qt::EditRole);
|
||||
proxy.sort(DeckListModelColumns::CARD_NAME, Qt::AscendingOrder);
|
||||
if (!trackedIndex.isValid()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. trackedIndex is a source index → map it to proxy space
|
||||
QModelIndex proxyParent = proxy.mapFromSource(trackedIndex);
|
||||
|
||||
// 2. iterate children under the proxy parent
|
||||
for (int i = 0; i < proxy.rowCount(proxyParent); ++i) {
|
||||
QModelIndex proxyIndex = proxy.index(i, 0, proxyParent);
|
||||
|
||||
// 3. map back to source
|
||||
QModelIndex sourceIndex = proxy.mapToSource(proxyIndex);
|
||||
|
||||
// 4. persist the source index
|
||||
QPersistentModelIndex persistent(sourceIndex);
|
||||
// Iterate the direct children of the tracked zone, keeping the tree view's row
|
||||
// order (criteria groups first, then custom zones, both in the model's sort order).
|
||||
QList<QPersistentModelIndex> rows;
|
||||
for (int i = 0; i < deckListModel->rowCount(trackedIndex); ++i) {
|
||||
rows.append(QPersistentModelIndex(deckListModel->index(i, 0, trackedIndex)));
|
||||
}
|
||||
|
||||
for (const QPersistentModelIndex &persistent : rows) {
|
||||
constructAppropriateWidget(persistent);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,7 +42,6 @@ public:
|
|||
void addCardsToOverlapWidget();
|
||||
|
||||
public slots:
|
||||
void onClick(QMouseEvent *event, const ExactCard &card);
|
||||
void onHover(const ExactCard &card);
|
||||
void cleanupInvalidCardGroup(CardGroupDisplayWidget *displayWidget);
|
||||
void constructAppropriateWidget(QPersistentModelIndex index);
|
||||
|
|
|
|||
|
|
@ -90,6 +90,13 @@ void CardDatabaseView::decrementCard(const QString &zoneName)
|
|||
emit cardDecremented(currentCardName(), zoneName);
|
||||
}
|
||||
|
||||
void CardDatabaseView::setZoneMenuProvider(const std::function<QList<QPair<QString, QStringList>>()> &provider,
|
||||
const std::function<QString()> &newZoneHandler)
|
||||
{
|
||||
zoneMenuProvider = provider;
|
||||
this->newZoneHandler = newZoneHandler;
|
||||
}
|
||||
|
||||
void CardDatabaseView::updateCard(const QModelIndex ¤t, const QModelIndex & /*previous*/)
|
||||
{
|
||||
if (!current.isValid()) {
|
||||
|
|
@ -142,6 +149,50 @@ void CardDatabaseView::openCustomMenu(QPoint point)
|
|||
[this, card] { emit cardAdded(card->getName(), DECK_ZONE_SIDE); });
|
||||
connect(selectPrinting, &QAction::triggered, this, &CardDatabaseView::selectPrintingClicked);
|
||||
|
||||
if (zoneMenuProvider) {
|
||||
QMenu *addToZoneMenu = menu.addMenu(tr("Add to Zone"));
|
||||
const auto zoneBoards = zoneMenuProvider();
|
||||
for (const QString &boardName : InnerDecklistNode::boardZoneNames()) {
|
||||
// Boards with zones nest their children so no two menu entries
|
||||
// share a visible name: "Maindeck ▸ { Maindeck (whole board), … }".
|
||||
const QStringList customZones = [&zoneBoards, boardName] {
|
||||
for (const auto &zoneBoard : zoneBoards) {
|
||||
if (zoneBoard.first == boardName) {
|
||||
return zoneBoard.second;
|
||||
}
|
||||
}
|
||||
return QStringList();
|
||||
}();
|
||||
if (customZones.isEmpty()) {
|
||||
QAction *action = addToZoneMenu->addAction(InnerDecklistNode::visibleNameFromName(boardName));
|
||||
connect(action, &QAction::triggered, this,
|
||||
[this, card, boardName] { emit cardAdded(card->getName(), boardName); });
|
||||
} else {
|
||||
QMenu *boardSubmenu = addToZoneMenu->addMenu(InnerDecklistNode::visibleNameFromName(boardName));
|
||||
QAction *wholeBoardAction = boardSubmenu->addAction(InnerDecklistNode::visibleNameFromName(boardName));
|
||||
connect(wholeBoardAction, &QAction::triggered, this,
|
||||
[this, card, boardName] { emit cardAdded(card->getName(), boardName); });
|
||||
for (const QString &zoneName : customZones) {
|
||||
QAction *action = boardSubmenu->addAction(zoneName);
|
||||
connect(action, &QAction::triggered, this,
|
||||
[this, card, zoneName] { emit cardAdded(card->getName(), zoneName); });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (newZoneHandler) {
|
||||
addToZoneMenu->addSeparator();
|
||||
|
||||
QAction *newZoneAction = addToZoneMenu->addAction(tr("Create &new zone..."));
|
||||
connect(newZoneAction, &QAction::triggered, this, [this, card] {
|
||||
const QString zoneName = newZoneHandler();
|
||||
if (!zoneName.isEmpty()) {
|
||||
emit cardAdded(card->getName(), zoneName);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (canBeCommander(*card)) {
|
||||
QAction *edhRecCommander = menu.addAction(tr("Show on EDHRec (Commander)"));
|
||||
connect(edhRecCommander, &QAction::triggered, this, [this, card] { emit edhrecClicked(card, true); });
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
#include "../../key_signals.h"
|
||||
|
||||
#include <QTreeView>
|
||||
#include <functional>
|
||||
#include <libcockatrice/card/card_info.h>
|
||||
|
||||
class CardDatabaseModel;
|
||||
|
|
@ -19,6 +20,13 @@ class CardDatabaseView : public QTreeView
|
|||
KeySignals searchKeySignals;
|
||||
CardDatabaseDisplayModel *databaseDisplayModel;
|
||||
|
||||
/// Provides the custom zones available in the current deck, grouped by board zone.
|
||||
/// The list contains (board zone name, custom zone names) pairs for every board.
|
||||
std::function<QList<QPair<QString, QStringList>>()> zoneMenuProvider;
|
||||
/// Handler invoked when the user picks "New zone..." from the add-to-zone menu.
|
||||
/// Returns the name of the created zone, or an empty string if creation was cancelled.
|
||||
std::function<QString()> newZoneHandler;
|
||||
|
||||
public:
|
||||
explicit CardDatabaseView(QWidget *parent, CardDatabaseDisplayModel *model);
|
||||
|
||||
|
|
@ -33,6 +41,17 @@ public:
|
|||
return &searchKeySignals;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Sets the provider used to populate the "Add to zone" submenu of the context menu.
|
||||
* If no provider is set, the submenu is not shown.
|
||||
*
|
||||
* @param provider Returns the custom zones of the current deck, grouped by board zone
|
||||
* @param newZoneHandler Creates a new custom zone and returns its name, or an empty string
|
||||
* if creation was cancelled. The menu entry is hidden when not provided.
|
||||
*/
|
||||
void setZoneMenuProvider(const std::function<QList<QPair<QString, QStringList>>()> &provider,
|
||||
const std::function<QString()> &newZoneHandler);
|
||||
|
||||
signals:
|
||||
void cardChanged(const QString &cardName);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,12 @@
|
|||
#include "deck_editor_card_database_dock_widget.h"
|
||||
|
||||
#include "../../../interface/widgets/tabs/abstract_tab_deck_editor.h"
|
||||
#include "card_database_view.h"
|
||||
#include "deck_state_manager.h"
|
||||
#include "deck_zone_dialog.h"
|
||||
|
||||
#include <libcockatrice/deck_list/deck_list_node_tree.h>
|
||||
|
||||
DeckEditorCardDatabaseDockWidget::DeckEditorCardDatabaseDockWidget(AbstractTabDeckEditor *parent) : QDockWidget(parent)
|
||||
{
|
||||
setObjectName("databaseDisplayDock");
|
||||
|
|
@ -15,6 +22,27 @@ void DeckEditorCardDatabaseDockWidget::createDatabaseDisplayDock(AbstractTabDeck
|
|||
{
|
||||
databaseDisplayWidget = new DeckEditorDatabaseDisplayWidget(this, deckEditor->databaseModel);
|
||||
|
||||
databaseDisplayWidget->getDatabaseView()->setZoneMenuProvider(
|
||||
[deckEditor]() -> QList<QPair<QString, QStringList>> {
|
||||
QList<QPair<QString, QStringList>> result;
|
||||
auto *deckListModel = deckEditor->deckStateManager->getModel();
|
||||
for (const QString &boardName : InnerDecklistNode::boardZoneNames()) {
|
||||
result.append({boardName, deckListModel->getCustomZoneNames(boardName)});
|
||||
}
|
||||
return result;
|
||||
},
|
||||
[this, deckEditor]() -> QString {
|
||||
QString boardName;
|
||||
const QString zoneName =
|
||||
DeckZoneDialog::promptForNewZone(this, {}, &boardName, [deckEditor](const QString &candidate) {
|
||||
return deckEditor->deckStateManager->validateNewZoneName(candidate);
|
||||
});
|
||||
if (!zoneName.isEmpty()) {
|
||||
deckEditor->deckStateManager->createCustomZone(boardName, zoneName);
|
||||
}
|
||||
return zoneName;
|
||||
});
|
||||
|
||||
auto *frame = new QVBoxLayout;
|
||||
frame->setObjectName("databaseDisplayFrame");
|
||||
frame->addWidget(databaseDisplayWidget);
|
||||
|
|
|
|||
|
|
@ -7,15 +7,18 @@
|
|||
#include "../tabs/api/commander_spellbook/commander_bracket_widget.h"
|
||||
#include "deck_list_style_proxy.h"
|
||||
#include "deck_state_manager.h"
|
||||
#include "deck_zone_dialog.h"
|
||||
|
||||
#include <QComboBox>
|
||||
#include <QDockWidget>
|
||||
#include <QHeaderView>
|
||||
#include <QLabel>
|
||||
#include <QMessageBox>
|
||||
#include <QPushButton>
|
||||
#include <QSplitter>
|
||||
#include <QTextEdit>
|
||||
#include <libcockatrice/card/database/card_database_manager.h>
|
||||
#include <libcockatrice/deck_list/deck_list_node_tree.h>
|
||||
#include <libcockatrice/settings/deck_editor_settings.h>
|
||||
#include <libcockatrice/settings/interface_settings.h>
|
||||
#include <libcockatrice/utility/macros.h>
|
||||
|
|
@ -772,14 +775,213 @@ void DeckEditorDeckDockWidget::offsetCountAtIndex(const QModelIndex &idx, bool i
|
|||
|
||||
void DeckEditorDeckDockWidget::decklistCustomMenu(QPoint point)
|
||||
{
|
||||
const QModelIndex sourceIndex = proxy->mapToSource(deckView->indexAt(point));
|
||||
|
||||
QMenu menu;
|
||||
|
||||
const bool isCustomZoneRow = sourceIndex.isValid() && sourceIndex.data(DeckRoles::IsCustomZoneRole).toBool();
|
||||
const bool isBoardZoneRow = sourceIndex.isValid() && !isCustomZoneRow && !sourceIndex.parent().isValid();
|
||||
const bool isCardRow =
|
||||
sourceIndex.isValid() && !isCustomZoneRow && !isBoardZoneRow && !getModel()->hasChildren(sourceIndex);
|
||||
|
||||
// Walk the row up to its top-level node to find the hosting board. Cards in
|
||||
// the tokens board cannot be moved (moveCardToZone bails for it), so the
|
||||
// move menu is skipped for them.
|
||||
QString currentBoardName;
|
||||
QModelIndex board = sourceIndex.parent();
|
||||
while (board.isValid() && board.parent().isValid()) {
|
||||
board = board.parent();
|
||||
}
|
||||
if (board.isValid()) {
|
||||
currentBoardName = board.siblingAtColumn(DeckListModelColumns::CARD_NAME).data(Qt::EditRole).toString();
|
||||
}
|
||||
|
||||
if (isCardRow) {
|
||||
if (currentBoardName != DECK_ZONE_TOKENS) {
|
||||
addMoveToZoneMenu(&menu, sourceIndex, currentBoardName);
|
||||
menu.addSeparator();
|
||||
}
|
||||
} else if (isCustomZoneRow) {
|
||||
const QString zoneName =
|
||||
sourceIndex.siblingAtColumn(DeckListModelColumns::CARD_NAME).data(Qt::EditRole).toString();
|
||||
|
||||
QAction *renameAction = menu.addAction(tr("&Rename zone..."));
|
||||
connect(renameAction, &QAction::triggered, this, [this, zoneName] {
|
||||
// The unchanged name must not validate as a duplicate.
|
||||
const QString newName =
|
||||
DeckZoneDialog::promptForRename(this, zoneName, [this, zoneName](const QString &candidate) {
|
||||
return candidate == zoneName ? QString() : deckStateManager->validateNewZoneName(candidate);
|
||||
});
|
||||
if (!newName.isEmpty() && newName != zoneName) {
|
||||
deckStateManager->renameCustomZone(zoneName, newName);
|
||||
}
|
||||
});
|
||||
|
||||
QMenu *boardMenu = menu.addMenu(tr("Change &board"));
|
||||
addChangeBoardMenu(boardMenu, zoneName);
|
||||
|
||||
QAction *deleteAction = menu.addAction(tr("&Delete zone"));
|
||||
const bool zoneHasCards = getModel()->hasChildren(sourceIndex);
|
||||
deleteAction->setEnabled(!zoneHasCards);
|
||||
if (zoneHasCards) {
|
||||
deleteAction->setToolTip(tr("Move or remove all cards first."));
|
||||
menu.setToolTipsVisible(true);
|
||||
}
|
||||
connect(deleteAction, &QAction::triggered, this, [this, zoneName] {
|
||||
const auto result =
|
||||
QMessageBox::warning(this, tr("Delete zone"), tr("Delete the zone \"%1\"?").arg(zoneName),
|
||||
QMessageBox::Yes | QMessageBox::No, QMessageBox::No);
|
||||
if (result == QMessageBox::Yes) {
|
||||
deckStateManager->removeCustomZone(zoneName);
|
||||
}
|
||||
});
|
||||
menu.addSeparator();
|
||||
} else if (isBoardZoneRow) {
|
||||
const QString boardName =
|
||||
sourceIndex.siblingAtColumn(DeckListModelColumns::CARD_NAME).data(Qt::EditRole).toString();
|
||||
// Tokens cannot host custom zones, so only offer the action on real boards.
|
||||
const bool canHostCustomZones =
|
||||
boardName == DECK_ZONE_MAIN || boardName == DECK_ZONE_SIDE || boardName == DECK_ZONE_MAYBEBOARD;
|
||||
if (canHostCustomZones) {
|
||||
addNewZoneAction(&menu, boardName);
|
||||
menu.addSeparator();
|
||||
}
|
||||
} else if (!sourceIndex.isValid()) {
|
||||
addNewZoneAction(&menu);
|
||||
menu.addSeparator();
|
||||
}
|
||||
|
||||
QAction *selectPrinting = menu.addAction(tr("Select Printing"));
|
||||
connect(selectPrinting, &QAction::triggered, deckEditor, &AbstractTabDeckEditor::showPrintingSelector);
|
||||
|
||||
menu.exec(deckView->mapToGlobal(point));
|
||||
}
|
||||
|
||||
void DeckEditorDeckDockWidget::addMoveToZoneMenu(QMenu *menu,
|
||||
const QModelIndex &sourceCardIndex,
|
||||
const QString ¤tBoardName)
|
||||
{
|
||||
// The card's current *zone*, derived with the same ancestor walk as
|
||||
// DeckStateManager::moveCardToZone (nearest custom-zone ancestor, else the
|
||||
// top-level board/zone): a card inside "Removal" under the maindeck lives in
|
||||
// "Removal", not "main". Comparing against that instead of the board keeps
|
||||
// the enabled state and the same-zone no-op consistent with the move logic.
|
||||
QString currentZoneName;
|
||||
for (QModelIndex ancestor = sourceCardIndex.parent(); ancestor.isValid(); ancestor = ancestor.parent()) {
|
||||
if (ancestor.data(DeckRoles::IsCustomZoneRole).toBool() || !ancestor.parent().isValid()) {
|
||||
currentZoneName = ancestor.siblingAtColumn(DeckListModelColumns::CARD_NAME).data(Qt::EditRole).toString();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const auto addMoveAction = [this, sourceCardIndex](QMenu *targetMenu, const QString &targetZoneName,
|
||||
const QString &label, bool enabled) {
|
||||
QAction *action = targetMenu->addAction(label);
|
||||
action->setEnabled(enabled);
|
||||
if (enabled) {
|
||||
connect(action, &QAction::triggered, this, [this, sourceCardIndex, targetZoneName] {
|
||||
deckStateManager->moveCardToZone(sourceCardIndex, targetZoneName);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const auto tree = deckStateManager->getDeckListShared()->getTree();
|
||||
|
||||
QMenu *moveMenu = menu->addMenu(tr("Move to &zone"));
|
||||
|
||||
for (const QString &boardName : InnerDecklistNode::boardZoneNames()) {
|
||||
const QString boardLabel = InnerDecklistNode::visibleNameFromName(boardName);
|
||||
const auto customZones = tree->getCustomZones(boardName);
|
||||
|
||||
// Boards with zones nest their children so no two menu entries share a
|
||||
// visible name: "Maindeck ▸ { Maindeck (whole board), Removal, … }".
|
||||
// The board the card already lives on is marked instead of offered.
|
||||
if (!customZones.isEmpty()) {
|
||||
QMenu *boardSubmenu = moveMenu->addMenu(boardLabel);
|
||||
addMoveAction(boardSubmenu, boardName, boardLabel, boardName != currentZoneName);
|
||||
for (const auto *customZone : customZones) {
|
||||
addMoveAction(boardSubmenu, customZone->getName(), customZone->getName(),
|
||||
customZone->getName() != currentZoneName);
|
||||
}
|
||||
} else {
|
||||
addMoveAction(moveMenu, boardName, boardLabel, boardName != currentZoneName);
|
||||
}
|
||||
}
|
||||
|
||||
moveMenu->addSeparator();
|
||||
|
||||
QAction *newZoneAction = moveMenu->addAction(tr("Create new zone and move &here..."));
|
||||
connect(newZoneAction, &QAction::triggered, this, [this, sourceCardIndex, currentBoardName, currentZoneName] {
|
||||
// Resolve the card's identity before creating the zone:
|
||||
// createNewCustomZone rebuilds the model tree, so sourceCardIndex's
|
||||
// internal pointer is freed by the time it would be used.
|
||||
const QString cardName =
|
||||
sourceCardIndex.siblingAtColumn(DeckListModelColumns::CARD_NAME).data(Qt::EditRole).toString();
|
||||
const QString providerId =
|
||||
sourceCardIndex.siblingAtColumn(DeckListModelColumns::CARD_PROVIDER_ID).data(Qt::DisplayRole).toString();
|
||||
const QString collectorNumber = sourceCardIndex.siblingAtColumn(DeckListModelColumns::CARD_COLLECTOR_NUMBER)
|
||||
.data(Qt::DisplayRole)
|
||||
.toString();
|
||||
|
||||
const QString zoneName = createNewCustomZone(currentBoardName);
|
||||
if (!zoneName.isEmpty()) {
|
||||
// Re-find the card: the old index is no longer safe since rows were
|
||||
// rebuilt. Mirror DeckStateManager::decrementCard's re-find pattern.
|
||||
const QModelIndex refreshed = getModel()->findCard(cardName, currentZoneName, providerId, collectorNumber);
|
||||
if (refreshed.isValid()) {
|
||||
deckStateManager->moveCardToZone(refreshed, zoneName);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void DeckEditorDeckDockWidget::addChangeBoardMenu(QMenu *menu, const QString &zoneName)
|
||||
{
|
||||
const auto tree = deckStateManager->getDeckListShared()->getTree();
|
||||
for (const QString &boardName : InnerDecklistNode::boardZoneNames()) {
|
||||
QAction *action = menu->addAction(InnerDecklistNode::visibleNameFromName(boardName));
|
||||
|
||||
// The board currently holding the zone is marked instead of offered.
|
||||
// Duplicate names cannot come up through the editor, so this doubles as
|
||||
// the uniqueness guard for imported decks.
|
||||
bool holdsTheZone = false;
|
||||
for (const auto *customZone : tree->getCustomZones(boardName)) {
|
||||
if (customZone->getName() == zoneName) {
|
||||
holdsTheZone = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (holdsTheZone) {
|
||||
action->setCheckable(true);
|
||||
action->setChecked(true);
|
||||
continue;
|
||||
}
|
||||
|
||||
connect(action, &QAction::triggered, this,
|
||||
[this, zoneName, boardName] { deckStateManager->moveCustomZone(zoneName, boardName); });
|
||||
}
|
||||
}
|
||||
|
||||
void DeckEditorDeckDockWidget::addNewZoneAction(QMenu *menu, const QString &initialBoardName)
|
||||
{
|
||||
QAction *newZoneAction = menu->addAction(tr("Create &new zone..."));
|
||||
connect(newZoneAction, &QAction::triggered, this,
|
||||
[this, initialBoardName] { createNewCustomZone(initialBoardName); });
|
||||
}
|
||||
|
||||
QString DeckEditorDeckDockWidget::createNewCustomZone(const QString &initialBoardName)
|
||||
{
|
||||
QString boardName;
|
||||
const QString zoneName =
|
||||
DeckZoneDialog::promptForNewZone(this, initialBoardName, &boardName, [this](const QString &candidate) {
|
||||
return deckStateManager->validateNewZoneName(candidate);
|
||||
});
|
||||
if (!zoneName.isEmpty()) {
|
||||
deckStateManager->createCustomZone(boardName, zoneName);
|
||||
}
|
||||
return zoneName;
|
||||
}
|
||||
|
||||
void DeckEditorDeckDockWidget::refreshShortcuts()
|
||||
{
|
||||
ShortcutsSettings &shortcuts = SettingsCache::instance().shortcuts();
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@
|
|||
#include <QComboBox>
|
||||
#include <QDockWidget>
|
||||
#include <QLabel>
|
||||
#include <QMenu>
|
||||
#include <QPushButton>
|
||||
#include <QTextEdit>
|
||||
#include <QTreeView>
|
||||
|
|
@ -102,6 +103,11 @@ private:
|
|||
[[nodiscard]] QModelIndexList getSelectedCardNodeSourceIndices() const;
|
||||
void offsetCountAtIndex(const QModelIndex &idx, bool isIncrement);
|
||||
|
||||
void addMoveToZoneMenu(QMenu *menu, const QModelIndex &sourceCardIndex, const QString ¤tBoardName);
|
||||
void addChangeBoardMenu(QMenu *menu, const QString &zoneName);
|
||||
QString createNewCustomZone(const QString &initialBoardName = {});
|
||||
void addNewZoneAction(QMenu *menu, const QString &initialBoardName = {});
|
||||
|
||||
private slots:
|
||||
void decklistCustomMenu(QPoint point);
|
||||
void updateCard(QModelIndex, const QModelIndex ¤t);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
#include <libcockatrice/card/database/card_database_manager.h>
|
||||
#include <libcockatrice/deck_list/deck_list_history_manager.h>
|
||||
#include <libcockatrice/deck_list/tree/inner_deck_list_node.h>
|
||||
|
||||
DeckStateManager::DeckStateManager(QObject *parent)
|
||||
: QObject(parent), deckList(QSharedPointer<DeckList>(new DeckList)),
|
||||
|
|
@ -307,6 +308,170 @@ bool DeckStateManager::decrementCountAtIndex(const QModelIndex &idx)
|
|||
return offsetCountAtIndex(idx, -1);
|
||||
}
|
||||
|
||||
bool DeckStateManager::moveCardToZone(const QModelIndex &idx, const QString &targetZoneName)
|
||||
{
|
||||
if (!idx.isValid()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Only actual card rows can be moved. Group or zone rows report an
|
||||
// aggregate amount and must never be deleted by this operation.
|
||||
if (!idx.data(DeckRoles::IsCardRole).toBool()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
QString cardName = idx.siblingAtColumn(DeckListModelColumns::CARD_NAME).data(Qt::EditRole).toString();
|
||||
QString providerId = idx.siblingAtColumn(DeckListModelColumns::CARD_PROVIDER_ID).data(Qt::DisplayRole).toString();
|
||||
int copies = idx.siblingAtColumn(DeckListModelColumns::CARD_AMOUNT).data(Qt::EditRole).toInt();
|
||||
|
||||
if (copies <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Tokens only live in the tokens zone and cannot be moved into decks.
|
||||
CardInfoPtr info = CardDatabaseManager::query()->getCardInfo(cardName);
|
||||
if (info && info->getIsToken()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Determine the zone the card currently lives in: the enclosing custom
|
||||
// zone, or the nearest top-level zone (board zone or legacy zone).
|
||||
QString currentZoneName;
|
||||
for (QModelIndex ancestor = idx.parent(); ancestor.isValid(); ancestor = ancestor.parent()) {
|
||||
bool isCustomZone = ancestor.data(DeckRoles::IsCustomZoneRole).toBool();
|
||||
if (isCustomZone || !ancestor.parent().isValid()) {
|
||||
currentZoneName = ancestor.siblingAtColumn(DeckListModelColumns::CARD_NAME).data(Qt::EditRole).toString();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentZoneName == targetZoneName) {
|
||||
return false;
|
||||
}
|
||||
|
||||
QString reason = tr("Moved %1 × \"%2\" (%3) to %4")
|
||||
.arg(copies)
|
||||
.arg(cardName)
|
||||
.arg(providerId)
|
||||
.arg(InnerDecklistNode::visibleNameFromName(targetZoneName));
|
||||
|
||||
return modifyDeck(reason, [&idx, &cardName, &providerId, &targetZoneName, copies](auto model) {
|
||||
if (!model->removeRow(idx.row(), idx.parent())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (ExactCard card = CardDatabaseManager::query()->getCard({cardName, providerId})) {
|
||||
for (int i = 0; i < copies; ++i) {
|
||||
model->addCard(card, targetZoneName);
|
||||
}
|
||||
} else {
|
||||
for (int i = 0; i < copies; ++i) {
|
||||
model->addPreferredPrintingCard(cardName, targetZoneName, true);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
bool DeckStateManager::createCustomZone(const QString &boardZoneName, const QString &zoneName)
|
||||
{
|
||||
const QString trimmedZoneName = zoneName.trimmed();
|
||||
if (trimmedZoneName.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
QString reason =
|
||||
tr("Created zone \"%1\" in %2").arg(trimmedZoneName, InnerDecklistNode::visibleNameFromName(boardZoneName));
|
||||
|
||||
return modifyTree(reason, [&boardZoneName, &trimmedZoneName](DecklistNodeTree *tree) {
|
||||
return tree->addCustomZone(boardZoneName, trimmedZoneName) != nullptr;
|
||||
});
|
||||
}
|
||||
|
||||
bool DeckStateManager::renameCustomZone(const QString &oldZoneName, const QString &newZoneName)
|
||||
{
|
||||
const QString trimmedNewZoneName = newZoneName.trimmed();
|
||||
if (trimmedNewZoneName.isEmpty() || oldZoneName == trimmedNewZoneName) {
|
||||
return false;
|
||||
}
|
||||
|
||||
QString reason = tr("Renamed zone \"%1\" to \"%2\"").arg(oldZoneName, trimmedNewZoneName);
|
||||
|
||||
return modifyTree(reason, [&oldZoneName, &trimmedNewZoneName](DecklistNodeTree *tree) {
|
||||
return tree->renameCustomZone(oldZoneName, trimmedNewZoneName);
|
||||
});
|
||||
}
|
||||
|
||||
bool DeckStateManager::moveCustomZone(const QString &zoneName, const QString &newBoardZoneName)
|
||||
{
|
||||
const auto *tree = deckList->getTree();
|
||||
|
||||
// Locate the zone through the tree's own lookup, which walks every top-level
|
||||
// zone (not just the standard boards) and covers the same-board no-op below.
|
||||
const auto *zone = tree->findCustomZoneByName(zoneName);
|
||||
if (!zone) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Same-board moves are no-ops and must not pollute the history.
|
||||
const QString currentBoardName = zone->getParent() ? zone->getParent()->getName() : QString();
|
||||
if (currentBoardName == newBoardZoneName) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Zone names are deck-unique among zones created through this manager, so a
|
||||
// same-named zone on the target board can only come from an imported deck.
|
||||
// Refuse the move instead of silently stacking same-named zones.
|
||||
for (const auto *targetZone : tree->getCustomZones(newBoardZoneName)) {
|
||||
if (targetZone->getName() == zoneName) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
QString reason =
|
||||
tr("Moved zone \"%1\" to %2").arg(zoneName, InnerDecklistNode::visibleNameFromName(newBoardZoneName));
|
||||
|
||||
return modifyTree(reason, [&zoneName, &newBoardZoneName](DecklistNodeTree *tree) {
|
||||
return tree->moveCustomZone(zoneName, newBoardZoneName);
|
||||
});
|
||||
}
|
||||
|
||||
bool DeckStateManager::removeCustomZone(const QString &zoneName)
|
||||
{
|
||||
QString reason = tr("Deleted zone \"%1\"").arg(zoneName);
|
||||
|
||||
return modifyTree(reason, [&zoneName](DecklistNodeTree *tree) { return tree->removeCustomZone(zoneName); });
|
||||
}
|
||||
|
||||
QString DeckStateManager::validateNewZoneName(const QString &zoneName) const
|
||||
{
|
||||
if (zoneName.trimmed().isEmpty()) {
|
||||
return tr("Enter a zone name.");
|
||||
}
|
||||
|
||||
const QString trimmedZoneName = zoneName.trimmed();
|
||||
|
||||
// The standard zone names are reserved even before they exist.
|
||||
if (trimmedZoneName == DECK_ZONE_MAIN || trimmedZoneName == DECK_ZONE_SIDE ||
|
||||
trimmedZoneName == DECK_ZONE_MAYBEBOARD || trimmedZoneName == DECK_ZONE_TOKENS) {
|
||||
return tr("This name is reserved.");
|
||||
}
|
||||
|
||||
const auto *tree = deckList->getTree();
|
||||
|
||||
// Reuse the tree's own uniqueness contract: any top-level zone and any
|
||||
// custom zone on *every* board claims the name (hasZoneName also reserves
|
||||
// the standard board names, which we already rejected with a dedicated
|
||||
// message above). Scanning only the standard boards here would miss a
|
||||
// custom zone an imported deck carries under `tokens`.
|
||||
if (tree->hasZoneName(trimmedZoneName)) {
|
||||
return tr("A zone with this name already exists.");
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
bool DeckStateManager::offsetCountAtIndex(const QModelIndex &idx, int offset)
|
||||
{
|
||||
if (!idx.isValid()) {
|
||||
|
|
@ -367,6 +532,25 @@ void DeckStateManager::requestHistorySave(const QString &reason)
|
|||
historyManager->save(deckList->createMemento(reason));
|
||||
}
|
||||
|
||||
bool DeckStateManager::modifyTree(const QString &reason, const std::function<bool(DecklistNodeTree *)> &operation)
|
||||
{
|
||||
DeckListMemento memento = deckList->createMemento(reason);
|
||||
bool success = operation(deckList->getTree());
|
||||
|
||||
if (success) {
|
||||
historyManager->save(memento);
|
||||
deckListModel->rebuildTree();
|
||||
deckList->refreshDeckHash();
|
||||
emit deckListModel->deckHashChanged();
|
||||
// removeCustomZone can drop whole card sets the model never notified
|
||||
// about (rebuildTree emits no cardNodesChanged), so tell the consumers.
|
||||
emit deckListModel->cardNodesChanged();
|
||||
doCardModified();
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Handles updating state and emitting signals whenever the cards are modified
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
#include "deck_list_model.h"
|
||||
|
||||
#include <QSharedPointer>
|
||||
#include <functional>
|
||||
#include <libcockatrice/deck_list/deck_list.h>
|
||||
|
||||
class DeckListHistoryManager;
|
||||
|
|
@ -236,6 +237,68 @@ public:
|
|||
*/
|
||||
bool decrementCountAtIndex(const QModelIndex &idx);
|
||||
|
||||
/**
|
||||
* @brief Moves all copies of the card at the given index to the given zone.
|
||||
* No-ops if the index is invalid, not a card node, the card is a token, or the
|
||||
* card is already in the target zone.
|
||||
* Saves the operation to history if successful.
|
||||
*
|
||||
* @param idx The model index of the card to move
|
||||
* @param targetZoneName The zone to move the card to (board zone or custom zone name)
|
||||
* @return Whether the operation was successfully performed
|
||||
*/
|
||||
bool moveCardToZone(const QModelIndex &idx, const QString &targetZoneName);
|
||||
|
||||
/**
|
||||
* @brief Creates a new custom zone nested under a board zone.
|
||||
* Saves the operation to history if successful.
|
||||
*
|
||||
* @param boardZoneName The board zone to nest the custom zone under
|
||||
* @param zoneName The name of the new custom zone. Gets trimmed and must be
|
||||
* unique across the deck.
|
||||
* @return Whether the zone was created
|
||||
*/
|
||||
bool createCustomZone(const QString &boardZoneName, const QString &zoneName);
|
||||
|
||||
/**
|
||||
* @brief Renames a custom zone.
|
||||
* Saves the operation to history if successful.
|
||||
*
|
||||
* @param oldZoneName The current name of the custom zone
|
||||
* @param newZoneName The new name. Gets trimmed and must be unique across the deck.
|
||||
* @return Whether the rename succeeded
|
||||
*/
|
||||
bool renameCustomZone(const QString &oldZoneName, const QString &newZoneName);
|
||||
|
||||
/**
|
||||
* @brief Moves a custom zone (and its cards) to a different board zone.
|
||||
* Same-board moves succeed without creating a history entry.
|
||||
* Saves the operation to history if successful.
|
||||
*
|
||||
* @param zoneName The custom zone to move
|
||||
* @param newBoardZoneName The board zone to move the custom zone under
|
||||
* @return Whether the move succeeded
|
||||
*/
|
||||
bool moveCustomZone(const QString &zoneName, const QString &newBoardZoneName);
|
||||
|
||||
/**
|
||||
* @brief Removes a custom zone and all its cards.
|
||||
* Saves the operation to history if successful.
|
||||
*
|
||||
* @param zoneName The custom zone to remove
|
||||
* @return Whether the zone was removed
|
||||
*/
|
||||
bool removeCustomZone(const QString &zoneName);
|
||||
|
||||
/**
|
||||
* @brief Checks whether a candidate name is usable for a new custom zone.
|
||||
*
|
||||
* @param zoneName The candidate name
|
||||
* @return An empty string when the name is usable, otherwise a user-facing
|
||||
* error message describing the problem
|
||||
*/
|
||||
[[nodiscard]] QString validateNewZoneName(const QString &zoneName) const;
|
||||
|
||||
/**
|
||||
* Undoes n steps of the history, setting the decklist state and updating the current step in the historyManager.
|
||||
* @param steps Number of steps to undo.
|
||||
|
|
@ -257,6 +320,7 @@ public slots:
|
|||
|
||||
private:
|
||||
bool offsetCountAtIndex(const QModelIndex &idx, int offset);
|
||||
bool modifyTree(const QString &reason, const std::function<bool(DecklistNodeTree *)> &operation);
|
||||
void doCardModified();
|
||||
void doMetadataModified();
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,145 @@
|
|||
#include "deck_zone_dialog.h"
|
||||
|
||||
#include <QComboBox>
|
||||
#include <QDialogButtonBox>
|
||||
#include <QLabel>
|
||||
#include <QLineEdit>
|
||||
#include <QPushButton>
|
||||
#include <QVBoxLayout>
|
||||
#include <libcockatrice/deck_list/tree/inner_deck_list_node.h>
|
||||
#include <libcockatrice/utility/string_limits.h>
|
||||
|
||||
DeckZoneDialog::DeckZoneDialog(QWidget *parent,
|
||||
const QString &initialBoardName,
|
||||
const std::function<QString(const QString &)> &_nameValidator,
|
||||
bool _allowBoardSelection)
|
||||
: QDialog(parent), nameValidator(_nameValidator), allowBoardSelection(_allowBoardSelection)
|
||||
{
|
||||
nameLabel = new QLabel(this);
|
||||
nameEdit = new QLineEdit(this);
|
||||
nameEdit->setMaxLength(MAX_NAME_LENGTH);
|
||||
|
||||
errorLabel = new QLabel(this);
|
||||
errorLabel->hide();
|
||||
|
||||
boardLabel = new QLabel(this);
|
||||
boardCombo = new QComboBox(this);
|
||||
for (const QString &boardName : InnerDecklistNode::boardZoneNames()) {
|
||||
// Use the icon overload explicitly so `boardName` lands in the user data role
|
||||
// (visible text is applied below in retranslateUi). The two-argument form
|
||||
// addItem({}, boardName) would be ambiguous and resolve to the icon overload
|
||||
// with empty user data, yielding empty entries and an empty getBoardName().
|
||||
boardCombo->addItem({}, {}, boardName);
|
||||
}
|
||||
if (!initialBoardName.isEmpty()) {
|
||||
int idx = boardCombo->findData(initialBoardName);
|
||||
if (idx != -1) {
|
||||
boardCombo->setCurrentIndex(idx);
|
||||
}
|
||||
}
|
||||
|
||||
buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this);
|
||||
buttonBox->button(QDialogButtonBox::Ok)->setEnabled(false);
|
||||
connect(buttonBox, &QDialogButtonBox::accepted, this, &QDialog::accept);
|
||||
connect(buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject);
|
||||
|
||||
auto *layout = new QVBoxLayout(this);
|
||||
layout->addWidget(nameLabel);
|
||||
layout->addWidget(nameEdit);
|
||||
layout->addWidget(errorLabel);
|
||||
if (allowBoardSelection) {
|
||||
layout->addWidget(boardLabel);
|
||||
layout->addWidget(boardCombo);
|
||||
} else {
|
||||
boardLabel->hide();
|
||||
boardCombo->hide();
|
||||
}
|
||||
layout->addWidget(buttonBox);
|
||||
|
||||
retranslateUi();
|
||||
|
||||
connect(nameEdit, &QLineEdit::textChanged, this, [this] { validateName(); });
|
||||
validateName();
|
||||
|
||||
nameEdit->setFocus();
|
||||
}
|
||||
|
||||
QString DeckZoneDialog::getZoneName() const
|
||||
{
|
||||
return nameEdit->text().trimmed();
|
||||
}
|
||||
|
||||
QString DeckZoneDialog::getBoardName() const
|
||||
{
|
||||
return boardCombo->currentData().toString();
|
||||
}
|
||||
|
||||
void DeckZoneDialog::setZoneName(const QString &zoneName)
|
||||
{
|
||||
nameEdit->setText(zoneName);
|
||||
nameEdit->selectAll();
|
||||
}
|
||||
|
||||
void DeckZoneDialog::changeEvent(QEvent *event)
|
||||
{
|
||||
QDialog::changeEvent(event);
|
||||
|
||||
if (event->type() == QEvent::LanguageChange) {
|
||||
retranslateUi();
|
||||
}
|
||||
}
|
||||
|
||||
void DeckZoneDialog::retranslateUi()
|
||||
{
|
||||
setWindowTitle(allowBoardSelection ? tr("New zone") : tr("Rename zone"));
|
||||
|
||||
nameLabel->setText(tr("Zone &name:"));
|
||||
nameLabel->setBuddy(nameEdit);
|
||||
|
||||
boardLabel->setText(tr("&Parent zone:"));
|
||||
boardLabel->setBuddy(boardCombo);
|
||||
|
||||
for (int i = 0; i < boardCombo->count(); i++) {
|
||||
boardCombo->setItemText(i, InnerDecklistNode::visibleNameFromName(boardCombo->itemData(i).toString()));
|
||||
}
|
||||
}
|
||||
|
||||
void DeckZoneDialog::validateName()
|
||||
{
|
||||
const QString zoneName = nameEdit->text().trimmed();
|
||||
QString error;
|
||||
if (zoneName.isEmpty()) {
|
||||
error = tr("Enter a zone name.");
|
||||
} else if (nameValidator) {
|
||||
error = nameValidator(zoneName);
|
||||
}
|
||||
|
||||
errorLabel->setText(error);
|
||||
errorLabel->setVisible(!error.isEmpty());
|
||||
buttonBox->button(QDialogButtonBox::Ok)->setEnabled(error.isEmpty());
|
||||
}
|
||||
|
||||
QString DeckZoneDialog::promptForNewZone(QWidget *parent,
|
||||
const QString &initialBoardName,
|
||||
QString *chosenBoardName,
|
||||
const std::function<QString(const QString &)> &nameValidator)
|
||||
{
|
||||
DeckZoneDialog dialog(parent, initialBoardName, nameValidator);
|
||||
if (dialog.exec() != QDialog::Accepted) {
|
||||
return {};
|
||||
}
|
||||
|
||||
if (chosenBoardName) {
|
||||
*chosenBoardName = dialog.getBoardName();
|
||||
}
|
||||
return dialog.getZoneName();
|
||||
}
|
||||
|
||||
QString DeckZoneDialog::promptForRename(QWidget *parent,
|
||||
const QString ¤tZoneName,
|
||||
const std::function<QString(const QString &)> &nameValidator)
|
||||
{
|
||||
DeckZoneDialog dialog(parent, {}, nameValidator, false);
|
||||
dialog.setZoneName(currentZoneName);
|
||||
return dialog.exec() == QDialog::Accepted ? dialog.getZoneName() : QString();
|
||||
}
|
||||
123
cockatrice/src/interface/widgets/deck_editor/deck_zone_dialog.h
Normal file
123
cockatrice/src/interface/widgets/deck_editor/deck_zone_dialog.h
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
/**
|
||||
* @file deck_zone_dialog.h
|
||||
* @ingroup DeckEditorWidgets
|
||||
* @brief Shared dialog for creating custom deck zones.
|
||||
*/
|
||||
|
||||
#ifndef DECK_ZONE_DIALOG_H
|
||||
#define DECK_ZONE_DIALOG_H
|
||||
|
||||
#include <QDialog>
|
||||
#include <QEvent>
|
||||
#include <QString>
|
||||
#include <functional>
|
||||
|
||||
class QComboBox;
|
||||
class QDialogButtonBox;
|
||||
class QLabel;
|
||||
class QLineEdit;
|
||||
class QWidget;
|
||||
|
||||
/**
|
||||
* @brief Modal dialog asking for the name and parent zone of a new custom deck zone.
|
||||
*
|
||||
* Menus construct the dialog transiently around exec(), so validation state only
|
||||
* ever reflects the name currently typed.
|
||||
*/
|
||||
class DeckZoneDialog : public QDialog
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
/**
|
||||
* @brief Constructs the dialog and runs the initial validation pass.
|
||||
*
|
||||
* @param parent The parent widget for the dialog
|
||||
* @param initialBoardName The board zone to preselect in the combo. Unknown names
|
||||
* fall back to main.
|
||||
* @param _nameValidator Given the trimmed candidate name, returns an empty string
|
||||
* when it is usable, otherwise a user-facing error message. May be empty.
|
||||
* @param _allowBoardSelection When false the parent-zone combo is hidden and the
|
||||
* dialog acts as a rename prompt for an existing zone.
|
||||
*/
|
||||
explicit DeckZoneDialog(QWidget *parent = nullptr,
|
||||
const QString &initialBoardName = {},
|
||||
const std::function<QString(const QString &)> &_nameValidator = {},
|
||||
bool _allowBoardSelection = true);
|
||||
|
||||
/**
|
||||
* @brief The trimmed zone name entered by the user.
|
||||
*/
|
||||
[[nodiscard]] QString getZoneName() const;
|
||||
|
||||
/**
|
||||
* @brief The internal name of the board zone selected in the combo.
|
||||
*/
|
||||
[[nodiscard]] QString getBoardName() const;
|
||||
|
||||
/**
|
||||
* @brief Prefills the name field, e.g. with the current name when renaming.
|
||||
*
|
||||
* @param zoneName The text to put into the name field, selected for quick editing
|
||||
*/
|
||||
void setZoneName(const QString &zoneName);
|
||||
|
||||
/**
|
||||
* @brief Prompts the user for a new custom zone name and the board zone to nest it under.
|
||||
*
|
||||
* Convenience wrapper that runs DeckZoneDialog modally.
|
||||
*
|
||||
* @param parent The parent widget for the dialog
|
||||
* @param initialBoardName The board zone to preselect in the dialog. Unknown names fall
|
||||
* back to main.
|
||||
* @param chosenBoardName (out) The internal name of the board zone the user chose
|
||||
* @param nameValidator Optional validator forwarded to the dialog
|
||||
* @return The trimmed zone name, or an empty string if the user cancelled
|
||||
*/
|
||||
static QString promptForNewZone(QWidget *parent,
|
||||
const QString &initialBoardName,
|
||||
QString *chosenBoardName,
|
||||
const std::function<QString(const QString &)> &nameValidator = {});
|
||||
|
||||
/**
|
||||
* @brief Prompts the user for a new name for an existing custom zone.
|
||||
*
|
||||
* Same inline validation as promptForNewZone, but without a parent-zone picker.
|
||||
*
|
||||
* @param parent The parent widget for the dialog
|
||||
* @param currentZoneName The current name, prefilled for editing
|
||||
* @param nameValidator Validator deciding whether a candidate name is usable. It sees
|
||||
* the current name too, so callers wanting to allow unchanged names must
|
||||
* special-case that themselves.
|
||||
* @return The trimmed new name, or an empty string if the user cancelled
|
||||
*/
|
||||
static QString promptForRename(QWidget *parent,
|
||||
const QString ¤tZoneName,
|
||||
const std::function<QString(const QString &)> &nameValidator = {});
|
||||
|
||||
protected:
|
||||
void changeEvent(QEvent *event) override;
|
||||
|
||||
private:
|
||||
/**
|
||||
* @brief Sets every user-visible string. Runs on construction and on runtime
|
||||
* language changes.
|
||||
*/
|
||||
void retranslateUi();
|
||||
|
||||
/**
|
||||
* @brief Validates the current input, toggling Ok and the inline error label.
|
||||
*/
|
||||
void validateName();
|
||||
|
||||
QLabel *nameLabel;
|
||||
QLineEdit *nameEdit;
|
||||
QLabel *errorLabel;
|
||||
QLabel *boardLabel;
|
||||
QComboBox *boardCombo;
|
||||
QDialogButtonBox *buttonBox;
|
||||
std::function<QString(const QString &)> nameValidator;
|
||||
bool allowBoardSelection;
|
||||
};
|
||||
|
||||
#endif // DECK_ZONE_DIALOG_H
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
#include "../../../client/settings/cache_settings.h"
|
||||
#include "../interface/widgets/tabs/tab_room.h"
|
||||
#include "dlg_tournament_settings.h"
|
||||
|
||||
#include <QApplication>
|
||||
#include <QCheckBox>
|
||||
|
|
@ -14,6 +15,7 @@
|
|||
#include <QPushButton>
|
||||
#include <QRadioButton>
|
||||
#include <QSet>
|
||||
#include <QSignalBlocker>
|
||||
#include <QSpinBox>
|
||||
#include <libcockatrice/protocol/pb/serverinfo_game.pb.h>
|
||||
#include <libcockatrice/protocol/pending_command.h>
|
||||
|
|
@ -104,14 +106,29 @@ void DlgCreateGame::sharedCtor()
|
|||
|
||||
shareDecklistsOnLoadCheckBox = new QCheckBox(tr("Open decklists in lobby"));
|
||||
|
||||
tournamentCheckBox = new QCheckBox(tr("Tournament mode"));
|
||||
tournamentCheckBox->setToolTip(tr("All players submit a deck up front and rounds are paired automatically"));
|
||||
tournamentSettingsButton = new QPushButton(tr("Settings..."));
|
||||
tournamentSettingsButton->setEnabled(false);
|
||||
connect(tournamentCheckBox, &QCheckBox::toggled, tournamentSettingsButton, &QPushButton::setEnabled);
|
||||
connect(tournamentSettingsButton, &QPushButton::clicked, this, [this] {
|
||||
DlgTournamentSettings dlg(this);
|
||||
dlg.setCurrentGamesPerMatch(tournamentSettings.gamesPerMatch);
|
||||
if (dlg.exec() == QDialog::Accepted) {
|
||||
tournamentSettings = dlg.getResult();
|
||||
}
|
||||
});
|
||||
|
||||
createGameAsJudgeCheckBox = new QCheckBox(tr("Create game as judge"));
|
||||
|
||||
auto *gameSetupOptionsLayout = new QGridLayout;
|
||||
gameSetupOptionsLayout->addWidget(startingLifeTotalLabel, 0, 0);
|
||||
gameSetupOptionsLayout->addWidget(startingLifeTotalEdit, 0, 1);
|
||||
gameSetupOptionsLayout->addWidget(shareDecklistsOnLoadCheckBox, 1, 0);
|
||||
gameSetupOptionsLayout->addWidget(tournamentCheckBox, 2, 0);
|
||||
gameSetupOptionsLayout->addWidget(tournamentSettingsButton, 2, 1);
|
||||
if (room && room->getUserInfo()->user_level() & ServerInfo_User::IsJudge) {
|
||||
gameSetupOptionsLayout->addWidget(createGameAsJudgeCheckBox, 2, 0);
|
||||
gameSetupOptionsLayout->addWidget(createGameAsJudgeCheckBox, 3, 0);
|
||||
} else {
|
||||
createGameAsJudgeCheckBox->setChecked(false);
|
||||
createGameAsJudgeCheckBox->setHidden(true);
|
||||
|
|
@ -188,7 +205,7 @@ DlgCreateGame::DlgCreateGame(TabRoom *_room, const QMap<int, QString> &_gameType
|
|||
}
|
||||
|
||||
DlgCreateGame::DlgCreateGame(const ServerInfo_Game &gameInfo, const QMap<int, QString> &_gameTypes, QWidget *parent)
|
||||
: QDialog(parent), room(0), gameTypes(_gameTypes)
|
||||
: QDialog(parent), room(nullptr), gameTypes(_gameTypes)
|
||||
{
|
||||
sharedCtor();
|
||||
|
||||
|
|
@ -205,6 +222,8 @@ DlgCreateGame::DlgCreateGame(const ServerInfo_Game &gameInfo, const QMap<int, QS
|
|||
createGameAsSpectatorCheckBox->setEnabled(false);
|
||||
startingLifeTotalEdit->setEnabled(false);
|
||||
shareDecklistsOnLoadCheckBox->setEnabled(false);
|
||||
tournamentCheckBox->setEnabled(false);
|
||||
tournamentSettingsButton->setEnabled(false);
|
||||
|
||||
descriptionEdit->setText(QString::fromStdString(gameInfo.description()));
|
||||
maxPlayersEdit->setValue(gameInfo.max_players());
|
||||
|
|
@ -215,6 +234,10 @@ DlgCreateGame::DlgCreateGame(const ServerInfo_Game &gameInfo, const QMap<int, QS
|
|||
spectatorsCanTalkCheckBox->setChecked(gameInfo.spectators_can_chat());
|
||||
spectatorsSeeEverythingCheckBox->setChecked(gameInfo.spectators_omniscient());
|
||||
shareDecklistsOnLoadCheckBox->setChecked(gameInfo.share_decklists_on_load());
|
||||
{
|
||||
const QSignalBlocker blocker(tournamentCheckBox);
|
||||
tournamentCheckBox->setChecked(gameInfo.is_tournament());
|
||||
}
|
||||
|
||||
QSet<int> types;
|
||||
for (int i = 0; i < gameInfo.game_types_size(); ++i) {
|
||||
|
|
@ -252,6 +275,8 @@ void DlgCreateGame::actReset()
|
|||
|
||||
startingLifeTotalEdit->setValue(20);
|
||||
shareDecklistsOnLoadCheckBox->setChecked(false);
|
||||
tournamentCheckBox->setChecked(false);
|
||||
tournamentSettings = DlgTournamentSettingsResult{};
|
||||
createGameAsJudgeCheckBox->setChecked(false);
|
||||
|
||||
QMapIterator<int, QRadioButton *> gameTypeCheckBoxIterator(gameTypeCheckBoxes);
|
||||
|
|
@ -282,6 +307,10 @@ void DlgCreateGame::actOK()
|
|||
cmd.set_join_as_spectator(createGameAsSpectatorCheckBox->isChecked());
|
||||
cmd.set_starting_life_total(startingLifeTotalEdit->value());
|
||||
cmd.set_share_decklists_on_load(shareDecklistsOnLoadCheckBox->isChecked());
|
||||
cmd.set_is_tournament(tournamentCheckBox->isChecked());
|
||||
if (tournamentCheckBox->isChecked()) {
|
||||
cmd.mutable_tournament_settings()->set_games_per_match(tournamentSettings.gamesPerMatch);
|
||||
}
|
||||
|
||||
auto _gameTypes = QString();
|
||||
QMapIterator<int, QRadioButton *> gameTypeCheckBoxIterator(gameTypeCheckBoxes);
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@
|
|||
#ifndef DLG_CREATEGAME_H
|
||||
#define DLG_CREATEGAME_H
|
||||
|
||||
#include "dlg_tournament_settings.h"
|
||||
|
||||
#include <QDialog>
|
||||
#include <QMap>
|
||||
#include <libcockatrice/utility/macros.h>
|
||||
|
|
@ -48,6 +50,9 @@ private:
|
|||
QCheckBox *spectatorsAllowedCheckBox, *spectatorsNeedPasswordCheckBox, *spectatorsCanTalkCheckBox,
|
||||
*spectatorsSeeEverythingCheckBox, *createGameAsJudgeCheckBox, *createGameAsSpectatorCheckBox;
|
||||
QCheckBox *shareDecklistsOnLoadCheckBox;
|
||||
QCheckBox *tournamentCheckBox;
|
||||
QPushButton *tournamentSettingsButton;
|
||||
DlgTournamentSettingsResult tournamentSettings;
|
||||
QDialogButtonBox *buttonBox;
|
||||
QPushButton *clearButton;
|
||||
QCheckBox *rememberGameSettings;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,48 @@
|
|||
#include "dlg_tournament_settings.h"
|
||||
|
||||
#include <QDialogButtonBox>
|
||||
#include <QFormLayout>
|
||||
#include <QLabel>
|
||||
#include <QPushButton>
|
||||
#include <QSpinBox>
|
||||
|
||||
DlgTournamentSettings::DlgTournamentSettings(QWidget *parent) : QDialog(parent)
|
||||
{
|
||||
setWindowTitle(tr("Tournament Settings"));
|
||||
|
||||
auto *mainLayout = new QFormLayout(this);
|
||||
|
||||
gamesPerMatchSpin = new QSpinBox(this);
|
||||
gamesPerMatchSpin->setRange(1, 5);
|
||||
gamesPerMatchSpin->setValue(1);
|
||||
gamesPerMatchSpin->setToolTip(tr("Number of games per match (e.g., 3 for Best of 3)"));
|
||||
mainLayout->addRow(tr("Games per match:"), gamesPerMatchSpin);
|
||||
|
||||
QLabel *hintLabel = new QLabel(tr("Set to 3 for Best of 3, 5 for Best of 5, etc."), this);
|
||||
hintLabel->setStyleSheet("color: palette(placeholderText);");
|
||||
mainLayout->addRow(QString(), hintLabel);
|
||||
|
||||
buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this);
|
||||
connect(buttonBox, &QDialogButtonBox::accepted, this, &DlgTournamentSettings::actOK);
|
||||
connect(buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject);
|
||||
mainLayout->addRow(buttonBox);
|
||||
|
||||
setFixedHeight(sizeHint().height());
|
||||
}
|
||||
|
||||
DlgTournamentSettingsResult DlgTournamentSettings::getResult() const
|
||||
{
|
||||
DlgTournamentSettingsResult result;
|
||||
result.gamesPerMatch = gamesPerMatchSpin->value();
|
||||
return result;
|
||||
}
|
||||
|
||||
void DlgTournamentSettings::setCurrentGamesPerMatch(int n)
|
||||
{
|
||||
gamesPerMatchSpin->setValue(n);
|
||||
}
|
||||
|
||||
void DlgTournamentSettings::actOK()
|
||||
{
|
||||
accept();
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
#ifndef DLG_TOURNAMENT_SETTINGS_H
|
||||
#define DLG_TOURNAMENT_SETTINGS_H
|
||||
|
||||
#include <QDialog>
|
||||
|
||||
class QDialogButtonBox;
|
||||
class QSpinBox;
|
||||
|
||||
struct DlgTournamentSettingsResult
|
||||
{
|
||||
int gamesPerMatch = 1;
|
||||
};
|
||||
|
||||
class DlgTournamentSettings : public QDialog
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit DlgTournamentSettings(QWidget *parent = nullptr);
|
||||
DlgTournamentSettingsResult getResult() const;
|
||||
void setCurrentGamesPerMatch(int n);
|
||||
|
||||
private slots:
|
||||
void actOK();
|
||||
|
||||
private:
|
||||
QSpinBox *gamesPerMatchSpin;
|
||||
QDialogButtonBox *buttonBox;
|
||||
};
|
||||
|
||||
#endif
|
||||
232
cockatrice/src/interface/widgets/draft/tournament_widget.cpp
Normal file
232
cockatrice/src/interface/widgets/draft/tournament_widget.cpp
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
#include "tournament_widget.h"
|
||||
|
||||
#include <QHeaderView>
|
||||
#include <QLabel>
|
||||
#include <QPushButton>
|
||||
#include <QTableWidgetItem>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
namespace
|
||||
{
|
||||
const int PLAYER_COLUMN = 0;
|
||||
const int DECK_COLUMN = 1;
|
||||
const int WINS_COLUMN = 2;
|
||||
const int LOSSES_COLUMN = 3;
|
||||
const int DRAWS_COLUMN = 4;
|
||||
|
||||
const int PLAYER1_COLUMN = 0;
|
||||
const int SCORE_COLUMN = 1;
|
||||
const int STATUS_COLUMN = 2;
|
||||
const int PLAYER2_COLUMN = 3;
|
||||
} // namespace
|
||||
|
||||
TournamentWidget::TournamentWidget(QWidget *parent) : QWidget(parent)
|
||||
{
|
||||
auto *mainLayout = new QVBoxLayout(this);
|
||||
|
||||
statusLabel = new QLabel(this);
|
||||
statusLabel->setStyleSheet("font-weight: bold;");
|
||||
mainLayout->addWidget(statusLabel);
|
||||
|
||||
roundLabel = new QLabel(this);
|
||||
mainLayout->addWidget(roundLabel);
|
||||
|
||||
standingsTable = new QTableWidget(this);
|
||||
standingsTable->setColumnCount(5);
|
||||
standingsTable->horizontalHeader()->setStretchLastSection(true);
|
||||
standingsTable->setEditTriggers(QAbstractItemView::NoEditTriggers);
|
||||
standingsTable->setSelectionBehavior(QAbstractItemView::SelectRows);
|
||||
standingsTable->verticalHeader()->setVisible(false);
|
||||
mainLayout->addWidget(standingsTable);
|
||||
|
||||
pairingsTable = new QTableWidget(this);
|
||||
pairingsTable->setColumnCount(4);
|
||||
pairingsTable->horizontalHeader()->setStretchLastSection(true);
|
||||
pairingsTable->setEditTriggers(QAbstractItemView::NoEditTriggers);
|
||||
pairingsTable->setSelectionBehavior(QAbstractItemView::SelectRows);
|
||||
pairingsTable->verticalHeader()->setVisible(false);
|
||||
mainLayout->addWidget(pairingsTable);
|
||||
|
||||
openMatchButton = new QPushButton(this);
|
||||
openMatchButton->setEnabled(false);
|
||||
connect(openMatchButton, &QPushButton::clicked, this, [this]() { emit openMatchGameRequested(currentGameId); });
|
||||
mainLayout->addWidget(openMatchButton);
|
||||
|
||||
retranslateUi();
|
||||
}
|
||||
|
||||
void TournamentWidget::retranslateUi()
|
||||
{
|
||||
standingsTable->setHorizontalHeaderLabels({tr("Player"), tr("Deck"), tr("W"), tr("L"), tr("D")});
|
||||
pairingsTable->setHorizontalHeaderLabels({tr("Player 1"), tr("Score"), tr("Status"), tr("Player 2")});
|
||||
openMatchButton->setText(tr("Open match game"));
|
||||
|
||||
if (lastState.has_phase()) {
|
||||
updateTournamentState(lastState);
|
||||
} else {
|
||||
statusLabel->setText(tr("Tournament"));
|
||||
roundLabel->setVisible(true);
|
||||
roundLabel->setText(tr("Waiting for the tournament to start"));
|
||||
}
|
||||
}
|
||||
|
||||
void TournamentWidget::setLocalPlayerId(int playerId)
|
||||
{
|
||||
localPlayerId = playerId;
|
||||
}
|
||||
|
||||
void TournamentWidget::setIsSpectator(bool spectator)
|
||||
{
|
||||
isSpectator = spectator;
|
||||
}
|
||||
|
||||
QString TournamentWidget::getPlayerName(const Event_TournamentState &state, int playerId) const
|
||||
{
|
||||
for (int i = 0; i < state.players_size(); ++i) {
|
||||
if (state.players(i).player_id() == playerId) {
|
||||
return QString::fromStdString(state.players(i).player_name());
|
||||
}
|
||||
}
|
||||
return tr("Player %1").arg(playerId);
|
||||
}
|
||||
|
||||
void TournamentWidget::updateTournamentState(const Event_TournamentState &state)
|
||||
{
|
||||
lastState = state;
|
||||
|
||||
int gamesPerMatch = getGamesPerMatch(state);
|
||||
QString statusText;
|
||||
switch (state.phase()) {
|
||||
case Event_TournamentState::PHASE_DECK_BUILDING:
|
||||
statusText = gamesPerMatch > 1 ? tr("Tournament - Deck building (Best of %1)").arg(gamesPerMatch)
|
||||
: tr("Tournament - Deck building");
|
||||
break;
|
||||
case Event_TournamentState::PHASE_PLAYING:
|
||||
statusText = gamesPerMatch > 1 ? tr("Tournament - Playing (Best of %1)").arg(gamesPerMatch)
|
||||
: tr("Tournament - Playing");
|
||||
break;
|
||||
case Event_TournamentState::PHASE_FINISHED:
|
||||
statusText = gamesPerMatch > 1 ? tr("Tournament - Finished (Best of %1)").arg(gamesPerMatch)
|
||||
: tr("Tournament - Finished");
|
||||
break;
|
||||
default:
|
||||
statusText = tr("Tournament");
|
||||
break;
|
||||
}
|
||||
statusLabel->setText(statusText);
|
||||
|
||||
// The server does not report rounds before pairing starts; avoid showing "Round 0 of 0".
|
||||
bool roundStarted = state.current_round() > 0 || state.total_rounds() > 0;
|
||||
roundLabel->setVisible(roundStarted);
|
||||
if (roundStarted) {
|
||||
roundLabel->setText(tr("Round %1 of %2").arg(state.current_round()).arg(state.total_rounds()));
|
||||
}
|
||||
|
||||
rebuildTables(state);
|
||||
resolveCurrentGameId(state);
|
||||
updateOpenMatchButton();
|
||||
}
|
||||
|
||||
void TournamentWidget::updateOpenMatchButton()
|
||||
{
|
||||
bool playingPhase = lastState.phase() == Event_TournamentState::PHASE_PLAYING;
|
||||
openMatchButton->setVisible(playingPhase);
|
||||
if (!playingPhase) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasOwnLivePairing) {
|
||||
openMatchButton->setEnabled(true);
|
||||
openMatchButton->setText(tr("Open match game"));
|
||||
openMatchButton->setToolTip(tr("Switch to your current match game"));
|
||||
} else if (isSpectator && hasAnyLivePairing) {
|
||||
// Spectators have no pairing of their own but may watch any running match.
|
||||
openMatchButton->setEnabled(true);
|
||||
openMatchButton->setText(tr("Spectate live match"));
|
||||
openMatchButton->setToolTip(tr("Watch a match that is currently running"));
|
||||
} else {
|
||||
openMatchButton->setEnabled(false);
|
||||
openMatchButton->setText(tr("Open match game"));
|
||||
openMatchButton->setToolTip(tr("No match game is running for you right now"));
|
||||
}
|
||||
}
|
||||
|
||||
void TournamentWidget::rebuildTables(const Event_TournamentState &state)
|
||||
{
|
||||
standingsTable->setRowCount(state.players_size());
|
||||
for (int i = 0; i < state.players_size(); ++i) {
|
||||
const auto &player = state.players(i);
|
||||
|
||||
auto *nameItem = new QTableWidgetItem(QString::fromStdString(player.player_name()));
|
||||
nameItem->setToolTip(QString::fromStdString(player.player_name()));
|
||||
standingsTable->setItem(i, PLAYER_COLUMN, nameItem);
|
||||
|
||||
auto *deckItem = new QTableWidgetItem(player.deck_submitted() ? tr("Submitted") : tr("Pending"));
|
||||
deckItem->setToolTip(player.deck_submitted() ? tr("Deck submitted") : tr("Still choosing a deck"));
|
||||
standingsTable->setItem(i, DECK_COLUMN, deckItem);
|
||||
|
||||
standingsTable->setItem(i, WINS_COLUMN, new QTableWidgetItem(QString::number(player.wins())));
|
||||
standingsTable->setItem(i, LOSSES_COLUMN, new QTableWidgetItem(QString::number(player.losses())));
|
||||
standingsTable->setItem(i, DRAWS_COLUMN, new QTableWidgetItem(QString::number(player.draws())));
|
||||
}
|
||||
|
||||
pairingsTable->setRowCount(state.pairings_size());
|
||||
for (int i = 0; i < state.pairings_size(); ++i) {
|
||||
const auto &pairing = state.pairings(i);
|
||||
|
||||
QString name1 = getPlayerName(state, pairing.player1_id());
|
||||
QString name2 = pairing.player2_id() == -1 ? tr("BYE") : getPlayerName(state, pairing.player2_id());
|
||||
|
||||
QString scoreStr;
|
||||
if (getGamesPerMatch(state) > 1 && pairing.player2_id() != -1) {
|
||||
scoreStr = tr("%1 - %2").arg(pairing.player1_match_wins()).arg(pairing.player2_match_wins());
|
||||
}
|
||||
|
||||
QString statusStr;
|
||||
if (pairing.player2_id() == -1) {
|
||||
statusStr = tr("BYE");
|
||||
} else if (pairing.winner_id() != -1) {
|
||||
statusStr = tr("Finished");
|
||||
} else {
|
||||
statusStr = tr("vs");
|
||||
}
|
||||
|
||||
pairingsTable->setItem(i, PLAYER1_COLUMN, new QTableWidgetItem(name1));
|
||||
pairingsTable->setItem(i, SCORE_COLUMN, new QTableWidgetItem(scoreStr));
|
||||
pairingsTable->setItem(i, STATUS_COLUMN, new QTableWidgetItem(statusStr));
|
||||
pairingsTable->setItem(i, PLAYER2_COLUMN, new QTableWidgetItem(name2));
|
||||
}
|
||||
}
|
||||
|
||||
int TournamentWidget::getGamesPerMatch(const Event_TournamentState &state) const
|
||||
{
|
||||
return state.has_settings() ? state.settings().games_per_match() : 1;
|
||||
}
|
||||
|
||||
void TournamentWidget::resolveCurrentGameId(const Event_TournamentState &state)
|
||||
{
|
||||
currentGameId = -1;
|
||||
hasOwnLivePairing = false;
|
||||
hasAnyLivePairing = false;
|
||||
|
||||
for (int i = 0; i < state.pairings_size(); ++i) {
|
||||
const auto &pairing = state.pairings(i);
|
||||
if (pairing.game_id() == -1) {
|
||||
continue;
|
||||
}
|
||||
if (!hasOwnLivePairing && localPlayerId != -1 &&
|
||||
(pairing.player1_id() == localPlayerId || pairing.player2_id() == localPlayerId)) {
|
||||
currentGameId = pairing.game_id();
|
||||
hasOwnLivePairing = true;
|
||||
}
|
||||
if (!hasAnyLivePairing) {
|
||||
hasAnyLivePairing = true;
|
||||
if (!hasOwnLivePairing) {
|
||||
currentGameId = pairing.game_id();
|
||||
}
|
||||
}
|
||||
if (hasOwnLivePairing && hasAnyLivePairing) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
58
cockatrice/src/interface/widgets/draft/tournament_widget.h
Normal file
58
cockatrice/src/interface/widgets/draft/tournament_widget.h
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
#ifndef TOURNAMENT_WIDGET_H
|
||||
#define TOURNAMENT_WIDGET_H
|
||||
|
||||
#include <QWidget>
|
||||
#include <libcockatrice/protocol/pb/event_tournament_state.pb.h>
|
||||
|
||||
class QLabel;
|
||||
class QPushButton;
|
||||
class QTableWidget;
|
||||
class QTableWidgetItem;
|
||||
|
||||
/**
|
||||
* @class TournamentWidget
|
||||
* @ingroup GameViews
|
||||
* @brief Displays live standings and pairings for a tournament game.
|
||||
*
|
||||
* Updated exclusively via updateTournamentState(); the widget holds no
|
||||
* logic of its own beyond mapping protocol state to table rows. Navigation
|
||||
* decisions (which pairing belongs to the local player) are resolved here so
|
||||
* callers only need to react to openMatchGameRequested().
|
||||
*/
|
||||
class TournamentWidget : public QWidget
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit TournamentWidget(QWidget *parent = nullptr);
|
||||
|
||||
void updateTournamentState(const Event_TournamentState &state);
|
||||
void setLocalPlayerId(int playerId);
|
||||
void setIsSpectator(bool spectator);
|
||||
void retranslateUi();
|
||||
|
||||
signals:
|
||||
/*! Emitted when the user asks to open the match game of their current pairing. */
|
||||
void openMatchGameRequested(int gameId);
|
||||
|
||||
private:
|
||||
void rebuildTables(const Event_TournamentState &state);
|
||||
void resolveCurrentGameId(const Event_TournamentState &state);
|
||||
void updateOpenMatchButton();
|
||||
[[nodiscard]] QString getPlayerName(const Event_TournamentState &state, int playerId) const;
|
||||
[[nodiscard]] int getGamesPerMatch(const Event_TournamentState &state) const;
|
||||
|
||||
QLabel *statusLabel;
|
||||
QLabel *roundLabel;
|
||||
QTableWidget *standingsTable;
|
||||
QTableWidget *pairingsTable;
|
||||
QPushButton *openMatchButton;
|
||||
|
||||
int localPlayerId = -1;
|
||||
bool isSpectator = false;
|
||||
int currentGameId = -1;
|
||||
bool hasOwnLivePairing = false;
|
||||
bool hasAnyLivePairing = false;
|
||||
Event_TournamentState lastState;
|
||||
};
|
||||
|
||||
#endif // TOURNAMENT_WIDGET_H
|
||||
|
|
@ -33,6 +33,7 @@
|
|||
#include "card_database_display_model.h"
|
||||
#include "card_database_model.h"
|
||||
#include "tab_supervisor.h"
|
||||
#include "tournament_tab_game_extension.h"
|
||||
|
||||
#include <QAction>
|
||||
#include <QApplication>
|
||||
|
|
@ -134,6 +135,10 @@ TabGame::TabGame(TabSupervisor *_tabSupervisor,
|
|||
createMenuItems();
|
||||
createViewMenuItems();
|
||||
|
||||
if (game->getGameMetaInfo()->isTournament()) {
|
||||
tournamentExtension = new TournamentTabGameExtension(this);
|
||||
}
|
||||
|
||||
connectToGameState();
|
||||
connectToPlayerManager();
|
||||
connectToGameEventHandler();
|
||||
|
|
@ -150,6 +155,10 @@ TabGame::TabGame(TabSupervisor *_tabSupervisor,
|
|||
gameTypes.append(game->getGameMetaInfo()->findRoomGameType(i));
|
||||
}
|
||||
|
||||
if (tournamentExtension) {
|
||||
tournamentExtension->initializeTournamentMode();
|
||||
}
|
||||
|
||||
QTimer::singleShot(0, this, &TabGame::loadLayout);
|
||||
}
|
||||
|
||||
|
|
@ -400,6 +409,10 @@ void TabGame::retranslateUi()
|
|||
}
|
||||
|
||||
scene->retranslateUi();
|
||||
|
||||
if (tournamentExtension) {
|
||||
tournamentExtension->retranslateUi();
|
||||
}
|
||||
}
|
||||
|
||||
void TabGame::refreshShortcuts()
|
||||
|
|
@ -928,8 +941,19 @@ void TabGame::stopGame()
|
|||
}
|
||||
}
|
||||
|
||||
bool TabGame::switchToGameTab(int gameId)
|
||||
{
|
||||
return tabSupervisor->switchToGameTabIfAlreadyExists(gameId);
|
||||
}
|
||||
|
||||
void TabGame::closeGame()
|
||||
{
|
||||
int parentId = game->getGameMetaInfo()->parentGameId();
|
||||
if (parentId >= 0 && switchToGameTab(parentId)) {
|
||||
close();
|
||||
return;
|
||||
}
|
||||
|
||||
gameMenu->clear();
|
||||
gameMenu->addAction(aLeaveGame);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,11 +19,13 @@
|
|||
#include <QCompleter>
|
||||
#include <QLoggingCategory>
|
||||
#include <QMap>
|
||||
#include <QPointer>
|
||||
#include <QStringListModel>
|
||||
|
||||
class CardMenu;
|
||||
class ServerInfo_PlayerProperties;
|
||||
class TabbedDeckViewContainer;
|
||||
class TournamentTabGameExtension;
|
||||
inline Q_LOGGING_CATEGORY(TabGameLog, "tab_game");
|
||||
|
||||
class UserListProxy;
|
||||
|
|
@ -92,6 +94,8 @@ private:
|
|||
QList<QAction *> phaseActions;
|
||||
QAction *aCardMenu;
|
||||
|
||||
QPointer<TournamentTabGameExtension> tournamentExtension;
|
||||
|
||||
/**
|
||||
* @brief The actions associated with managing a QDockWidget
|
||||
*/
|
||||
|
|
@ -189,6 +193,8 @@ public:
|
|||
void connectToGameEventHandler();
|
||||
void connectMessageLogToGameEventHandler();
|
||||
void connectPlayerListToGameEventHandler();
|
||||
/*! Brings the tab of the given game to front if it is open in this session. */
|
||||
bool switchToGameTab(int gameId);
|
||||
TabGame(TabSupervisor *_tabSupervisor, GameReplay *replay);
|
||||
~TabGame() override;
|
||||
void retranslateUi() override;
|
||||
|
|
@ -202,6 +208,19 @@ public:
|
|||
return game;
|
||||
}
|
||||
|
||||
[[nodiscard]] QStackedWidget *getMainWidget() const
|
||||
{
|
||||
return mainWidget;
|
||||
}
|
||||
[[nodiscard]] QVBoxLayout *getDeckViewContainerLayout() const
|
||||
{
|
||||
return deckViewContainerLayout;
|
||||
}
|
||||
[[nodiscard]] QWidget *getDeckViewContainerWidget() const
|
||||
{
|
||||
return deckViewContainerWidget;
|
||||
}
|
||||
|
||||
public slots:
|
||||
void viewCardInfo(const CardRef &cardRef = {}) const;
|
||||
void resetChatAndPhase();
|
||||
|
|
|
|||
|
|
@ -116,9 +116,10 @@ void CloseButton::paintEvent(QPaintEvent * /*event*/)
|
|||
}
|
||||
|
||||
TabSupervisor::TabSupervisor(AbstractClient *_client, QMenu *tabsMenu, QWidget *parent)
|
||||
: QTabWidget(parent), userInfo(nullptr), client(_client), tabsMenu(tabsMenu), tabVisualDeckStorage(nullptr),
|
||||
tabServer(nullptr), tabAccount(nullptr), tabDeckStorage(nullptr), tabReplays(nullptr), tabAdmin(nullptr),
|
||||
tabLog(nullptr), tabReport(nullptr), tabModeration(nullptr), isLocalGame(false)
|
||||
: QTabWidget(parent), userInfo(nullptr), client(_client), tabsMenu(tabsMenu), tabHome(nullptr),
|
||||
tabVisualDeckStorage(nullptr), tabServer(nullptr), tabAccount(nullptr), tabDeckStorage(nullptr),
|
||||
tabReplays(nullptr), tabAdmin(nullptr), tabCardArtRules(nullptr), tabLog(nullptr), tabReport(nullptr),
|
||||
tabModeration(nullptr), isLocalGame(false)
|
||||
{
|
||||
setElideMode(Qt::ElideRight);
|
||||
setMovable(true);
|
||||
|
|
@ -245,6 +246,7 @@ void TabSupervisor::retranslateUi()
|
|||
aTabLog->setText(tr("Logs"));
|
||||
aTabReport->setText(tr("Report Queue"));
|
||||
aTabModeration->setText(tr("Moderation"));
|
||||
aTabCardArtRules->setText(tr("Card Art Rules"));
|
||||
|
||||
// tabs
|
||||
QList<Tab *> tabs;
|
||||
|
|
@ -256,6 +258,7 @@ void TabSupervisor::retranslateUi()
|
|||
tabs.append(tabLog);
|
||||
tabs.append(tabReport);
|
||||
tabs.append(tabModeration);
|
||||
tabs.append(tabCardArtRules);
|
||||
QMapIterator<int, TabRoom *> roomIterator(roomTabs);
|
||||
while (roomIterator.hasNext()) {
|
||||
tabs.append(roomIterator.next().value());
|
||||
|
|
@ -520,7 +523,9 @@ void TabSupervisor::start(const ServerInfo_User &_userInfo)
|
|||
if (SettingsCache::instance().tabs().getTabModerationOpen()) {
|
||||
openTabModeration();
|
||||
}
|
||||
openTabCardArtRules();
|
||||
if (SettingsCache::instance().tabs().getTabCardArtRulesOpen()) {
|
||||
openTabCardArtRules();
|
||||
}
|
||||
}
|
||||
|
||||
retranslateUi();
|
||||
|
|
@ -582,6 +587,9 @@ void TabSupervisor::stop()
|
|||
if (tabModeration) {
|
||||
tabModeration->close();
|
||||
}
|
||||
if (tabCardArtRules) {
|
||||
tabCardArtRules->close();
|
||||
}
|
||||
}
|
||||
|
||||
QList<Tab *> tabsToDelete;
|
||||
|
|
@ -775,6 +783,7 @@ void TabSupervisor::openTabAdmin()
|
|||
|
||||
void TabSupervisor::actTabCardArtRules(bool checked)
|
||||
{
|
||||
SettingsCache::instance().tabs().setTabCardArtRulesOpen(checked);
|
||||
if (checked && !tabCardArtRules) {
|
||||
openTabCardArtRules();
|
||||
setCurrentWidget(tabCardArtRules);
|
||||
|
|
@ -1007,10 +1016,20 @@ void TabSupervisor::replayLeft(TabGame *tab)
|
|||
}
|
||||
|
||||
void TabSupervisor::joinReportGame(const int gameId, const int roomId)
|
||||
{
|
||||
startSpectatorJoin(gameId, roomId, tr("Report joins are only available on a remote server."));
|
||||
}
|
||||
|
||||
void TabSupervisor::spectatorJoinGame(const int gameId, const int roomId)
|
||||
{
|
||||
startSpectatorJoin(gameId, roomId, tr("Spectating is only available on a remote server."));
|
||||
}
|
||||
|
||||
void TabSupervisor::startSpectatorJoin(const int gameId, const int roomId, const QString &unavailableMessage)
|
||||
{
|
||||
auto *remoteClient = qobject_cast<RemoteClient *>(client);
|
||||
if (!remoteClient) {
|
||||
actShowPopup(tr("Report joins are only available on a remote server."));
|
||||
actShowPopup(unavailableMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -92,6 +92,8 @@ public:
|
|||
};
|
||||
|
||||
private:
|
||||
void startSpectatorJoin(int gameId, int roomId, const QString &unavailableMessage);
|
||||
|
||||
ServerInfo_User *userInfo;
|
||||
AbstractClient *client;
|
||||
UserListManager *userListManager;
|
||||
|
|
@ -189,6 +191,7 @@ public slots:
|
|||
TabEdhRec *addEdhrecTab(const CardInfoPtr &cardToQuery, bool isCommander = false);
|
||||
void openReplay(GameReplay *replay);
|
||||
void joinReportGame(int gameId, int roomId);
|
||||
void spectatorJoinGame(int gameId, int roomId);
|
||||
void openTabModeration(const QString &userName = {});
|
||||
void switchToFirstAvailableNetworkTab();
|
||||
void maximizeMainWindow();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,167 @@
|
|||
#include "tournament_tab_game_extension.h"
|
||||
|
||||
#include "../../../game/game_event_handler.h"
|
||||
#include "../../widgets/draft/tournament_widget.h"
|
||||
#include "tab_game.h"
|
||||
#include "tab_supervisor.h"
|
||||
|
||||
#include <QHBoxLayout>
|
||||
#include <QLabel>
|
||||
#include <QPushButton>
|
||||
#include <QStackedWidget>
|
||||
#include <QVBoxLayout>
|
||||
#include <libcockatrice/protocol/pb/event_tournament_state.pb.h>
|
||||
|
||||
TournamentTabGameExtension::TournamentTabGameExtension(TabGame *parent) : QObject(parent), tabGame(parent)
|
||||
{
|
||||
tournamentOverviewWidget = new QWidget(parent);
|
||||
auto *overviewLayout = new QVBoxLayout(tournamentOverviewWidget);
|
||||
|
||||
auto *headerLayout = new QHBoxLayout;
|
||||
backToGameButton = new QPushButton(tournamentOverviewWidget);
|
||||
connect(backToGameButton, &QPushButton::clicked, this, &TournamentTabGameExtension::showDeckViewPage);
|
||||
headerLayout->addWidget(backToGameButton);
|
||||
headerLayout->addStretch();
|
||||
overviewLayout->addLayout(headerLayout);
|
||||
|
||||
tournamentWidget = new TournamentWidget(tournamentOverviewWidget);
|
||||
tournamentWidget->setLocalPlayerId(parent->getGame()->getPlayerManager()->getLocalPlayerId());
|
||||
tournamentWidget->setIsSpectator(parent->getGame()->getPlayerManager()->isSpectator());
|
||||
overviewLayout->addWidget(tournamentWidget);
|
||||
|
||||
parent->getMainWidget()->addWidget(tournamentOverviewWidget);
|
||||
|
||||
deckViewStatusLabel = new QLabel(parent->getDeckViewContainerWidget());
|
||||
deckViewStatusLabel->setStyleSheet("color: palette(placeholderText);");
|
||||
deckViewStatusLabel->setVisible(false);
|
||||
|
||||
standingsButton = new QPushButton(parent->getDeckViewContainerWidget());
|
||||
connect(standingsButton, &QPushButton::clicked, this, &TournamentTabGameExtension::showOverviewPage);
|
||||
standingsButton->setVisible(false);
|
||||
|
||||
connectSignals();
|
||||
}
|
||||
|
||||
void TournamentTabGameExtension::connectSignals()
|
||||
{
|
||||
auto *handler = tabGame->getGame()->getGameEventHandler();
|
||||
connect(handler, &GameEventHandler::tournamentStateChanged, this,
|
||||
&TournamentTabGameExtension::onTournamentStateChanged);
|
||||
connect(tournamentWidget, &TournamentWidget::openMatchGameRequested, this,
|
||||
&TournamentTabGameExtension::openMatchGame);
|
||||
}
|
||||
|
||||
void TournamentTabGameExtension::initializeTournamentMode()
|
||||
{
|
||||
if (!tabGame) {
|
||||
return;
|
||||
}
|
||||
|
||||
auto *deckLayout = tabGame->getDeckViewContainerLayout();
|
||||
int index = 0;
|
||||
deckLayout->insertWidget(index++, deckViewStatusLabel);
|
||||
deckLayout->insertWidget(index++, standingsButton);
|
||||
deckLayout->insertSpacing(index, 4);
|
||||
}
|
||||
|
||||
void TournamentTabGameExtension::retranslateUi()
|
||||
{
|
||||
backToGameButton->setText(tr("Back to game view"));
|
||||
standingsButton->setText(tr("Tournament standings"));
|
||||
tournamentWidget->retranslateUi();
|
||||
}
|
||||
|
||||
void TournamentTabGameExtension::updateDeckViewStrip(const Event_TournamentState &state)
|
||||
{
|
||||
int submitted = 0;
|
||||
for (int i = 0; i < state.players_size(); ++i) {
|
||||
if (state.players(i).deck_submitted()) {
|
||||
++submitted;
|
||||
}
|
||||
}
|
||||
|
||||
QString phaseText;
|
||||
switch (state.phase()) {
|
||||
case Event_TournamentState::PHASE_DECK_BUILDING:
|
||||
phaseText = tr("Deck building");
|
||||
break;
|
||||
case Event_TournamentState::PHASE_PLAYING:
|
||||
phaseText = tr("Round in progress");
|
||||
break;
|
||||
case Event_TournamentState::PHASE_FINISHED:
|
||||
phaseText = tr("Finished");
|
||||
break;
|
||||
default:
|
||||
phaseText = tr("Unknown phase");
|
||||
break;
|
||||
}
|
||||
|
||||
// The submission count only matters while players are still picking decks.
|
||||
QString text =
|
||||
state.phase() == Event_TournamentState::PHASE_DECK_BUILDING
|
||||
? tr("Tournament: %1 - %2/%3 decks submitted").arg(phaseText).arg(submitted).arg(state.players_size())
|
||||
: tr("Tournament: %1").arg(phaseText);
|
||||
deckViewStatusLabel->setText(text);
|
||||
deckViewStatusLabel->setVisible(true);
|
||||
}
|
||||
|
||||
void TournamentTabGameExtension::updateNavigationButtons(const Event_TournamentState &state)
|
||||
{
|
||||
bool showStandings =
|
||||
state.phase() == Event_TournamentState::PHASE_PLAYING || state.phase() == Event_TournamentState::PHASE_FINISHED;
|
||||
standingsButton->setVisible(showStandings);
|
||||
}
|
||||
|
||||
void TournamentTabGameExtension::onTournamentStateChanged(const Event_TournamentState &state)
|
||||
{
|
||||
if (!tabGame) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateDeckViewStrip(state);
|
||||
updateNavigationButtons(state);
|
||||
tournamentWidget->updateTournamentState(state);
|
||||
|
||||
// Switch to the standings page only when the phase itself changes, so score
|
||||
// updates never yank the user away while they are looking at their deck.
|
||||
// Instant switches are the app-wide baseline today; motion and sound cues
|
||||
// for this transition are deferred to the Game dressing phase.
|
||||
bool overviewPhase =
|
||||
state.phase() == Event_TournamentState::PHASE_PLAYING || state.phase() == Event_TournamentState::PHASE_FINISHED;
|
||||
if (overviewPhase && (!hasLastKnownPhase || state.phase() != lastKnownPhase)) {
|
||||
tabGame->getMainWidget()->setCurrentWidget(tournamentOverviewWidget);
|
||||
}
|
||||
lastKnownPhase = state.phase();
|
||||
hasLastKnownPhase = true;
|
||||
}
|
||||
|
||||
void TournamentTabGameExtension::showOverviewPage()
|
||||
{
|
||||
if (tabGame) {
|
||||
tabGame->getMainWidget()->setCurrentWidget(tournamentOverviewWidget);
|
||||
}
|
||||
}
|
||||
|
||||
void TournamentTabGameExtension::showDeckViewPage()
|
||||
{
|
||||
if (tabGame) {
|
||||
tabGame->getMainWidget()->setCurrentWidget(tabGame->getDeckViewContainerWidget());
|
||||
}
|
||||
}
|
||||
|
||||
void TournamentTabGameExtension::openMatchGame(int gameId)
|
||||
{
|
||||
if (!tabGame || gameId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Players are auto-joined into their match game by the server, so the tab
|
||||
// usually already exists and switching is enough. Otherwise (e.g. after
|
||||
// leaving the match) fall back to joining as spectator.
|
||||
if (tabGame->switchToGameTab(gameId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
int roomId = tabGame->getGame()->getGameMetaInfo()->proto().room_id();
|
||||
tabGame->getTabSupervisor()->spectatorJoinGame(gameId, roomId);
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
#ifndef TOURNAMENT_TAB_GAME_EXTENSION_H
|
||||
#define TOURNAMENT_TAB_GAME_EXTENSION_H
|
||||
|
||||
#include <QObject>
|
||||
#include <QPointer>
|
||||
#include <libcockatrice/protocol/pb/event_tournament_state.pb.h>
|
||||
|
||||
class QLabel;
|
||||
class QPushButton;
|
||||
class QWidget;
|
||||
class TournamentWidget;
|
||||
class TabGame;
|
||||
|
||||
/**
|
||||
* @class TournamentTabGameExtension
|
||||
* @ingroup GameViews
|
||||
* @brief Attaches tournament behavior to a TabGame hosting a tournament hub game.
|
||||
*
|
||||
* Owns the tournament overview page inside the tab's stacked main widget,
|
||||
* mirrors tournament state onto the always-visible deck-view status strip,
|
||||
* and routes user intents (open match game, change settings, switch pages)
|
||||
* between the two views.
|
||||
*/
|
||||
class TournamentTabGameExtension : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit TournamentTabGameExtension(TabGame *parent);
|
||||
|
||||
void initializeTournamentMode();
|
||||
void retranslateUi();
|
||||
|
||||
private slots:
|
||||
void onTournamentStateChanged(const Event_TournamentState &state);
|
||||
void showOverviewPage();
|
||||
void showDeckViewPage();
|
||||
void openMatchGame(int gameId);
|
||||
|
||||
private:
|
||||
void connectSignals();
|
||||
void updateDeckViewStrip(const Event_TournamentState &state);
|
||||
void updateNavigationButtons(const Event_TournamentState &state);
|
||||
|
||||
QPointer<TabGame> tabGame;
|
||||
TournamentWidget *tournamentWidget = nullptr;
|
||||
QWidget *tournamentOverviewWidget = nullptr;
|
||||
QPushButton *backToGameButton = nullptr;
|
||||
QPushButton *standingsButton = nullptr;
|
||||
QLabel *deckViewStatusLabel = nullptr;
|
||||
Event_TournamentState::TournamentPhase lastKnownPhase = Event_TournamentState::PHASE_DECK_BUILDING;
|
||||
bool hasLastKnownPhase = false;
|
||||
};
|
||||
|
||||
#endif // TOURNAMENT_TAB_GAME_EXTENSION_H
|
||||
|
|
@ -4,6 +4,7 @@
|
|||
#include "../../../../client/settings/shortcuts_settings.h"
|
||||
#include "../../cards/card_info_display_widget.h"
|
||||
#include "../../deck_editor/deck_state_manager.h"
|
||||
#include "../../deck_editor/deck_zone_dialog.h"
|
||||
#include "../../filters/filter_builder.h"
|
||||
#include "../../interface/pixel_map_generator.h"
|
||||
#include "../../interface/widgets/cards/card_info_frame_widget.h"
|
||||
|
|
@ -84,6 +85,7 @@ void TabDeckEditorVisual::createCentralFrame()
|
|||
connect(tabContainer, &TabDeckEditorVisualTabWidget::printingSelectorRequested, this,
|
||||
&TabDeckEditorVisual::showPrintingSelector);
|
||||
connect(tabContainer, &TabDeckEditorVisualTabWidget::cardInfoRequested, this, &TabDeckEditorVisual::updateCardInfo);
|
||||
tabContainer->visualDatabaseDisplay->setNewZoneCreator([this] { return createNewZone(); });
|
||||
|
||||
centralFrame->addWidget(tabContainer);
|
||||
setCentralWidget(centralWidget);
|
||||
|
|
@ -269,6 +271,19 @@ bool TabDeckEditorVisual::actSaveDeckAs()
|
|||
return result;
|
||||
}
|
||||
|
||||
/** @brief Prompts for and creates a new custom deck zone. Returns the name of the created zone. */
|
||||
QString TabDeckEditorVisual::createNewZone()
|
||||
{
|
||||
QString boardName;
|
||||
const QString zoneName = DeckZoneDialog::promptForNewZone(this, {}, &boardName, [this](const QString &candidate) {
|
||||
return deckStateManager->validateNewZoneName(candidate);
|
||||
});
|
||||
if (!zoneName.isEmpty()) {
|
||||
deckStateManager->createCustomZone(boardName, zoneName);
|
||||
}
|
||||
return zoneName;
|
||||
}
|
||||
|
||||
/** @brief Refreshes keyboard shortcuts for this tab from settings. */
|
||||
void TabDeckEditorVisual::refreshShortcuts()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -165,6 +165,12 @@ public slots:
|
|||
*/
|
||||
bool actSaveDeckAs() override;
|
||||
|
||||
/**
|
||||
* @brief Prompts for and creates a new custom deck zone.
|
||||
* @return The name of the created zone, or an empty string if creation was cancelled.
|
||||
*/
|
||||
QString createNewZone();
|
||||
|
||||
private:
|
||||
/**
|
||||
* @brief Sets the deck for this tab and selects the sub-tab to open on
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@
|
|||
#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 <utility>
|
||||
|
||||
|
|
@ -89,6 +90,19 @@ VisualDatabaseDisplayWidget::VisualDatabaseDisplayWidget(QWidget *parent,
|
|||
databaseView->setItemDelegate(nullptr);
|
||||
databaseView->setVisible(false);
|
||||
|
||||
// Without a deck model there is nothing to add cards to, so the zone menu stays hidden.
|
||||
if (deckListModel) {
|
||||
databaseView->setZoneMenuProvider(
|
||||
[deckListModel]() -> QList<QPair<QString, QStringList>> {
|
||||
QList<QPair<QString, QStringList>> result;
|
||||
for (const QString &boardName : InnerDecklistNode::boardZoneNames()) {
|
||||
result.append({boardName, deckListModel->getCustomZoneNames(boardName)});
|
||||
}
|
||||
return result;
|
||||
},
|
||||
[this] { return newZoneCreator ? newZoneCreator() : QString(); });
|
||||
}
|
||||
|
||||
searchEdit->setTreeView(databaseView);
|
||||
searchEdit->installEventFilter(databaseView->getKeySignals());
|
||||
|
||||
|
|
@ -195,6 +209,11 @@ void VisualDatabaseDisplayWidget::showEvent(QShowEvent *event)
|
|||
initializeFilters();
|
||||
}
|
||||
|
||||
void VisualDatabaseDisplayWidget::setNewZoneCreator(const std::function<QString()> &creator)
|
||||
{
|
||||
newZoneCreator = creator;
|
||||
}
|
||||
|
||||
void VisualDatabaseDisplayWidget::retranslateUi()
|
||||
{
|
||||
databaseLoadIndicator->setText(tr("Loading database ..."));
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@
|
|||
#include <QVBoxLayout>
|
||||
#include <QWheelEvent>
|
||||
#include <QWidget>
|
||||
#include <functional>
|
||||
#include <libcockatrice/models/database/card_database_model.h>
|
||||
#include <libcockatrice/models/deck_list/deck_list_model.h>
|
||||
#include <qscrollarea.h>
|
||||
|
|
@ -46,6 +47,12 @@ public:
|
|||
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.
|
||||
*/
|
||||
void setNewZoneCreator(const std::function<QString()> &creator);
|
||||
|
||||
CardDatabaseDisplayModel *getDatabaseDisplayModel()
|
||||
{
|
||||
return databaseDisplayModel;
|
||||
|
|
@ -106,6 +113,7 @@ private:
|
|||
VisualDatabaseDisplayFilterToolbarWidget *filterContainer;
|
||||
CardDatabaseDisplayModel *databaseDisplayModel;
|
||||
CardDatabaseView *databaseView;
|
||||
std::function<QString()> newZoneCreator;
|
||||
QList<ExactCard> *cards;
|
||||
QVBoxLayout *mainLayout;
|
||||
QScrollArea *scrollArea;
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@
|
|||
#include <libcockatrice/settings/interface_settings.h>
|
||||
#include <libcockatrice/settings/network_settings.h>
|
||||
#include <libcockatrice/settings/personal_settings.h>
|
||||
#include <libcockatrice/utility/cryptoutil.h>
|
||||
|
||||
QTranslator *translator, *qtTranslator;
|
||||
RNG_Abstract *rng;
|
||||
|
|
@ -292,7 +293,7 @@ int main(int argc, char *argv[])
|
|||
}
|
||||
}
|
||||
|
||||
rng = new RNG_SFMT;
|
||||
rng = new RNG_SFMT(CryptoUtil::randomUInt64());
|
||||
themeManager = new ThemeManager;
|
||||
soundEngine = new SoundEngine;
|
||||
|
||||
|
|
|
|||
|
|
@ -18,11 +18,11 @@ include=("cockatrice/src" \
|
|||
libcockatrice_* \
|
||||
"oracle/src" \
|
||||
"servatrice/src" \
|
||||
"cmake/pch" \
|
||||
"tests")
|
||||
exclude=("libcockatrice_rng/libcockatrice/rng/sfmt/" \
|
||||
"libcockatrice_utility/libcockatrice/utility/peglib.h" \
|
||||
"oracle/src/lzma/" \
|
||||
"oracle/src/qt-json/" \
|
||||
"oracle/src/zip/" \
|
||||
"servatrice/src/smtp/")
|
||||
exts=("cpp" "h" "proto")
|
||||
|
|
|
|||
|
|
@ -115,6 +115,25 @@ public:
|
|||
*/
|
||||
QList<const InnerDecklistNode *> getCustomZones(const QString &boardZoneName) const;
|
||||
|
||||
/**
|
||||
* @brief Checks whether a zone name is taken anywhere in the deck.
|
||||
*
|
||||
* Covers the standard board names and any top-level or nested custom zone.
|
||||
* @param zoneName The checked name.
|
||||
* @return true if the name is reserved or already in use.
|
||||
*/
|
||||
bool hasZoneName(const QString &zoneName) const;
|
||||
|
||||
/**
|
||||
* @brief Finds a custom zone anywhere in the deck by name.
|
||||
*
|
||||
* Walks the children of every top-level zone, so a zone nested under any
|
||||
* board (and not just the standard ones) is found.
|
||||
* @param zoneName The zone name to find.
|
||||
* @return The matching zone node, or nullptr if none exists.
|
||||
*/
|
||||
InnerDecklistNode *findCustomZoneByName(const QString &zoneName) const;
|
||||
|
||||
/**
|
||||
* @brief Applies a function to every card in the deck tree. This can modify the cards.
|
||||
*
|
||||
|
|
@ -128,8 +147,6 @@ private:
|
|||
InnerDecklistNode *getZoneObjFromName(const QString &zoneName) const;
|
||||
InnerDecklistNode *findBoardZone(const QString &boardZoneName) const;
|
||||
InnerDecklistNode *findOrCreateBoardZone(const QString &boardZoneName);
|
||||
InnerDecklistNode *findCustomZoneByName(const QString &zoneName) const;
|
||||
bool hasZoneName(const QString &zoneName) const;
|
||||
};
|
||||
|
||||
#endif // COCKATRICE_DECKLIST_NODE_TREE_H
|
||||
|
|
|
|||
|
|
@ -43,6 +43,13 @@ void InnerDecklistNode::setSortMethod(DeckSortMethod method)
|
|||
}
|
||||
}
|
||||
|
||||
const QList<QString> &InnerDecklistNode::boardZoneNames()
|
||||
{
|
||||
static const QList<QString> names = {QString(DECK_ZONE_MAIN), QString(DECK_ZONE_SIDE),
|
||||
QString(DECK_ZONE_MAYBEBOARD)};
|
||||
return names;
|
||||
}
|
||||
|
||||
QString InnerDecklistNode::getVisibleName() const
|
||||
{
|
||||
return visibleNameFromName(name);
|
||||
|
|
@ -87,6 +94,9 @@ AbstractDecklistNode *InnerDecklistNode::findCardChildByNameProviderIdAndNumber(
|
|||
|
||||
int InnerDecklistNode::height() const
|
||||
{
|
||||
if (isEmpty()) {
|
||||
return 1;
|
||||
}
|
||||
return at(0)->height() + 1;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,9 @@
|
|||
|
||||
#include "abstract_deck_list_node.h"
|
||||
|
||||
#include <QList>
|
||||
#include <QString>
|
||||
|
||||
/** @brief Constant for the "main" deck zone name. */
|
||||
#define DECK_ZONE_MAIN "main"
|
||||
/** @brief Constant for the "sideboard" zone name. */
|
||||
|
|
@ -118,6 +121,13 @@ public:
|
|||
*/
|
||||
static QString visibleNameFromName(const QString &_name);
|
||||
|
||||
/**
|
||||
* @brief The standard board zone names, in display order.
|
||||
*
|
||||
* @return main, side and maybeboard.
|
||||
*/
|
||||
static const QList<QString> &boardZoneNames();
|
||||
|
||||
/**
|
||||
* @brief Get this node’s display-friendly name.
|
||||
* @return Human-readable name (zone/group name).
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
#include <QRegularExpression>
|
||||
#include <QString>
|
||||
#include <functional>
|
||||
#include <libcockatrice/card/database/card_database_manager.h>
|
||||
#include <libcockatrice/utility/peglib.h>
|
||||
|
||||
static peg::parser search(R"(
|
||||
|
|
@ -19,7 +20,7 @@ SomewhatComplexQueryPart <- [(] QueryPartList [)] / QueryPart
|
|||
QueryPart <- NotQuery / SetQuery / RarityQuery / CMCQuery / FormatQuery / PowerQuery / ToughnessQuery / ColorQuery / TypeQuery / OracleQuery / FieldQuery / GenericQuery
|
||||
|
||||
NotQuery <- ('NOT' ws/'-') SomewhatComplexQueryPart
|
||||
SetQuery <- ('e'/'set') [:] FlexStringValue
|
||||
SetQuery <- ('e'/'set') SetExpression / ([:] FlexStringValue)
|
||||
OracleQuery <- 'o' [:] MatcherString
|
||||
|
||||
|
||||
|
|
@ -64,6 +65,8 @@ RegexMatcherString <- ('\\/' / !'/' .)+
|
|||
FlexStringValue <- CompactStringSet / String / [(] StringList [)]
|
||||
CompactStringSet <- StringListString ([,+] StringListString)+
|
||||
|
||||
SetExpression <- NumericOperator ws? String
|
||||
|
||||
NumericExpression <- NumericOperator ws? NumericValue
|
||||
NumericOperator <- [=:] / <[><!][=]?>
|
||||
NumericValue <- [0-9]+
|
||||
|
|
@ -101,12 +104,25 @@ static void setupParserRules()
|
|||
return [=](const CardData &x) -> bool { return matcher(x->getCardType()); };
|
||||
};
|
||||
search["SetQuery"] = [](const peg::SemanticValues &sv) -> Filter {
|
||||
auto matcher = std::any_cast<StringMatcher>(sv[0]);
|
||||
return [=](const CardData &x) -> bool {
|
||||
QList<QString> sets = x->getSets().keys();
|
||||
if (sv.choice() == 1) {
|
||||
auto matcher = std::any_cast<StringMatcher>(sv[0]);
|
||||
return [=](const CardData &x) -> bool {
|
||||
QList<QString> sets = x->getSets().keys();
|
||||
|
||||
auto matchesSet = [&matcher](const QString &set) { return matcher(set); };
|
||||
return std::any_of(sets.begin(), sets.end(), matchesSet);
|
||||
auto matchesSet = [&matcher](const QString &set) { return matcher(set); };
|
||||
return std::any_of(sets.begin(), sets.end(), matchesSet);
|
||||
};
|
||||
}
|
||||
|
||||
auto matcher = std::any_cast<NumberMatcher>(sv[0]);
|
||||
return [=](const CardData &x) -> bool {
|
||||
const auto &sets = x->getSets().values();
|
||||
auto matchesSet = [&](const PrintingInfo &printing) {
|
||||
return printing.getSet()->getEnabled() && matcher(printing.getSet()->getReleaseDate().toJulianDay());
|
||||
};
|
||||
return std::any_of(sets.begin(), sets.end(), [&](const auto &printings) {
|
||||
return std::any_of(printings.begin(), printings.end(), matchesSet);
|
||||
});
|
||||
};
|
||||
};
|
||||
search["Rarity"] = [](const peg::SemanticValues &sv) -> QString {
|
||||
|
|
@ -247,40 +263,54 @@ static void setupParserRules()
|
|||
return QString::fromStdString(std::string(sv.sv()));
|
||||
};
|
||||
|
||||
search["NumericExpression"] = [](const peg::SemanticValues &sv) -> NumberMatcher {
|
||||
const auto arg = std::any_cast<int>(sv[1]);
|
||||
const auto op = std::any_cast<QString>(sv[0]);
|
||||
search["NumericOperator"] = [](const peg::SemanticValues &sv) -> NumberComparer {
|
||||
const auto op = QString::fromStdString(std::string(sv.sv()));
|
||||
|
||||
if (op == ">") {
|
||||
return [=](const int s) { return s > arg; };
|
||||
return [=](const int s, const int arg) { return s > arg; };
|
||||
}
|
||||
if (op == ">=") {
|
||||
return [=](const int s) { return s >= arg; };
|
||||
return [=](const int s, const int arg) { return s >= arg; };
|
||||
}
|
||||
if (op == "<") {
|
||||
return [=](const int s) { return s < arg; };
|
||||
return [=](const int s, const int arg) { return s < arg; };
|
||||
}
|
||||
if (op == "<=") {
|
||||
return [=](const int s) { return s <= arg; };
|
||||
return [=](const int s, const int arg) { return s <= arg; };
|
||||
}
|
||||
if (op == "=") {
|
||||
return [=](const int s) { return s == arg; };
|
||||
return [=](const int s, const int arg) { return s == arg; };
|
||||
}
|
||||
if (op == ":") {
|
||||
return [=](const int s) { return s == arg; };
|
||||
return [=](const int s, const int arg) { return s == arg; };
|
||||
}
|
||||
if (op == "!=") {
|
||||
return [=](const int s) { return s != arg; };
|
||||
return [=](const int s, const int arg) { return s != arg; };
|
||||
}
|
||||
return [](int) { return false; };
|
||||
return [](int, int) { return false; };
|
||||
};
|
||||
|
||||
search["NumericValue"] = [](const peg::SemanticValues &sv) -> int {
|
||||
return QString::fromStdString(std::string(sv.sv())).toInt();
|
||||
};
|
||||
|
||||
search["NumericOperator"] = [](const peg::SemanticValues &sv) -> QString {
|
||||
return QString::fromStdString(std::string(sv.sv()));
|
||||
search["NumericExpression"] = [](const peg::SemanticValues &sv) -> NumberMatcher {
|
||||
const auto comparer = std::any_cast<NumberComparer>(sv[0]);
|
||||
const auto arg = std::any_cast<int>(sv[1]);
|
||||
return [=](int s) { return comparer(s, arg); };
|
||||
};
|
||||
|
||||
search["SetExpression"] = [](const peg::SemanticValues &sv) -> NumberMatcher {
|
||||
const auto comparer = std::any_cast<NumberComparer>(sv[0]);
|
||||
const auto setCode = std::any_cast<QString>(sv[1]);
|
||||
const auto allSets = CardDatabaseManager::getInstance()->getSetList();
|
||||
for (auto &set : allSets) {
|
||||
if (set->getShortName() == setCode) {
|
||||
const int releaseDate = set->getReleaseDate().toJulianDay();
|
||||
return [=](int s) { return comparer(s, releaseDate); };
|
||||
}
|
||||
}
|
||||
return [](int) { return false; };
|
||||
};
|
||||
|
||||
search["NormalMatcher"] = [](const peg::SemanticValues &sv) -> StringMatcher {
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ typedef CardInfoPtr CardData;
|
|||
typedef std::function<bool(const CardData &)> Filter;
|
||||
typedef std::function<bool(const QString &)> StringMatcher;
|
||||
typedef std::function<bool(int)> NumberMatcher;
|
||||
typedef std::function<bool(int, int)> NumberComparer;
|
||||
|
||||
namespace peg
|
||||
{
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ public:
|
|||
[[nodiscard]] virtual bool getTabLogOpen() const = 0;
|
||||
[[nodiscard]] virtual bool getTabReportOpen() const = 0;
|
||||
[[nodiscard]] virtual bool getTabModerationOpen() const = 0;
|
||||
[[nodiscard]] virtual bool getTabCardArtRulesOpen() const = 0;
|
||||
};
|
||||
|
||||
#endif // COCKATRICE_INTERFACE_TABS_SETTINGS_PROVIDER_H
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@ set(HEADERS deck_list_model.h deck_list_sort_filter_proxy_model.h)
|
|||
qt6_wrap_cpp(MOC_SOURCES ${HEADERS})
|
||||
|
||||
add_library(
|
||||
libcockatrice_models_deck_list STATIC ${MOC_SOURCES} deck_list_model.cpp deck_list_sort_filter_proxy_model.cpp
|
||||
libcockatrice_models_deck_list STATIC ${MOC_SOURCES} deck_list_model.cpp deck_list_model_custom_zones.cpp
|
||||
deck_list_sort_filter_proxy_model.cpp
|
||||
)
|
||||
|
||||
target_include_directories(libcockatrice_models_deck_list PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
|
||||
|
|
|
|||
|
|
@ -66,7 +66,8 @@ void DeckListModel::rebuildTree()
|
|||
for (int j = 0; j < currentZone->size(); j++) {
|
||||
auto *currentCard = dynamic_cast<DecklistCardNode *>(currentZone->at(j));
|
||||
|
||||
//! \todo Better sanity checking.
|
||||
// Non-card children are custom zones; they are mirrored in a single
|
||||
// pass below so each is mirrored exactly once.
|
||||
if (currentCard == nullptr) {
|
||||
continue;
|
||||
}
|
||||
|
|
@ -82,8 +83,19 @@ void DeckListModel::rebuildTree()
|
|||
|
||||
new DecklistModelCardNode(currentCard, groupNode);
|
||||
}
|
||||
|
||||
// Custom zones nested under the board zone are mirrored as-is, with their
|
||||
// cards as direct children (no further grouping).
|
||||
DeckListModelCustomZones::mirrorCustomZones(currentZone, node);
|
||||
}
|
||||
|
||||
// The shadow tree was built in deck file order. Apply the active sort while
|
||||
// the reset is still open so every consumer (tree view and visual editor)
|
||||
// sees the canonical order from the start. sortShadowTree emits no signals,
|
||||
// which is only valid before endResetModel closes the reset.
|
||||
root->setSortMethod(lastKnownColumn == 0 ? DeckSortMethod::ByNumber : DeckSortMethod::ByName);
|
||||
sortShadowTree(root, lastKnownOrder);
|
||||
|
||||
endResetModel();
|
||||
|
||||
refreshCardFormatLegalities();
|
||||
|
|
@ -154,6 +166,9 @@ QVariant DeckListModel::data(const QModelIndex &index, int role) const
|
|||
case DeckRoles::IsLegalRole:
|
||||
return true;
|
||||
|
||||
case DeckRoles::IsCustomZoneRole:
|
||||
return DeckListModelCustomZones::isCustomZone(group);
|
||||
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
|
|
@ -190,6 +205,10 @@ QVariant DeckListModel::data(const QModelIndex &index, int role) const
|
|||
return card->getFormatLegality();
|
||||
}
|
||||
|
||||
case DeckRoles::IsCustomZoneRole: {
|
||||
return false;
|
||||
}
|
||||
|
||||
default: {
|
||||
return {};
|
||||
}
|
||||
|
|
@ -327,6 +346,13 @@ bool DeckListModel::removeRows(int row, int count, const QModelIndex &parent)
|
|||
return false;
|
||||
}
|
||||
|
||||
// Custom zone rows are managed through the deck tree, never removed as model rows.
|
||||
for (int i = 0; i < count; i++) {
|
||||
if (DeckListModelCustomZones::isCustomZone(node->at(row + i))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
beginRemoveRows(parent, row, row + count - 1);
|
||||
for (int i = 0; i < count; i++) {
|
||||
AbstractDecklistNode *toDelete = node->takeAt(row);
|
||||
|
|
@ -337,7 +363,8 @@ bool DeckListModel::removeRows(int row, int count, const QModelIndex &parent)
|
|||
}
|
||||
endRemoveRows();
|
||||
|
||||
if (node->empty() && (node != root)) {
|
||||
// Empty criteria groups get pruned, but custom zones stay until explicitly deleted.
|
||||
if (node->empty() && (node != root) && !DeckListModelCustomZones::isCustomZone(node)) {
|
||||
removeRows(parent.row(), 1, parent.parent());
|
||||
} else {
|
||||
emitRecursiveUpdates(parent);
|
||||
|
|
@ -351,7 +378,8 @@ bool DeckListModel::removeRows(int row, int count, const QModelIndex &parent)
|
|||
|
||||
InnerDecklistNode *DeckListModel::createNodeIfNeeded(const QString &name, InnerDecklistNode *parent)
|
||||
{
|
||||
auto *newNode = dynamic_cast<InnerDecklistNode *>(parent->findChild(name));
|
||||
// Group lookups must not resolve a mirrored custom zone that shares the name.
|
||||
auto *newNode = DeckListModelCustomZones::findGroupChild(parent, name);
|
||||
if (!newNode) {
|
||||
beginInsertRows(nodeToIndex(parent), parent->size(), parent->size());
|
||||
newNode = new InnerDecklistNode(name, parent);
|
||||
|
|
@ -365,24 +393,44 @@ DecklistModelCardNode *DeckListModel::findCardNode(const QString &cardName,
|
|||
const QString &providerId,
|
||||
const QString &cardNumber) const
|
||||
{
|
||||
InnerDecklistNode *zoneNode = dynamic_cast<InnerDecklistNode *>(root->findChild(zoneName));
|
||||
if (!zoneNode) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
CardInfoPtr info = CardDatabaseManager::query()->getCardInfo(cardName);
|
||||
if (!info) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
QString groupCriteria = extractGroupCriteriaValue(info, activeGroupCriteria);
|
||||
InnerDecklistNode *groupNode = dynamic_cast<InnerDecklistNode *>(zoneNode->findChild(groupCriteria));
|
||||
if (!groupNode) {
|
||||
return nullptr;
|
||||
// 1. Board zone lookup: search the criteria groups, then the custom zones
|
||||
// nested under the board.
|
||||
if (auto *zoneNode = dynamic_cast<InnerDecklistNode *>(root->findChild(zoneName))) {
|
||||
QString groupCriteria = extractGroupCriteriaValue(info, activeGroupCriteria);
|
||||
if (auto *groupNode = DeckListModelCustomZones::findGroupChild(zoneNode, groupCriteria)) {
|
||||
if (auto *card = dynamic_cast<DecklistModelCardNode *>(
|
||||
groupNode->findCardChildByNameProviderIdAndNumber(cardName, providerId, cardNumber))) {
|
||||
return card;
|
||||
}
|
||||
}
|
||||
|
||||
for (auto *child : *zoneNode) {
|
||||
if (!DeckListModelCustomZones::isCustomZone(child)) {
|
||||
continue;
|
||||
}
|
||||
auto *customZone = dynamic_cast<InnerDecklistNode *>(child);
|
||||
if (!customZone) {
|
||||
continue;
|
||||
}
|
||||
if (auto *card = dynamic_cast<DecklistModelCardNode *>(
|
||||
customZone->findCardChildByNameProviderIdAndNumber(cardName, providerId, cardNumber))) {
|
||||
return card;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return dynamic_cast<DecklistModelCardNode *>(
|
||||
groupNode->findCardChildByNameProviderIdAndNumber(cardName, providerId, cardNumber));
|
||||
// 2. Custom zone lookup by name (custom zone names are deck-unique).
|
||||
if (auto *customZone = DeckListModelCustomZones::findSubZoneByName(root, zoneName)) {
|
||||
return dynamic_cast<DecklistModelCardNode *>(
|
||||
customZone->findCardChildByNameProviderIdAndNumber(cardName, providerId, cardNumber));
|
||||
}
|
||||
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
QModelIndex DeckListModel::findCard(const QString &cardName,
|
||||
|
|
@ -423,29 +471,95 @@ QModelIndex DeckListModel::addCard(const ExactCard &card, const QString &zoneNam
|
|||
return {};
|
||||
}
|
||||
|
||||
InnerDecklistNode *zoneNode = createNodeIfNeeded(zoneName, root);
|
||||
|
||||
CardInfoPtr cardInfo = card.getCardPtr();
|
||||
PrintingInfo printingInfo = card.getPrinting();
|
||||
|
||||
QString groupCriteria = extractGroupCriteriaValue(cardInfo, activeGroupCriteria);
|
||||
InnerDecklistNode *groupNode = createNodeIfNeeded(groupCriteria, zoneNode);
|
||||
InnerDecklistNode *cardParent = nullptr;
|
||||
|
||||
const QModelIndex parentIndex = nodeToIndex(groupNode);
|
||||
auto *cardNode = dynamic_cast<DecklistModelCardNode *>(groupNode->findCardChildByNameProviderIdAndNumber(
|
||||
auto *boardNode = dynamic_cast<InnerDecklistNode *>(root->findChild(zoneName));
|
||||
auto *customZoneNode = boardNode ? nullptr : DeckListModelCustomZones::findSubZoneByName(root, zoneName);
|
||||
|
||||
// Mirroring flattens nested deck sub-zones into shadow rows, so a shadow row
|
||||
// index is only usable as a deck-tree position while both sides have the same
|
||||
// direct-children shape. When they diverge, the card is appended to the deck
|
||||
// zone instead of being written out of range.
|
||||
InnerDecklistNode *deckCardParent = nullptr;
|
||||
bool customZoneNeedsAppend = false;
|
||||
|
||||
if (boardNode) {
|
||||
// Board zone: cards are grouped by the active criteria.
|
||||
QString groupCriteria = extractGroupCriteriaValue(cardInfo, activeGroupCriteria);
|
||||
cardParent = createNodeIfNeeded(groupCriteria, boardNode);
|
||||
} else if (customZoneNode) {
|
||||
// Custom zone: cards live flat inside the zone.
|
||||
cardParent = customZoneNode;
|
||||
auto *listRoot = deckList->getTree()->getRoot();
|
||||
for (int i = 0; i < listRoot->size(); ++i) {
|
||||
auto *boardZone = dynamic_cast<InnerDecklistNode *>(listRoot->at(i));
|
||||
if (!boardZone) {
|
||||
continue;
|
||||
}
|
||||
deckCardParent = dynamic_cast<InnerDecklistNode *>(boardZone->findChild(zoneName));
|
||||
if (deckCardParent) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// A deck custom zone holding nested sub-zones mirrors with flattened rows,
|
||||
// so a shadow row index does not map onto its direct children.
|
||||
if (deckCardParent) {
|
||||
for (int i = 0; i < deckCardParent->size(); ++i) {
|
||||
if (dynamic_cast<InnerDecklistNode *>(deckCardParent->at(i))) {
|
||||
customZoneNeedsAppend = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Not present in the shadow tree. The deck tree may still hold a custom
|
||||
// zone that has not been mirrored (callers can add a zone and then a
|
||||
// card without a rebuild). Check before falling back to creating a
|
||||
// top-level zone the deck does not actually have.
|
||||
auto *listRoot = deckList->getTree()->getRoot();
|
||||
bool hasDeckZone = false;
|
||||
for (int i = 0; i < listRoot->size(); ++i) {
|
||||
if (auto *boardZone = dynamic_cast<InnerDecklistNode *>(listRoot->at(i))) {
|
||||
// Only real zones count: a card sitting directly under the board
|
||||
// shares the name comparison but is not a zone, and treating it as
|
||||
// one would recurse forever without mirroring anything.
|
||||
if (dynamic_cast<InnerDecklistNode *>(boardZone->findChild(zoneName))) {
|
||||
hasDeckZone = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasDeckZone) {
|
||||
rebuildTree();
|
||||
return addCard(card, zoneName);
|
||||
}
|
||||
|
||||
// Unknown zone: create a top-level zone (legacy behavior).
|
||||
QString groupCriteria = extractGroupCriteriaValue(cardInfo, activeGroupCriteria);
|
||||
auto *newZone = createNodeIfNeeded(zoneName, root);
|
||||
cardParent = createNodeIfNeeded(groupCriteria, newZone);
|
||||
}
|
||||
|
||||
const QModelIndex parentIndex = nodeToIndex(cardParent);
|
||||
auto *cardNode = dynamic_cast<DecklistModelCardNode *>(cardParent->findCardChildByNameProviderIdAndNumber(
|
||||
card.getName(), printingInfo.getUuid(), printingInfo.getProperty("num")));
|
||||
const auto cardSetName = printingInfo.getSet().isNull() ? "" : printingInfo.getSet()->getCorrectedShortName();
|
||||
|
||||
bool cardNodeAdded = false;
|
||||
if (!cardNode) {
|
||||
// Determine the correct index
|
||||
int insertRow = findSortedInsertRow(groupNode, cardInfo);
|
||||
int insertRow = findSortedInsertRow(cardParent, cardInfo);
|
||||
int deckInsertRow = customZoneNeedsAppend ? -1 : insertRow;
|
||||
|
||||
auto *decklistCard = deckList->addCard(cardInfo->getName(), zoneName, insertRow, cardSetName,
|
||||
auto *decklistCard = deckList->addCard(cardInfo->getName(), zoneName, deckInsertRow, cardSetName,
|
||||
printingInfo.getProperty("num"), printingInfo.getProperty("uuid"));
|
||||
|
||||
beginInsertRows(parentIndex, insertRow, insertRow);
|
||||
cardNode = new DecklistModelCardNode(decklistCard, groupNode, insertRow);
|
||||
cardNode = new DecklistModelCardNode(decklistCard, cardParent, insertRow);
|
||||
endInsertRows();
|
||||
|
||||
cardNodeAdded = true;
|
||||
|
|
@ -576,21 +690,41 @@ QModelIndex DeckListModel::nodeToIndex(AbstractDecklistNode *node) const
|
|||
return createIndex(node->getParent()->indexOf(node), 0, node);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Sorts a freshly built shadow subtree without emitting model signals.
|
||||
*
|
||||
* Used by rebuildTree while the model reset is still open (emitting layout
|
||||
* changes during a reset is invalid). Reorders every node just like
|
||||
* sortHelper does, but ignores the movement mapping because there are no
|
||||
* persistent indices established yet.
|
||||
*/
|
||||
void DeckListModel::sortShadowTree(InnerDecklistNode *node, Qt::SortOrder order)
|
||||
{
|
||||
// The mapping is not needed: fresh shadow nodes have no persistent indices yet.
|
||||
(void)DeckListModelCustomZones::sortWithCustomZonesLast(root, node, order);
|
||||
|
||||
for (int i = node->size() - 1; i >= 0; --i) {
|
||||
if (auto *subNode = dynamic_cast<InnerDecklistNode *>(node->at(i))) {
|
||||
sortShadowTree(subNode, order);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void DeckListModel::sortHelper(InnerDecklistNode *node, Qt::SortOrder order)
|
||||
{
|
||||
// Sort children of node and save the information needed to
|
||||
// update the list of persistent indexes.
|
||||
QVector<QPair<int, int>> sortResult = node->sort(order);
|
||||
// Sort children (custom zones always sorted after groups within a board) and
|
||||
// use the movement mapping to update the list of persistent indices.
|
||||
const auto mapping = DeckListModelCustomZones::sortWithCustomZonesLast(root, node, order);
|
||||
|
||||
QModelIndexList from, to;
|
||||
int columns = columnCount();
|
||||
for (int i = sortResult.size() - 1; i >= 0; --i) {
|
||||
const int fromRow = sortResult[i].first;
|
||||
const int toRow = sortResult[i].second;
|
||||
AbstractDecklistNode *temp = node->at(toRow);
|
||||
for (const auto &move : mapping) {
|
||||
const int preSortRow = move.first;
|
||||
const int finalRow = move.second;
|
||||
AbstractDecklistNode *temp = node->at(finalRow);
|
||||
for (int j = 0; j < columns; ++j) {
|
||||
from << createIndex(fromRow, j, temp);
|
||||
to << createIndex(toRow, j, temp);
|
||||
from << createIndex(preSortRow, j, temp);
|
||||
to << createIndex(finalRow, j, temp);
|
||||
}
|
||||
}
|
||||
changePersistentIndexList(from, to);
|
||||
|
|
@ -704,6 +838,15 @@ QList<QString> DeckListModel::getZones() const
|
|||
return zones;
|
||||
}
|
||||
|
||||
QStringList DeckListModel::getCustomZoneNames(const QString &boardZoneName) const
|
||||
{
|
||||
QStringList zoneNames;
|
||||
for (const auto *customZone : deckList->getTree()->getCustomZones(boardZoneName)) {
|
||||
zoneNames.append(customZone->getName());
|
||||
}
|
||||
return zoneNames;
|
||||
}
|
||||
|
||||
static int maxAllowedForLegality(const FormatRules &format, const QString &legality)
|
||||
{
|
||||
for (const AllowedCount &c : format.allowedCounts) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
#ifndef DECKLISTMODEL_H
|
||||
#define DECKLISTMODEL_H
|
||||
|
||||
#include "deck_list_model_custom_zones.h"
|
||||
|
||||
#include <../../../../libcockatrice_deck_list/libcockatrice/deck_list/tree/abstract_deck_list_card_node.h>
|
||||
#include <../../../../libcockatrice_deck_list/libcockatrice/deck_list/tree/deck_list_card_node.h>
|
||||
#include <QAbstractItemModel>
|
||||
|
|
@ -30,7 +32,8 @@ enum
|
|||
{
|
||||
IsCardRole = Qt::UserRole + 1, /**< Indicates whether the item represents a card. */
|
||||
DepthRole, /**< Depth level within the deck's grouping hierarchy. */
|
||||
IsLegalRole /**< Whether the card is legal in the current deck format. */
|
||||
IsLegalRole, /**< Whether the card is legal in the current deck format. */
|
||||
IsCustomZoneRole /**< Whether the item represents a custom zone nested under a board zone. */
|
||||
};
|
||||
} // namespace DeckRoles
|
||||
|
||||
|
|
@ -391,6 +394,14 @@ public:
|
|||
*/
|
||||
[[nodiscard]] QList<QString> getZones() const;
|
||||
|
||||
/**
|
||||
* @brief Gets the names of the custom zones nested under the given board zone.
|
||||
*
|
||||
* @param boardZoneName The board zone to query (main/side/maybeboard)
|
||||
* @return The custom zone names, in deck order
|
||||
*/
|
||||
[[nodiscard]] QStringList getCustomZoneNames(const QString &boardZoneName) const;
|
||||
|
||||
private:
|
||||
QSharedPointer<DeckList> deckList; /**< Pointer to the decklist providing the underlying data. */
|
||||
InnerDecklistNode *root; /**< Root node of the model tree. */
|
||||
|
|
@ -427,6 +438,7 @@ private:
|
|||
void emitRecursiveUpdates(const QModelIndex &index);
|
||||
|
||||
void sortHelper(InnerDecklistNode *node, Qt::SortOrder order);
|
||||
void sortShadowTree(InnerDecklistNode *node, Qt::SortOrder order);
|
||||
|
||||
template <typename T> T getNode(const QModelIndex &index) const
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,152 @@
|
|||
#include "deck_list_model_custom_zones.h"
|
||||
|
||||
#include "deck_list_model.h"
|
||||
|
||||
#include <../../../../libcockatrice_deck_list/libcockatrice/deck_list/tree/deck_list_card_node.h>
|
||||
#include <QHash>
|
||||
#include <QVector>
|
||||
|
||||
namespace DeckListModelCustomZones
|
||||
{
|
||||
|
||||
bool isCustomZone(const AbstractDecklistNode *node)
|
||||
{
|
||||
return dynamic_cast<const DecklistModelSubZoneNode *>(node) != nullptr;
|
||||
}
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
/**
|
||||
* @brief Flattens every card under @p zone into @p shadowZone, preserving order.
|
||||
*
|
||||
* Custom zones mirror as a single row level: cards nested in sub-zones of any
|
||||
* depth are added as direct children of the mirrored zone so no card is left
|
||||
* without a model row.
|
||||
*/
|
||||
void flattenCards(const InnerDecklistNode *zone, InnerDecklistNode *shadowZone)
|
||||
{
|
||||
for (int k = 0; k < zone->size(); k++) {
|
||||
if (auto *zoneCard = dynamic_cast<DecklistCardNode *>(zone->at(k))) {
|
||||
new DecklistModelCardNode(zoneCard, shadowZone);
|
||||
} else if (auto *subZone = dynamic_cast<const InnerDecklistNode *>(zone->at(k))) {
|
||||
flattenCards(subZone, shadowZone);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void mirrorCustomZones(const InnerDecklistNode *deckBoardZone, InnerDecklistNode *shadowBoardZone)
|
||||
{
|
||||
for (int j = 0; j < deckBoardZone->size(); j++) {
|
||||
auto *customZone = dynamic_cast<const InnerDecklistNode *>(deckBoardZone->at(j));
|
||||
if (!customZone) {
|
||||
continue;
|
||||
}
|
||||
|
||||
auto *shadowZone = new DecklistModelSubZoneNode(customZone->getName(), shadowBoardZone);
|
||||
flattenCards(customZone, shadowZone);
|
||||
}
|
||||
}
|
||||
|
||||
InnerDecklistNode *findGroupChild(InnerDecklistNode *parent, const QString &name)
|
||||
{
|
||||
for (int i = 0; i < parent->size(); i++) {
|
||||
AbstractDecklistNode *child = parent->at(i);
|
||||
if (isCustomZone(child)) {
|
||||
continue;
|
||||
}
|
||||
auto *group = dynamic_cast<InnerDecklistNode *>(child);
|
||||
if (group && group->getName() == name) {
|
||||
return group;
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
DecklistModelSubZoneNode *findSubZoneByName(InnerDecklistNode *root, const QString &zoneName)
|
||||
{
|
||||
for (int i = 0; i < root->size(); i++) {
|
||||
auto *boardZone = dynamic_cast<InnerDecklistNode *>(root->at(i));
|
||||
if (!boardZone) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (int j = 0; j < boardZone->size(); j++) {
|
||||
auto *customZone = dynamic_cast<DecklistModelSubZoneNode *>(boardZone->at(j));
|
||||
if (customZone && customZone->getName() == zoneName) {
|
||||
return customZone;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
/**
|
||||
* @brief Sorts a node's children and returns the (preSortRow, finalRow) mapping.
|
||||
*/
|
||||
QList<QPair<int, int>> plainSort(InnerDecklistNode *node, Qt::SortOrder order)
|
||||
{
|
||||
const QVector<QPair<int, int>> sortResult = node->sort(order);
|
||||
|
||||
QList<QPair<int, int>> mapping;
|
||||
mapping.reserve(node->size());
|
||||
for (int i = 0; i < node->size(); ++i) {
|
||||
mapping.append({sortResult[i].first, i});
|
||||
}
|
||||
return mapping;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Sorts a board zone's children, then stably moves custom zones to the end.
|
||||
*
|
||||
* @return The (preSortRow, finalRow) mapping covering both the sort and the shift.
|
||||
*/
|
||||
QList<QPair<int, int>> boardSort(InnerDecklistNode *node, Qt::SortOrder order)
|
||||
{
|
||||
const QVector<QPair<int, int>> sortResult = node->sort(order);
|
||||
|
||||
QVector<AbstractDecklistNode *> groups;
|
||||
QVector<AbstractDecklistNode *> customZones;
|
||||
QHash<AbstractDecklistNode *, int> preSortRowOf;
|
||||
|
||||
groups.reserve(node->size());
|
||||
customZones.reserve(node->size());
|
||||
|
||||
for (int i = 0; i < node->size(); ++i) {
|
||||
AbstractDecklistNode *child = node->at(i);
|
||||
preSortRowOf.insert(child, sortResult[i].first);
|
||||
if (isCustomZone(child)) {
|
||||
customZones.append(child);
|
||||
} else {
|
||||
groups.append(child);
|
||||
}
|
||||
}
|
||||
|
||||
QVector<AbstractDecklistNode *> ordered = groups + customZones;
|
||||
for (int i = 0; i < ordered.size(); ++i) {
|
||||
node->replace(i, ordered[i]);
|
||||
}
|
||||
|
||||
QList<QPair<int, int>> mapping;
|
||||
mapping.reserve(ordered.size());
|
||||
for (int i = 0; i < ordered.size(); ++i) {
|
||||
mapping.append({preSortRowOf.value(ordered[i]), i});
|
||||
}
|
||||
return mapping;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
QList<QPair<int, int>> sortWithCustomZonesLast(InnerDecklistNode *root, InnerDecklistNode *node, Qt::SortOrder order)
|
||||
{
|
||||
const bool isBoardZone = (node != root) && (node->getParent() == root);
|
||||
return isBoardZone ? boardSort(node, order) : plainSort(node, order);
|
||||
}
|
||||
|
||||
} // namespace DeckListModelCustomZones
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
#ifndef DECK_LIST_MODEL_CUSTOM_ZONES_H
|
||||
#define DECK_LIST_MODEL_CUSTOM_ZONES_H
|
||||
|
||||
#include <../../../../libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.h>
|
||||
#include <QList>
|
||||
#include <QPair>
|
||||
#include <QtGlobal>
|
||||
|
||||
/**
|
||||
* @class DecklistModelSubZoneNode
|
||||
* @ingroup DeckModels
|
||||
* @brief Model node representing a custom zone nested under a board zone.
|
||||
*
|
||||
* Custom zones group cards by user-defined names (e.g. "Removal", "Utility")
|
||||
* inside a board zone. They are mirrored from the underlying deck tree so that
|
||||
* they can be told apart from criteria group nodes by type.
|
||||
*/
|
||||
class DecklistModelSubZoneNode : public InnerDecklistNode
|
||||
{
|
||||
public:
|
||||
using InnerDecklistNode::InnerDecklistNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* @namespace DeckListModelCustomZones
|
||||
* @ingroup DeckModels
|
||||
* @brief Tree-level helpers for the deck list model's custom-zone shadow nodes.
|
||||
*
|
||||
* The deck list model keeps a second "shadow" tree of InnerDecklistNode that
|
||||
* mirrors the canonical deck tree for grouping and sorting. Custom zones add a
|
||||
* layer of bookkeeping to that shadow tree: they must be mirrored alongside
|
||||
* criteria groups, always sort after the groups within a board, and be
|
||||
* resolvable by deck-unique name.
|
||||
*
|
||||
* This namespace centralizes every "what is / where is a custom zone" decision
|
||||
* so the model itself only wires the results into Qt model signals.
|
||||
*/
|
||||
namespace DeckListModelCustomZones
|
||||
{
|
||||
|
||||
/**
|
||||
* @brief Whether the given node is a custom zone (as opposed to a criteria group).
|
||||
*/
|
||||
[[nodiscard]] bool isCustomZone(const AbstractDecklistNode *node);
|
||||
|
||||
/**
|
||||
* @brief Finds a criteria-group child of @p parent by name, skipping custom zones.
|
||||
*
|
||||
* The shadow tree keeps criteria groups and mirrored custom zones as siblings
|
||||
* under a board zone, and `InnerDecklistNode::findChild` matches both by name.
|
||||
* Group lookups must not resolve a custom zone that happens to share the group
|
||||
* name (e.g. a zone called "Creature"), so this searches only non-custom
|
||||
* children.
|
||||
*
|
||||
* @param parent The shadow node whose children are searched.
|
||||
* @param name The group name to find.
|
||||
* @return The matching group node, or nullptr if none exists.
|
||||
*/
|
||||
[[nodiscard]] InnerDecklistNode *findGroupChild(InnerDecklistNode *parent, const QString &name);
|
||||
|
||||
/**
|
||||
* @brief Mirrors the custom zones of a deck board zone into its shadow board node.
|
||||
*
|
||||
* Each custom zone becomes a DecklistModelSubZoneNode under @p shadowBoardZone
|
||||
* with its cards as direct (un-grouped) children.
|
||||
*
|
||||
* @param deckBoardZone The board zone in the canonical deck tree.
|
||||
* @param shadowBoardZone The matching board zone in the model's shadow tree.
|
||||
*/
|
||||
void mirrorCustomZones(const InnerDecklistNode *deckBoardZone, InnerDecklistNode *shadowBoardZone);
|
||||
|
||||
/**
|
||||
* @brief Finds a custom zone in the shadow tree by deck-unique name.
|
||||
* @param root Root of the shadow tree.
|
||||
* @param zoneName The custom zone name to find.
|
||||
* @return The matching custom zone node, or nullptr if not found.
|
||||
*/
|
||||
[[nodiscard]] DecklistModelSubZoneNode *findSubZoneByName(InnerDecklistNode *root, const QString &zoneName);
|
||||
|
||||
/**
|
||||
* @brief Sorts a shadow node's children, keeping a board's custom zones last.
|
||||
*
|
||||
* Sorting alone would interleave custom zones with criteria groups by name, but
|
||||
* custom zones must always stay after the groups within a board, regardless of
|
||||
* name. This applies the sort and, for board zones, stably moves the custom
|
||||
* zones to the end.
|
||||
*
|
||||
* @param root Root of the shadow tree (used to classify board zones).
|
||||
* @param node The shadow node whose children are reordered.
|
||||
* @param order Sort order to apply.
|
||||
* @return A list of (preSortRow, finalRow) pairs describing how each node moved.
|
||||
*/
|
||||
[[nodiscard]] QList<QPair<int, int>>
|
||||
sortWithCustomZonesLast(InnerDecklistNode *root, InnerDecklistNode *node, Qt::SortOrder order);
|
||||
|
||||
} // namespace DeckListModelCustomZones
|
||||
|
||||
#endif // DECK_LIST_MODEL_CUSTOM_ZONES_H
|
||||
|
|
@ -14,9 +14,12 @@ set(HEADERS
|
|||
game/server_deck_validation_strategy.h
|
||||
game/server_game.h
|
||||
game/server_game_lifecycle_strategy.h
|
||||
game/server_match_result_strategy.h
|
||||
game/server_match_game_factory.h
|
||||
game/server_match_result_strategy.h
|
||||
game/server_player.h
|
||||
game/server_tournament.h
|
||||
game/server_tournament_lifecycle_strategy.h
|
||||
game/server_tournament_match_result_strategy.h
|
||||
game/server_spectator.h
|
||||
server.h
|
||||
server_abstractuserinterface.h
|
||||
|
|
@ -43,6 +46,9 @@ add_library(
|
|||
game/server_game.cpp
|
||||
game/server_player.cpp
|
||||
game/server_spectator.cpp
|
||||
game/server_tournament.cpp
|
||||
game/server_tournament_lifecycle_strategy.cpp
|
||||
game/server_tournament_match_result_strategy.cpp
|
||||
server.cpp
|
||||
server_abstractuserinterface.cpp
|
||||
server_database_interface.cpp
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@
|
|||
#include <libcockatrice/protocol/pb/command_set_sideboard_lock.pb.h>
|
||||
#include <libcockatrice/protocol/pb/command_set_sideboard_plan.pb.h>
|
||||
#include <libcockatrice/protocol/pb/command_shuffle.pb.h>
|
||||
#include <libcockatrice/protocol/pb/command_tournament.pb.h>
|
||||
#include <libcockatrice/protocol/pb/command_undo_draw.pb.h>
|
||||
#include <libcockatrice/protocol/pb/context_connection_state_changed.pb.h>
|
||||
#include <libcockatrice/protocol/pb/event_game_say.pb.h>
|
||||
|
|
@ -536,6 +537,15 @@ Server_AbstractParticipant::processGameCommand(const GameCommand &command, Respo
|
|||
case GameCommand::SET_PLAYMAT:
|
||||
return cmdSetPlaymat(command.GetExtension(Command_SetPlaymat::ext), rc, ges);
|
||||
break;
|
||||
case GameCommand::REPORT_MATCH_RESULT:
|
||||
return cmdReportMatchResult(command.GetExtension(Command_ReportMatchResult::ext), rc, ges);
|
||||
break;
|
||||
case GameCommand::ADVANCE_TOURNAMENT:
|
||||
return cmdAdvanceTournament(command.GetExtension(Command_AdvanceTournament::ext), rc, ges);
|
||||
break;
|
||||
case GameCommand::TOURNAMENT_SETTINGS_SELECT:
|
||||
return cmdTournamentSettingsSelect(command.GetExtension(Command_TournamentSettingsSelect::ext), rc, ges);
|
||||
break;
|
||||
default:
|
||||
return Response::RespInvalidCommand;
|
||||
}
|
||||
|
|
@ -570,7 +580,7 @@ void Server_AbstractParticipant::setUserInterface(Server_AbstractUserInterface *
|
|||
void Server_AbstractParticipant::disconnectClient()
|
||||
{
|
||||
bool isRegistered = userInfo->user_level() & ServerInfo_User::IsRegistered;
|
||||
if (!isRegistered || spectator) {
|
||||
if (!isRegistered || spectator || game->getDisconnectRemovesPlayer()) {
|
||||
game->removeParticipant(this, Event_Leave::USER_DISCONNECTED);
|
||||
} else {
|
||||
setUserInterface(nullptr);
|
||||
|
|
@ -584,3 +594,25 @@ void Server_AbstractParticipant::getInfo(ServerInfo_Player *info,
|
|||
{
|
||||
getProperties(*info->mutable_properties(), withUserInfo);
|
||||
}
|
||||
|
||||
Response::ResponseCode Server_AbstractParticipant::cmdReportMatchResult(const Command_ReportMatchResult & /*cmd*/,
|
||||
ResponseContainer & /*rc*/,
|
||||
GameEventStorage & /*ges*/)
|
||||
{
|
||||
return Response::RespContextError;
|
||||
}
|
||||
|
||||
Response::ResponseCode Server_AbstractParticipant::cmdAdvanceTournament(const Command_AdvanceTournament & /*cmd*/,
|
||||
ResponseContainer & /*rc*/,
|
||||
GameEventStorage & /*ges*/)
|
||||
{
|
||||
return Response::RespContextError;
|
||||
}
|
||||
|
||||
Response::ResponseCode
|
||||
Server_AbstractParticipant::cmdTournamentSettingsSelect(const Command_TournamentSettingsSelect & /*cmd*/,
|
||||
ResponseContainer & /*rc*/,
|
||||
GameEventStorage & /*ges*/)
|
||||
{
|
||||
return Response::RespContextError;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,6 +53,9 @@ class Command_DeckSelect;
|
|||
class Command_SetSideboardLock;
|
||||
class Command_ChangeZoneProperties;
|
||||
class Command_SetPlaymat;
|
||||
class Command_ReportMatchResult;
|
||||
class Command_AdvanceTournament;
|
||||
class Command_TournamentSettingsSelect;
|
||||
|
||||
class Server_AbstractParticipant : public Server_ArrowTarget, public ServerInfo_User_Container
|
||||
{
|
||||
|
|
@ -175,6 +178,13 @@ public:
|
|||
cmdReverseTurn(const Command_ReverseTurn & /*cmd*/, ResponseContainer & /*rc*/, GameEventStorage &ges);
|
||||
virtual Response::ResponseCode
|
||||
cmdChangeZoneProperties(const Command_ChangeZoneProperties &cmd, ResponseContainer &rc, GameEventStorage &ges);
|
||||
virtual Response::ResponseCode
|
||||
cmdReportMatchResult(const Command_ReportMatchResult &cmd, ResponseContainer &rc, GameEventStorage &ges);
|
||||
virtual Response::ResponseCode
|
||||
cmdAdvanceTournament(const Command_AdvanceTournament &cmd, ResponseContainer &rc, GameEventStorage &ges);
|
||||
virtual Response::ResponseCode cmdTournamentSettingsSelect(const Command_TournamentSettingsSelect &cmd,
|
||||
ResponseContainer &rc,
|
||||
GameEventStorage &ges);
|
||||
|
||||
Response::ResponseCode processGameCommand(const GameCommand &command, ResponseContainer &rc, GameEventStorage &ges);
|
||||
void sendGameEvent(const GameEventContainer &event);
|
||||
|
|
|
|||
|
|
@ -1663,3 +1663,9 @@ void Server_AbstractPlayer::getPlayerProperties(ServerInfo_PlayerProperties &res
|
|||
playmatParams->set_zoom(playmat.params.zoom);
|
||||
}
|
||||
}
|
||||
|
||||
void Server_AbstractPlayer::setDeck(DeckList *_deck)
|
||||
{
|
||||
delete deck;
|
||||
deck = _deck;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ public:
|
|||
{
|
||||
return deck;
|
||||
}
|
||||
void setDeck(DeckList *_deck);
|
||||
bool getReadyStart() const
|
||||
{
|
||||
return readyStart;
|
||||
|
|
|
|||
|
|
@ -30,10 +30,14 @@
|
|||
#include "server_cardzone.h"
|
||||
#include "server_player.h"
|
||||
#include "server_spectator.h"
|
||||
#include "server_tournament.h"
|
||||
#include "server_tournament_lifecycle_strategy.h"
|
||||
#include "server_tournament_match_result_strategy.h"
|
||||
|
||||
#include <QDebug>
|
||||
#include <QRegularExpression>
|
||||
#include <QTimer>
|
||||
#include <algorithm>
|
||||
#include <google/protobuf/descriptor.h>
|
||||
#include <libcockatrice/deck_list/deck_list.h>
|
||||
#include <libcockatrice/protocol/pb/context_connection_state_changed.pb.h>
|
||||
|
|
@ -62,8 +66,9 @@ Server_Game::Server_Game(const GameConfig &config, Server_Room *_room)
|
|||
spectatorsCanTalk(config.spectatorsCanTalk), spectatorsSeeEverything(config.spectatorsSeeEverything),
|
||||
startingLifeTotal(config.startingLifeTotal), shareDecklistsOnLoad(config.shareDecklistsOnLoad),
|
||||
inactivityCounter(0), startTimeOfThisGame(0), secondsElapsed(0), firstGameStarted(false),
|
||||
turnOrderReversed(false), startTime(QDateTime::currentDateTime()), pingClock(nullptr),
|
||||
deckValidationStrategy(new Server_DefaultDeckValidationStrategy),
|
||||
turnOrderReversed(false), startTime(QDateTime::currentDateTime()), pingClock(nullptr), isTournament(false),
|
||||
tournament(nullptr), tournamentParentGame(nullptr), tournamentMatchPlayer1Id(-1), tournamentMatchPlayer2Id(-1),
|
||||
disconnectRemovesPlayer(false), deckValidationStrategy(new Server_DefaultDeckValidationStrategy),
|
||||
lifecycleStrategy(new Server_DefaultLifecycleStrategy), matchResultStrategy(new Server_NullMatchResultStrategy),
|
||||
gameMutex()
|
||||
{
|
||||
|
|
@ -265,6 +270,10 @@ void Server_Game::createGameStateChangedEvent(Event_GameStateChanged *event,
|
|||
event->set_game_started(false);
|
||||
}
|
||||
|
||||
if (tournamentParentGame) {
|
||||
event->set_parent_game_id(tournamentParentGame->getGameId());
|
||||
}
|
||||
|
||||
for (Server_AbstractParticipant *participant : participants.values()) {
|
||||
participant->getInfo(event->add_player_list(), recipient, omniscient, withUserInfo);
|
||||
}
|
||||
|
|
@ -313,7 +322,14 @@ void Server_Game::doStartGameIfReady(bool forceStartGame)
|
|||
Server_DatabaseInterface *databaseInterface = room->getServer()->getDatabaseInterface();
|
||||
QMutexLocker locker(&gameMutex);
|
||||
|
||||
if (getPlayerCount() < maxPlayers && !forceStartGame) {
|
||||
if (!isTournament && getPlayerCount() < maxPlayers && !forceStartGame) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Tournament hubs must be host-started and can't lock in a partially filled
|
||||
// bracket: a mere "everyone current is ready" must not start a 1-player or
|
||||
// undersized tournament. startTournament() additionally enforces 2+ players.
|
||||
if (isTournament && !forceStartGame) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -567,6 +583,17 @@ void Server_Game::removeParticipant(Server_AbstractParticipant *participant, Eve
|
|||
bool playerHost = hostId == participant->getPlayerId();
|
||||
participant->prepareDestroy();
|
||||
|
||||
// If this is the tournament hub (not one of its match sub-games), never re-pair
|
||||
// the leaving player: mark them dropped so their matches are awarded and they
|
||||
// disappear from the bracket instead of stalling the tournament.
|
||||
if (tournament && !tournamentParentGame && !spectator) {
|
||||
const int leavingPlayerId = participant->getPlayerId();
|
||||
GameEventStorage tournGes;
|
||||
tournament->dropPlayer(leavingPlayerId);
|
||||
tournament->broadcastTournamentState(tournGes);
|
||||
tournGes.sendToGame(this);
|
||||
}
|
||||
|
||||
if (playerHost) {
|
||||
int newHostId = -1;
|
||||
for (auto *otherPlayer : getPlayers().values()) {
|
||||
|
|
@ -783,6 +810,15 @@ void Server_Game::createGameJoinedEvent(Server_AbstractParticipant *joiningParti
|
|||
}
|
||||
|
||||
rc.enqueuePostResponseItem(ServerMessage::GAME_EVENT_CONTAINER, prepareGameEvent(event2, -1));
|
||||
|
||||
// A tournament's bracket/phase/standings live in Event_TournamentState, which
|
||||
// normally only flows on mutation. Without a copy here a late joiner would sit
|
||||
// on an empty bracket until the next round advances, so replay the current
|
||||
// state as part of the join snapshot.
|
||||
if (tournament) {
|
||||
rc.enqueuePostResponseItem(ServerMessage::GAME_EVENT_CONTAINER,
|
||||
prepareGameEvent(tournament->buildStateEvent(), -1));
|
||||
}
|
||||
}
|
||||
|
||||
void Server_Game::sendGameEventContainer(GameEventContainer *cont,
|
||||
|
|
@ -861,6 +897,7 @@ void Server_Game::getInfo(ServerInfo_Game &result) const
|
|||
result.set_share_decklists_on_load(shareDecklistsOnLoad);
|
||||
result.set_spectators_count(getSpectatorCount());
|
||||
result.set_start_time(startTime.toSecsSinceEpoch());
|
||||
result.set_is_tournament(isTournament);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -911,3 +948,92 @@ void Server_Game::setDeckValidationStrategy(Server_DeckValidationStrategy *strat
|
|||
{
|
||||
deckValidationStrategy.reset(strategy);
|
||||
}
|
||||
|
||||
void Server_Game::setMatchResultStrategy(Server_MatchResultStrategy *strategy)
|
||||
{
|
||||
matchResultStrategy.reset(strategy);
|
||||
}
|
||||
|
||||
void Server_Game::setIsTournamentGame(bool _isTournament)
|
||||
{
|
||||
isTournament = _isTournament;
|
||||
if (isTournament) {
|
||||
tournament = new Server_Tournament(this, this, this);
|
||||
lifecycleStrategy.reset(new Server_TournamentLifecycleStrategy);
|
||||
matchResultStrategy.reset(new Server_TournamentMatchResultStrategy);
|
||||
} else if (tournament) {
|
||||
delete tournament;
|
||||
tournament = nullptr;
|
||||
lifecycleStrategy.reset(new Server_DefaultLifecycleStrategy);
|
||||
matchResultStrategy.reset(new Server_NullMatchResultStrategy);
|
||||
}
|
||||
}
|
||||
|
||||
void Server_Game::startTournament()
|
||||
{
|
||||
if (!tournament) {
|
||||
tournament = new Server_Tournament(this, this, this);
|
||||
}
|
||||
|
||||
if (!tournament->isStarted()) {
|
||||
// Add all current players to the tournament
|
||||
auto players = getPlayers();
|
||||
for (auto *player : players.values()) {
|
||||
tournament->addPlayer(player->getPlayerId(), QString::fromStdString(player->getUserInfo()->name()));
|
||||
}
|
||||
|
||||
// A tournament with fewer than two players can't produce a valid bracket.
|
||||
if (tournament->getPlayerCount() < 2) {
|
||||
qWarning() << "Cannot start tournament with fewer than 2 players";
|
||||
return;
|
||||
}
|
||||
|
||||
tournament->startTournament();
|
||||
}
|
||||
|
||||
GameEventStorage ges;
|
||||
tournament->broadcastTournamentState(ges);
|
||||
ges.sendToGame(this);
|
||||
}
|
||||
|
||||
void Server_Game::setPlayerTournamentDeck(int playerId, DeckList *deck)
|
||||
{
|
||||
if (tournament) {
|
||||
tournament->setPlayerDeck(playerId, deck);
|
||||
}
|
||||
}
|
||||
|
||||
void Server_Game::setTournamentMatchInfo(Server_Game *parentGame, int p1Id, int p2Id)
|
||||
{
|
||||
tournamentParentGame = parentGame;
|
||||
tournamentMatchPlayer1Id = p1Id;
|
||||
tournamentMatchPlayer2Id = p2Id;
|
||||
}
|
||||
|
||||
Server_Game *Server_Game::createMatchGame(const GameConfig &config, int &outGameId)
|
||||
{
|
||||
Server_DatabaseInterface *databaseInterface = room->getServer()->getDatabaseInterface();
|
||||
outGameId = databaseInterface->getNextGameId();
|
||||
if (outGameId == -1) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
GameConfig matchConfig = config;
|
||||
matchConfig.gameId = outGameId;
|
||||
auto *game = new Server_Game(matchConfig, room);
|
||||
// Sub-games carry the tournament flag (for protocol fields) but keep the default
|
||||
// strategies; the parent tournament drives them through the match result strategy
|
||||
// installed by Server_Tournament::createMatchGame.
|
||||
game->isTournament = true;
|
||||
return game;
|
||||
}
|
||||
|
||||
Server_AbstractUserInterface *Server_Game::getUserInterface(const QString &playerName)
|
||||
{
|
||||
return room->getUserInterfaceByName(playerName);
|
||||
}
|
||||
|
||||
void Server_Game::addGameToRoom(Server_Game *game)
|
||||
{
|
||||
room->addGame(game);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,31 +24,38 @@
|
|||
#include "game_config.h"
|
||||
#include "server_deck_validation_strategy.h"
|
||||
#include "server_game_lifecycle_strategy.h"
|
||||
#include "server_match_game_factory.h"
|
||||
#include "server_match_result_strategy.h"
|
||||
|
||||
#include <QDateTime>
|
||||
#include <QMap>
|
||||
#include <QMutex>
|
||||
#include <QObject>
|
||||
#include <QPointer>
|
||||
#include <QScopedPointer>
|
||||
#include <QSet>
|
||||
#include <QStringList>
|
||||
#include <libcockatrice/protocol/pb/event_leave.pb.h>
|
||||
#include <libcockatrice/protocol/pb/event_tournament_state.pb.h>
|
||||
#include <libcockatrice/protocol/pb/response.pb.h>
|
||||
#include <libcockatrice/protocol/pb/serverinfo_game.pb.h>
|
||||
|
||||
class QTimer;
|
||||
class DeckList;
|
||||
class GameEventContainer;
|
||||
class GameEventStorage;
|
||||
class GameReplay;
|
||||
class Server_Room;
|
||||
class Server_AbstractPlayer;
|
||||
class Server_AbstractParticipant;
|
||||
class Server_Card;
|
||||
class Server_Tournament;
|
||||
class ServerInfo_User;
|
||||
class ServerInfo_Game;
|
||||
class Server_AbstractUserInterface;
|
||||
class Event_GameStateChanged;
|
||||
|
||||
class Server_Game : public QObject
|
||||
class Server_Game : public QObject, public Server_MatchGameFactory
|
||||
{
|
||||
Q_OBJECT
|
||||
private:
|
||||
|
|
@ -83,6 +90,14 @@ private:
|
|||
QList<GameReplay *> replayList;
|
||||
GameReplay *currentReplay;
|
||||
|
||||
bool isTournament;
|
||||
TournamentSettings tournamentSettings;
|
||||
Server_Tournament *tournament;
|
||||
QPointer<Server_Game> tournamentParentGame;
|
||||
int tournamentMatchPlayer1Id;
|
||||
int tournamentMatchPlayer2Id;
|
||||
bool disconnectRemovesPlayer;
|
||||
|
||||
QScopedPointer<Server_DeckValidationStrategy> deckValidationStrategy;
|
||||
|
||||
QScopedPointer<Server_GameLifecycleStrategy> lifecycleStrategy;
|
||||
|
|
@ -220,6 +235,49 @@ public:
|
|||
void returnCardsFromPlayer(GameEventStorage &ges, Server_AbstractPlayer *player);
|
||||
|
||||
/** @brief Get the current deck validation strategy (non-owning). */
|
||||
bool getIsTournamentGame() const
|
||||
{
|
||||
return isTournament;
|
||||
}
|
||||
void setIsTournamentGame(bool _isTournament);
|
||||
bool getIsTournament() const
|
||||
{
|
||||
return tournament != nullptr;
|
||||
}
|
||||
Server_Tournament *getTournament() const
|
||||
{
|
||||
return tournament;
|
||||
}
|
||||
void startTournament();
|
||||
void setPlayerTournamentDeck(int playerId, DeckList *deck);
|
||||
void setTournamentMatchInfo(Server_Game *parentGame, int p1Id, int p2Id);
|
||||
QPointer<Server_Game> getTournamentParentGame() const
|
||||
{
|
||||
return tournamentParentGame;
|
||||
}
|
||||
bool getDisconnectRemovesPlayer() const
|
||||
{
|
||||
return disconnectRemovesPlayer;
|
||||
}
|
||||
void setDisconnectRemovesPlayer(bool _disconnectRemovesPlayer)
|
||||
{
|
||||
disconnectRemovesPlayer = _disconnectRemovesPlayer;
|
||||
}
|
||||
|
||||
// Server_MatchGameFactory implementation
|
||||
Server_Game *createMatchGame(const GameConfig &config, int &outGameId) override;
|
||||
Server_AbstractUserInterface *getUserInterface(const QString &playerName) override;
|
||||
void addGameToRoom(Server_Game *game) override;
|
||||
|
||||
const TournamentSettings &getTournamentSettings() const
|
||||
{
|
||||
return tournamentSettings;
|
||||
}
|
||||
void setTournamentSettings(const TournamentSettings &settings)
|
||||
{
|
||||
tournamentSettings = settings;
|
||||
}
|
||||
|
||||
Server_DeckValidationStrategy *getDeckValidationStrategy() const
|
||||
{
|
||||
return deckValidationStrategy.data();
|
||||
|
|
@ -232,6 +290,8 @@ public:
|
|||
{
|
||||
return lifecycleStrategy.data();
|
||||
}
|
||||
/** @brief Replace the match result strategy; takes ownership of @p strategy. */
|
||||
void setMatchResultStrategy(Server_MatchResultStrategy *strategy);
|
||||
};
|
||||
|
||||
#endif
|
||||
|
|
|
|||
|
|
@ -0,0 +1,691 @@
|
|||
#include "server_tournament.h"
|
||||
|
||||
#include "../server_abstractuserinterface.h"
|
||||
#include "../server_response_containers.h"
|
||||
#include "../serverinfo_user_container.h"
|
||||
#include "game_config.h"
|
||||
#include "server_abstract_player.h"
|
||||
#include "server_game.h"
|
||||
#include "server_match_game_factory.h"
|
||||
#include "server_player.h"
|
||||
#include "server_tournament_match_result_strategy.h"
|
||||
|
||||
#include <QLoggingCategory>
|
||||
#include <algorithm>
|
||||
#include <libcockatrice/deck_list/deck_list.h>
|
||||
#include <libcockatrice/protocol/pb/event_tournament_state.pb.h>
|
||||
#include <libcockatrice/protocol/pb/game_event_container.pb.h>
|
||||
|
||||
inline Q_LOGGING_CATEGORY(TournamentLog, "tournament");
|
||||
|
||||
Server_Tournament::Server_Tournament(Server_Game *_parentGame, Server_MatchGameFactory *_factory, QObject *parent)
|
||||
: QObject(parent), parentGame(_parentGame), matchGameFactory(_factory), currentRound(0), totalRounds(0),
|
||||
started(false)
|
||||
{
|
||||
}
|
||||
|
||||
Server_Tournament::~Server_Tournament()
|
||||
{
|
||||
qDeleteAll(submittedDecks);
|
||||
}
|
||||
|
||||
void Server_Tournament::addPlayer(int playerId, const QString &playerName)
|
||||
{
|
||||
QMutexLocker locker(&tournamentMutex);
|
||||
TournamentPlayerData data;
|
||||
data.playerId = playerId;
|
||||
data.playerName = playerName;
|
||||
data.dropped = false;
|
||||
players[playerId] = data;
|
||||
}
|
||||
|
||||
void Server_Tournament::setPlayerDeck(int playerId, DeckList *deck)
|
||||
{
|
||||
QMutexLocker locker(&tournamentMutex);
|
||||
delete submittedDecks.value(playerId, nullptr);
|
||||
submittedDecks[playerId] = deck;
|
||||
if (players.contains(playerId)) {
|
||||
players[playerId].deckSubmitted = true;
|
||||
}
|
||||
}
|
||||
|
||||
void Server_Tournament::removePlayer(int playerId)
|
||||
{
|
||||
QMutexLocker locker(&tournamentMutex);
|
||||
players.remove(playerId);
|
||||
submittedDecks.remove(playerId);
|
||||
byeGivenPlayers.remove(playerId);
|
||||
}
|
||||
|
||||
void Server_Tournament::dropPlayer(int playerId)
|
||||
{
|
||||
QMutexLocker locker(&tournamentMutex);
|
||||
if (!players.contains(playerId)) {
|
||||
return;
|
||||
}
|
||||
players[playerId].dropped = true;
|
||||
players[playerId].deckSubmitted = false;
|
||||
|
||||
// Any current pairing that involves the dropped player and is not already
|
||||
// decided is awarded to the surviving opponent (or recorded as undecided if
|
||||
// both dropped). The opponent keeps playing without sitting out a round.
|
||||
for (auto &pairing : currentPairings) {
|
||||
if (pairing.winnerId != -2) {
|
||||
continue;
|
||||
}
|
||||
bool involvesDropped = (pairing.player1Id == playerId || pairing.player2Id == playerId);
|
||||
if (!involvesDropped) {
|
||||
continue;
|
||||
}
|
||||
if (pairing.player1Id == playerId && pairing.player2Id == playerId) {
|
||||
continue;
|
||||
}
|
||||
int opponent = (pairing.player1Id == playerId) ? pairing.player2Id : pairing.player1Id;
|
||||
if (players.contains(opponent) && !players[opponent].dropped) {
|
||||
pairing.winnerId = opponent;
|
||||
players[opponent].wins += 1;
|
||||
players[playerId].losses += 1;
|
||||
}
|
||||
allPreviousPairings.append(qMakePair(pairing.player1Id, pairing.player2Id));
|
||||
}
|
||||
}
|
||||
|
||||
void Server_Tournament::startTournament()
|
||||
{
|
||||
{
|
||||
QMutexLocker locker(&tournamentMutex);
|
||||
if (started) {
|
||||
return;
|
||||
}
|
||||
totalRounds = calculateTotalRounds();
|
||||
started = true;
|
||||
currentRound = 0;
|
||||
generateSwissPairings();
|
||||
}
|
||||
|
||||
// Spawn the first round's match games
|
||||
enqueueMatchGameCreation();
|
||||
}
|
||||
|
||||
bool Server_Tournament::isAllDecksSubmitted() const
|
||||
{
|
||||
QMutexLocker locker(&tournamentMutex);
|
||||
for (auto it = players.constBegin(); it != players.constEnd(); ++it) {
|
||||
if (!it->deckSubmitted) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
int Server_Tournament::getTournamentPlayerIdByName(const QString &name) const
|
||||
{
|
||||
QMutexLocker locker(&tournamentMutex);
|
||||
for (auto it = players.constBegin(); it != players.constEnd(); ++it) {
|
||||
if (it->playerName == name) {
|
||||
return it->playerId;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
void Server_Tournament::generateSwissPairings()
|
||||
{
|
||||
currentPairings.clear();
|
||||
QList<int> available;
|
||||
for (auto it = players.constBegin(); it != players.constEnd(); ++it) {
|
||||
if (!it->dropped) {
|
||||
available.append(it->playerId);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by wins descending (and by record for tie-breaking)
|
||||
std::sort(available.begin(), available.end(), [this](int a, int b) {
|
||||
const auto &pa = players[a];
|
||||
const auto &pb = players[b];
|
||||
if (pa.wins != pb.wins) {
|
||||
return pa.wins > pb.wins;
|
||||
}
|
||||
if (pa.losses != pb.losses) {
|
||||
return pa.losses < pb.losses;
|
||||
}
|
||||
return a < b;
|
||||
});
|
||||
|
||||
QSet<int> paired;
|
||||
// Try to pair every player, allowing a single rematch only if the greedy pass
|
||||
// would otherwise leave any unpaired remainder. Dropped players are never paired.
|
||||
int maxRematches = available.size() / 2;
|
||||
for (int i = 0; i < available.size(); ++i) {
|
||||
if (paired.contains(available[i])) {
|
||||
continue;
|
||||
}
|
||||
for (int j = i + 1; j < available.size(); ++j) {
|
||||
if (paired.contains(available[j])) {
|
||||
continue;
|
||||
}
|
||||
bool rematch = havePlayed(available[i], available[j]);
|
||||
if (rematch && maxRematches <= 0) {
|
||||
continue;
|
||||
}
|
||||
TournamentPairingData pairing;
|
||||
pairing.player1Id = available[i];
|
||||
pairing.player2Id = available[j];
|
||||
currentPairings.append(pairing);
|
||||
paired.insert(available[i]);
|
||||
paired.insert(available[j]);
|
||||
if (rematch) {
|
||||
--maxRematches;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Give a bye to every remaining unpaired eligible player, worst-ranked first.
|
||||
// A player receives at most one bye over the whole tournament.
|
||||
QList<int> unpaired;
|
||||
for (int id : available) {
|
||||
if (!paired.contains(id)) {
|
||||
unpaired.append(id);
|
||||
}
|
||||
}
|
||||
// Byes go to the lowest-ranked eligible player who has not had one yet.
|
||||
std::sort(unpaired.begin(), unpaired.end(), [this](int a, int b) {
|
||||
const auto &pa = players[a];
|
||||
const auto &pb = players[b];
|
||||
if (pa.wins != pb.wins) {
|
||||
return pa.wins < pb.wins;
|
||||
}
|
||||
if (pa.losses != pb.losses) {
|
||||
return pa.losses > pb.losses;
|
||||
}
|
||||
return a > b;
|
||||
});
|
||||
|
||||
for (int id : unpaired) {
|
||||
if (byeGivenPlayers.contains(id)) {
|
||||
// Already used a bye: a dropped opponent or earlier bye means this player
|
||||
// simply sits out the round with a free win to keep the bracket moving.
|
||||
TournamentPairingData bye;
|
||||
bye.player1Id = id;
|
||||
bye.player2Id = -1;
|
||||
bye.winnerId = id;
|
||||
currentPairings.append(bye);
|
||||
continue;
|
||||
}
|
||||
TournamentPairingData bye;
|
||||
bye.player1Id = id;
|
||||
bye.player2Id = -1;
|
||||
bye.winnerId = id;
|
||||
currentPairings.append(bye);
|
||||
players[id].wins += 1;
|
||||
byeGivenPlayers.insert(id);
|
||||
allPreviousPairings.append(qMakePair(id, -1));
|
||||
}
|
||||
}
|
||||
|
||||
int Server_Tournament::calculateTotalRounds() const
|
||||
{
|
||||
int n = 0;
|
||||
for (auto it = players.constBegin(); it != players.constEnd(); ++it) {
|
||||
if (!it->dropped) {
|
||||
++n;
|
||||
}
|
||||
}
|
||||
if (n <= 1) {
|
||||
return 0;
|
||||
}
|
||||
// Standard Swiss rounds: ceil(log2(n))
|
||||
int rounds = 0;
|
||||
while ((1 << rounds) < n) {
|
||||
++rounds;
|
||||
}
|
||||
return rounds;
|
||||
}
|
||||
|
||||
bool Server_Tournament::havePlayed(int p1, int p2) const
|
||||
{
|
||||
for (const auto &pair : allPreviousPairings) {
|
||||
if ((pair.first == p1 && pair.second == p2) || (pair.first == p2 && pair.second == p1)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void Server_Tournament::advanceRound(GameEventStorage &ges)
|
||||
{
|
||||
{
|
||||
QMutexLocker locker(&tournamentMutex);
|
||||
++currentRound;
|
||||
if (currentRound >= totalRounds) {
|
||||
broadcastTournamentState(ges);
|
||||
return;
|
||||
}
|
||||
generateSwissPairings();
|
||||
}
|
||||
|
||||
enqueueMatchGameCreation();
|
||||
broadcastTournamentState(ges);
|
||||
}
|
||||
|
||||
bool Server_Tournament::allPairingsDecided() const
|
||||
{
|
||||
for (const auto &pairing : currentPairings) {
|
||||
if (pairing.winnerId == -2) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void Server_Tournament::enqueueMatchGameCreation()
|
||||
{
|
||||
QList<QPair<int, int>> planned;
|
||||
{
|
||||
QMutexLocker locker(&tournamentMutex);
|
||||
for (const auto &pairing : currentPairings) {
|
||||
if (pairing.player2Id == -1 || pairing.winnerId != -2) {
|
||||
continue;
|
||||
}
|
||||
if (players.value(pairing.player1Id).dropped || players.value(pairing.player2Id).dropped) {
|
||||
continue;
|
||||
}
|
||||
if (pairing.matchGameIds.size() >= static_cast<int>(gamesPerMatch)) {
|
||||
continue;
|
||||
}
|
||||
planned.append(qMakePair(pairing.player1Id, pairing.player2Id));
|
||||
}
|
||||
}
|
||||
if (planned.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create the games from the event loop instead of the caller's stack: command
|
||||
// processing holds game mutexes, and room registration takes gamesLock, so spawning
|
||||
// synchronously would nest lock orders. The queued job runs once this object's
|
||||
// owning thread returns to its event loop with no locks held; it is dropped if this
|
||||
// tournament is destroyed first.
|
||||
QMetaObject::invokeMethod(
|
||||
this,
|
||||
[this, planned] {
|
||||
for (const auto &pair : planned) {
|
||||
createMatchGame(pair.first, pair.second);
|
||||
}
|
||||
|
||||
GameEventStorage ges;
|
||||
broadcastTournamentState(ges);
|
||||
ges.sendToGame(parentGame);
|
||||
},
|
||||
Qt::QueuedConnection);
|
||||
}
|
||||
|
||||
void Server_Tournament::createMatchGame(int player1Id, int player2Id)
|
||||
{
|
||||
if (!matchGameFactory || player2Id == -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
QString player1Name;
|
||||
QString player2Name;
|
||||
QString deck1Native;
|
||||
QString deck2Native;
|
||||
int round = 0;
|
||||
int gameNumber = 1;
|
||||
{
|
||||
QMutexLocker locker(&tournamentMutex);
|
||||
player1Name = players.value(player1Id).playerName;
|
||||
player2Name = players.value(player2Id).playerName;
|
||||
|
||||
if (submittedDecks.contains(player1Id)) {
|
||||
deck1Native = submittedDecks.value(player1Id)->writeToString_Native();
|
||||
}
|
||||
if (submittedDecks.contains(player2Id)) {
|
||||
deck2Native = submittedDecks.value(player2Id)->writeToString_Native();
|
||||
}
|
||||
|
||||
round = currentRound;
|
||||
for (const auto &pairing : currentPairings) {
|
||||
if (pairing.player1Id == player1Id && pairing.player2Id == player2Id) {
|
||||
gameNumber = pairing.matchGameIds.size() + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Defense in depth: never exceed the configured series length
|
||||
for (const auto &pairing : currentPairings) {
|
||||
if (pairing.player1Id == player1Id && pairing.player2Id == player2Id &&
|
||||
pairing.matchGameIds.size() >= static_cast<int>(gamesPerMatch)) {
|
||||
qCWarning(TournamentLog) << "Refusing to exceed series length for pairing" << player1Id << player2Id;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Bail out if either participant is no longer connected: a match game with
|
||||
// zero or one connected player can never finish and would stall the round.
|
||||
if (!matchGameFactory->getUserInterface(player1Name) || !matchGameFactory->getUserInterface(player2Name)) {
|
||||
qCWarning(TournamentLog) << "Skipping match creation: a player in pairing" << player1Id << player2Id
|
||||
<< "is no longer connected";
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Create a sub-game for this match via the factory, copying the real
|
||||
// ServerInfo_User so it ships the true user level rather than a fabricated
|
||||
// admin identity that would surface in buddy/ignore-list checks.
|
||||
ServerInfo_User creatorInfo;
|
||||
if (auto *ui = matchGameFactory->getUserInterface(player1Name)) {
|
||||
creatorInfo = *ui->getUserInfo();
|
||||
} else {
|
||||
creatorInfo.set_name(player1Name.toStdString());
|
||||
}
|
||||
|
||||
QString gameDesc = gamesPerMatch > 1
|
||||
? QString("R%1 Match - Game %2 of %3").arg(round).arg(gameNumber).arg(gamesPerMatch)
|
||||
: QString("Tournament Round %1").arg(round);
|
||||
|
||||
GameConfig matchConfig;
|
||||
matchConfig.creatorInfo = creatorInfo;
|
||||
matchConfig.description = gameDesc;
|
||||
matchConfig.maxPlayers = 2;
|
||||
matchConfig.startingLifeTotal = parentGame->getStartingLifeTotal();
|
||||
|
||||
int matchGameId = -1;
|
||||
auto *matchGame = matchGameFactory->createMatchGame(matchConfig, matchGameId);
|
||||
if (!matchGame || matchGameId == -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
matchGame->setTournamentMatchInfo(parentGame, player1Id, player2Id);
|
||||
matchGame->setMatchResultStrategy(new Server_TournamentMatchResultStrategy);
|
||||
// A disconnect inside a tournament match must remove the player so the match
|
||||
// can be decided; it must not leave them sitting as a half-present participant.
|
||||
matchGame->setDisconnectRemovesPlayer(true);
|
||||
matchGameFactory->addGameToRoom(matchGame);
|
||||
|
||||
// Store the game ID in the pairing
|
||||
{
|
||||
QMutexLocker locker(&tournamentMutex);
|
||||
for (auto &pairing : currentPairings) {
|
||||
if (pairing.player1Id == player1Id && pairing.player2Id == player2Id) {
|
||||
pairing.gameId = matchGameId;
|
||||
pairing.matchGameIds.append(matchGameId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-join both players, sending the join event directly through their UIs.
|
||||
// Both UI lookups were verified above, so a player can only drop between that
|
||||
// check and this add — in which case they get handled by drop processing and
|
||||
// the pairing settles on the surviving opponent.
|
||||
QMap<int, QPair<Server_AbstractUserInterface *, ResponseContainer *>> joiners;
|
||||
|
||||
auto joinAndSetupPlayer = [&](int pid, const QString &name) {
|
||||
Server_AbstractUserInterface *ui = matchGameFactory->getUserInterface(name);
|
||||
if (ui) {
|
||||
auto *rc = new ResponseContainer(0);
|
||||
matchGame->addPlayer(ui, *rc, false, false, false);
|
||||
joiners[pid] = qMakePair(ui, rc);
|
||||
}
|
||||
};
|
||||
|
||||
joinAndSetupPlayer(player1Id, player1Name);
|
||||
joinAndSetupPlayer(player2Id, player2Name);
|
||||
|
||||
// Now send the enqueued GameJoined + GameStateChanged events to each player's client.
|
||||
for (auto it = joiners.constBegin(); it != joiners.constEnd(); ++it) {
|
||||
it.value().first->sendResponseContainer(*it.value().second, Response::RespNothing);
|
||||
delete it.value().second;
|
||||
}
|
||||
joiners.clear();
|
||||
|
||||
// Set decks and mark players as ready in the match game.
|
||||
bool anyDeckMissing = false;
|
||||
auto matchPlayers = matchGame->getPlayers();
|
||||
for (auto *matchPlayer : matchPlayers) {
|
||||
const QString name = QString::fromStdString(matchPlayer->getUserInfo()->name());
|
||||
QString deckNative;
|
||||
if (name == player1Name) {
|
||||
deckNative = deck1Native;
|
||||
} else if (name == player2Name) {
|
||||
deckNative = deck2Native;
|
||||
}
|
||||
|
||||
if (!deckNative.isEmpty()) {
|
||||
matchPlayer->setDeck(new DeckList(deckNative));
|
||||
matchPlayer->setReadyStart(true);
|
||||
} else {
|
||||
anyDeckMissing = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (anyDeckMissing) {
|
||||
// Not every participant submitted a deck. Do not force-start: that would
|
||||
// kick the players without a deck. Leave the match game open so they can
|
||||
// select a deck; the host starts it through the normal ready flow.
|
||||
return;
|
||||
}
|
||||
|
||||
// Start the match game without forcing: both participants are ready and have
|
||||
// decks, so there is nothing to kick.
|
||||
matchGame->startGameIfReady(false);
|
||||
}
|
||||
|
||||
void Server_Tournament::recordMatchResult(int playerId1, int playerId2, int winnerId, GameEventStorage &ges)
|
||||
{
|
||||
QMutexLocker locker(&tournamentMutex);
|
||||
|
||||
// Find the pairing and set the winner
|
||||
for (auto &pairing : currentPairings) {
|
||||
if ((pairing.player1Id == playerId1 && pairing.player2Id == playerId2) ||
|
||||
(pairing.player1Id == playerId2 && pairing.player2Id == playerId1)) {
|
||||
if (pairing.winnerId != -2) {
|
||||
return; // Already recorded — defense in depth against double-call
|
||||
}
|
||||
pairing.winnerId = winnerId;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Update player records
|
||||
if (winnerId == -1) {
|
||||
// Draw
|
||||
players[playerId1].draws += 1;
|
||||
players[playerId2].draws += 1;
|
||||
} else if (winnerId == playerId1) {
|
||||
players[playerId1].wins += 1;
|
||||
players[playerId2].losses += 1;
|
||||
} else if (winnerId == playerId2) {
|
||||
players[playerId2].wins += 1;
|
||||
players[playerId1].losses += 1;
|
||||
}
|
||||
|
||||
// Store for future pairing avoidance
|
||||
allPreviousPairings.append(qMakePair(playerId1, playerId2));
|
||||
|
||||
// Check if all pairings in current round have results
|
||||
bool allDecided = true;
|
||||
for (const auto &pairing : currentPairings) {
|
||||
if (pairing.winnerId == -2) {
|
||||
allDecided = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
broadcastTournamentState(ges);
|
||||
|
||||
if (allDecided) {
|
||||
advanceRound(ges);
|
||||
}
|
||||
}
|
||||
|
||||
bool Server_Tournament::recordMatchResultByGameId(int gameId, int winnerId, GameEventStorage &ges)
|
||||
{
|
||||
bool matchDecided = false;
|
||||
bool seriesContinues = false;
|
||||
int p1 = -1;
|
||||
int p2 = -1;
|
||||
{
|
||||
QMutexLocker locker(&tournamentMutex);
|
||||
|
||||
// Find the pairing that owns this game
|
||||
TournamentPairingData *pairingPtr = nullptr;
|
||||
for (auto &pairing : currentPairings) {
|
||||
if (pairing.matchGameIds.contains(gameId)) {
|
||||
pairingPtr = &pairing;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!pairingPtr) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If the match is already decided, ignore further sub-game results
|
||||
if (pairingPtr->winnerId != -2) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Increment per-match wins
|
||||
if (winnerId == pairingPtr->player1Id) {
|
||||
pairingPtr->player1MatchWins += 1;
|
||||
} else if (winnerId == pairingPtr->player2Id) {
|
||||
pairingPtr->player2MatchWins += 1;
|
||||
}
|
||||
// Draw (winnerId == -1): counts nothing toward the series but does consume
|
||||
// a slot, so a series can still end in a draw when it is exhausted.
|
||||
|
||||
// The winner needs a strict majority of the games in the series.
|
||||
const int gamesPlayed = pairingPtr->matchGameIds.size();
|
||||
const int gamesNeeded = static_cast<int>(gamesPerMatch / 2 + 1);
|
||||
const int gamesRemaining = static_cast<int>(gamesPerMatch) - gamesPlayed;
|
||||
matchDecided = (pairingPtr->player1MatchWins >= gamesNeeded) || (pairingPtr->player2MatchWins >= gamesNeeded);
|
||||
if (!matchDecided) {
|
||||
// Series exhausted without a strict-majority winner (e.g. a drawn Bo3
|
||||
// leaves it 1-1): record the match as a draw so the round always advances.
|
||||
matchDecided = (gamesRemaining <= 0) && (pairingPtr->player1MatchWins == pairingPtr->player2MatchWins);
|
||||
}
|
||||
|
||||
if (matchDecided) {
|
||||
// Determine match winner
|
||||
int matchWinnerId = -1;
|
||||
if (pairingPtr->player1MatchWins >= gamesNeeded) {
|
||||
matchWinnerId = pairingPtr->player1Id;
|
||||
} else if (pairingPtr->player2MatchWins >= gamesNeeded) {
|
||||
matchWinnerId = pairingPtr->player2Id;
|
||||
}
|
||||
// Otherwise the series was exhausted evenly — matchWinnerId stays -1 (a draw).
|
||||
|
||||
// Set the match winner on the pairing
|
||||
pairingPtr->winnerId = matchWinnerId;
|
||||
|
||||
// Update tournament-level player records
|
||||
if (matchWinnerId == pairingPtr->player1Id) {
|
||||
players[pairingPtr->player1Id].wins += 1;
|
||||
players[pairingPtr->player2Id].losses += 1;
|
||||
} else if (matchWinnerId == pairingPtr->player2Id) {
|
||||
players[pairingPtr->player2Id].wins += 1;
|
||||
players[pairingPtr->player1Id].losses += 1;
|
||||
} else {
|
||||
players[pairingPtr->player1Id].draws += 1;
|
||||
players[pairingPtr->player2Id].draws += 1;
|
||||
}
|
||||
|
||||
// Store for future pairing avoidance
|
||||
allPreviousPairings.append(qMakePair(pairingPtr->player1Id, pairingPtr->player2Id));
|
||||
} else {
|
||||
// Match not decided — spawn the next sub-game outside all locks
|
||||
seriesContinues = true;
|
||||
p1 = pairingPtr->player1Id;
|
||||
p2 = pairingPtr->player2Id;
|
||||
}
|
||||
|
||||
broadcastTournamentState(ges);
|
||||
}
|
||||
|
||||
if (seriesContinues) {
|
||||
QMetaObject::invokeMethod(
|
||||
this,
|
||||
[this, p1, p2] {
|
||||
createMatchGame(p1, p2);
|
||||
|
||||
GameEventStorage nextGes;
|
||||
broadcastTournamentState(nextGes);
|
||||
nextGes.sendToGame(parentGame);
|
||||
},
|
||||
Qt::QueuedConnection);
|
||||
}
|
||||
|
||||
checkAndAdvanceRound(ges);
|
||||
|
||||
return matchDecided;
|
||||
}
|
||||
|
||||
void Server_Tournament::checkAndAdvanceRound(GameEventStorage &ges)
|
||||
{
|
||||
bool roundComplete = false;
|
||||
{
|
||||
QMutexLocker locker(&tournamentMutex);
|
||||
roundComplete = allPairingsDecided();
|
||||
}
|
||||
|
||||
if (roundComplete) {
|
||||
advanceRound(ges);
|
||||
}
|
||||
}
|
||||
|
||||
Event_TournamentState Server_Tournament::buildStateEvent() const
|
||||
{
|
||||
QMutexLocker locker(&tournamentMutex);
|
||||
|
||||
Event_TournamentState state;
|
||||
|
||||
if (started && currentRound >= totalRounds) {
|
||||
state.set_phase(Event_TournamentState::PHASE_FINISHED);
|
||||
} else if (started) {
|
||||
state.set_phase(Event_TournamentState::PHASE_PLAYING);
|
||||
} else {
|
||||
state.set_phase(Event_TournamentState::PHASE_DECK_BUILDING);
|
||||
}
|
||||
|
||||
state.set_current_round(currentRound);
|
||||
state.set_total_rounds(totalRounds);
|
||||
|
||||
// Settings
|
||||
TournamentSettings *settings = state.mutable_settings();
|
||||
settings->set_games_per_match(gamesPerMatch);
|
||||
|
||||
for (auto it = players.constBegin(); it != players.constEnd(); ++it) {
|
||||
TournamentPlayer *p = state.add_players();
|
||||
p->set_player_id(it->playerId);
|
||||
p->set_player_name(it->playerName.toStdString());
|
||||
p->set_wins(it->wins);
|
||||
p->set_losses(it->losses);
|
||||
p->set_draws(it->draws);
|
||||
p->set_deck_submitted(it->deckSubmitted);
|
||||
}
|
||||
|
||||
for (const auto &pairing : currentPairings) {
|
||||
TournamentPairing *p = state.add_pairings();
|
||||
p->set_player1_id(pairing.player1Id);
|
||||
p->set_player2_id(pairing.player2Id);
|
||||
p->set_game_id(pairing.gameId);
|
||||
// -2 = undecided; a decided draw is -1. The is_draw bit distinguishes a
|
||||
// reported draw from an unset winner_id on the wire.
|
||||
if (pairing.winnerId == -1) {
|
||||
p->set_is_draw(true);
|
||||
} else if (pairing.winnerId != -2) {
|
||||
p->set_winner_id(pairing.winnerId);
|
||||
}
|
||||
p->set_player1_match_wins(pairing.player1MatchWins);
|
||||
p->set_player2_match_wins(pairing.player2MatchWins);
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
void Server_Tournament::broadcastTournamentState(GameEventStorage &ges)
|
||||
{
|
||||
ges.enqueueGameEvent(buildStateEvent(), -1);
|
||||
}
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
#ifndef SERVER_TOURNAMENT_H
|
||||
#define SERVER_TOURNAMENT_H
|
||||
|
||||
#include <QList>
|
||||
#include <QMap>
|
||||
#include <QObject>
|
||||
#include <QPointer>
|
||||
#include <QRecursiveMutex>
|
||||
#include <QSet>
|
||||
#include <libcockatrice/protocol/pb/event_tournament_state.pb.h>
|
||||
|
||||
class DeckList;
|
||||
class Server_Game;
|
||||
class Server_MatchGameFactory;
|
||||
class Server_AbstractParticipant;
|
||||
class Server_AbstractUserInterface;
|
||||
class GameEventStorage;
|
||||
|
||||
/** @brief Maximum number of games per match a tournament can be configured with. */
|
||||
constexpr int MAX_GAMES_PER_MATCH = 5;
|
||||
|
||||
class Server_Tournament : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit Server_Tournament(Server_Game *_parentGame, Server_MatchGameFactory *_factory, QObject *parent = nullptr);
|
||||
~Server_Tournament() override;
|
||||
|
||||
void addPlayer(int playerId, const QString &playerName);
|
||||
void removePlayer(int playerId);
|
||||
// Marks an already-starting/started tournament player as dropped: they stop
|
||||
// being paired and their outstanding unstarted match is awarded as a loss.
|
||||
void dropPlayer(int playerId);
|
||||
void startTournament();
|
||||
void advanceRound(GameEventStorage &ges);
|
||||
void recordMatchResult(int playerId1, int playerId2, int winnerId, GameEventStorage &ges);
|
||||
bool recordMatchResultByGameId(int gameId, int winnerId, GameEventStorage &ges);
|
||||
void broadcastTournamentState(GameEventStorage &ges);
|
||||
// Current tournament state message, for replaying to a participant joining late.
|
||||
Event_TournamentState buildStateEvent() const;
|
||||
|
||||
bool isStarted() const
|
||||
{
|
||||
return started;
|
||||
}
|
||||
bool isAllDecksSubmitted() const;
|
||||
int getPlayerCount() const
|
||||
{
|
||||
return players.size();
|
||||
}
|
||||
int getTournamentPlayerIdByName(const QString &name) const;
|
||||
void setPlayerDeckSubmitted(int playerId)
|
||||
{
|
||||
if (players.contains(playerId)) {
|
||||
players[playerId].deckSubmitted = true;
|
||||
}
|
||||
}
|
||||
void setPlayerDeck(int playerId, DeckList *deck);
|
||||
|
||||
void setGamesPerMatch(uint32_t n)
|
||||
{
|
||||
gamesPerMatch = n;
|
||||
}
|
||||
uint32_t getGamesPerMatch() const
|
||||
{
|
||||
return gamesPerMatch;
|
||||
}
|
||||
|
||||
struct TournamentPlayerData
|
||||
{
|
||||
int playerId;
|
||||
QString playerName;
|
||||
int wins = 0;
|
||||
int losses = 0;
|
||||
int draws = 0;
|
||||
bool deckSubmitted = false;
|
||||
bool dropped = false;
|
||||
};
|
||||
|
||||
struct TournamentPairingData
|
||||
{
|
||||
int player1Id;
|
||||
int player2Id;
|
||||
int gameId = -1;
|
||||
int winnerId = -2; // -2 = undecided, -1 = draw, >= 0 = winner player id
|
||||
int player1MatchWins = 0;
|
||||
int player2MatchWins = 0;
|
||||
QList<int> matchGameIds;
|
||||
};
|
||||
|
||||
private:
|
||||
QPointer<Server_Game> parentGame;
|
||||
Server_MatchGameFactory *matchGameFactory;
|
||||
mutable QRecursiveMutex tournamentMutex;
|
||||
QMap<int, TournamentPlayerData> players;
|
||||
QMap<int, DeckList *> submittedDecks;
|
||||
QList<TournamentPairingData> currentPairings;
|
||||
// Players that have already received a bye in a previous round, so no one
|
||||
// gets more than one bye over the whole tournament.
|
||||
QSet<int> byeGivenPlayers;
|
||||
QList<QPair<int, int>> allPreviousPairings;
|
||||
int currentRound;
|
||||
int totalRounds;
|
||||
bool started;
|
||||
uint32_t gamesPerMatch = 1;
|
||||
|
||||
void generateSwissPairings();
|
||||
int calculateTotalRounds() const;
|
||||
bool havePlayed(int p1, int p2) const;
|
||||
bool allPairingsDecided() const;
|
||||
void createMatchGame(int player1Id, int player2Id);
|
||||
void enqueueMatchGameCreation();
|
||||
void checkAndAdvanceRound(GameEventStorage &ges);
|
||||
};
|
||||
|
||||
#endif // SERVER_TOURNAMENT_H
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
#include "server_tournament_lifecycle_strategy.h"
|
||||
|
||||
#include "server_abstract_player.h"
|
||||
#include "server_game.h"
|
||||
|
||||
#include <QLoggingCategory>
|
||||
|
||||
inline Q_LOGGING_CATEGORY(TournamentLifecycleLog, "tournament_lifecycle");
|
||||
|
||||
Server_GameLifecycleStrategy::StartAction Server_TournamentLifecycleStrategy::onGameStarting(Server_Game *game)
|
||||
{
|
||||
// Match sub-games start through the normal flow; only the tournament hub game is
|
||||
// managed by this lifecycle.
|
||||
if (game->getTournamentParentGame().data() != nullptr) {
|
||||
return StartAction::ProceedNormal;
|
||||
}
|
||||
|
||||
for (auto *player : game->getPlayers().values()) {
|
||||
if (!player->getDeckList()) {
|
||||
qCWarning(TournamentLifecycleLog)
|
||||
<< "Tournament cannot start: player" << player->getUserInfo()->name().c_str() << "has no deck";
|
||||
return StartAction::Handled;
|
||||
}
|
||||
}
|
||||
|
||||
if (!game->getIsTournamentGame()) {
|
||||
qCWarning(TournamentLifecycleLog) << "Tournament lifecycle used for non-tournament game — falling back";
|
||||
return StartAction::ProceedNormal;
|
||||
}
|
||||
|
||||
game->startTournament();
|
||||
return StartAction::Handled;
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
#ifndef SERVER_TOURNAMENT_LIFECYCLE_STRATEGY_H
|
||||
#define SERVER_TOURNAMENT_LIFECYCLE_STRATEGY_H
|
||||
|
||||
#include "server_game_lifecycle_strategy.h"
|
||||
|
||||
class Server_TournamentLifecycleStrategy : public Server_GameLifecycleStrategy
|
||||
{
|
||||
public:
|
||||
StartAction onGameStarting(Server_Game *game) override;
|
||||
};
|
||||
|
||||
#endif
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
#include "server_tournament_match_result_strategy.h"
|
||||
|
||||
#include "../server_response_containers.h"
|
||||
#include "server_abstract_player.h"
|
||||
#include "server_game.h"
|
||||
#include "server_tournament.h"
|
||||
|
||||
#include <libcockatrice/protocol/pb/event_game_closed.pb.h>
|
||||
#include <libcockatrice/protocol/pb/event_tournament_state.pb.h>
|
||||
|
||||
bool Server_TournamentMatchResultStrategy::onGameFinished(Server_Game *game,
|
||||
int playing,
|
||||
Server_AbstractPlayer *lastPlayer)
|
||||
{
|
||||
// The hub game is owned by the room and may be torn down once its host leaves
|
||||
// and no players remain, while the match sub-games keep running. QPointer keeps
|
||||
// this link checked so a later-finishing match can't touch freed memory.
|
||||
auto *parentGame = game->getTournamentParentGame().data();
|
||||
if (!parentGame || !parentGame->getTournament()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
int winnerId;
|
||||
if (playing == 0) {
|
||||
winnerId = -1;
|
||||
} else {
|
||||
QString winnerName = QString::fromStdString(lastPlayer->getUserInfo()->name());
|
||||
auto *tournament = parentGame->getTournament();
|
||||
winnerId = tournament->getTournamentPlayerIdByName(winnerName);
|
||||
}
|
||||
|
||||
GameEventStorage parentGes;
|
||||
bool matchDecided = parentGame->getTournament()->recordMatchResultByGameId(game->getGameId(), winnerId, parentGes);
|
||||
parentGes.sendToGame(parentGame);
|
||||
|
||||
return matchDecided;
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
#ifndef SERVER_TOURNAMENT_MATCH_RESULT_STRATEGY_H
|
||||
#define SERVER_TOURNAMENT_MATCH_RESULT_STRATEGY_H
|
||||
|
||||
#include "server_match_result_strategy.h"
|
||||
|
||||
class Server_TournamentMatchResultStrategy : public Server_MatchResultStrategy
|
||||
{
|
||||
public:
|
||||
bool onGameFinished(Server_Game *game, int playing, Server_AbstractPlayer *lastPlayer) override;
|
||||
};
|
||||
|
||||
#endif
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
#include "game/game_config.h"
|
||||
#include "game/server_game.h"
|
||||
#include "game/server_player.h"
|
||||
#include "game/server_tournament.h"
|
||||
#include "server_database_interface.h"
|
||||
#include "server_room.h"
|
||||
|
||||
|
|
@ -916,6 +917,9 @@ Server_ProtocolHandler::cmdCreateGame(const Command_CreateGame &cmd, Server_Room
|
|||
int startingLifeTotal = cmd.has_starting_life_total() ? cmd.starting_life_total() : 20;
|
||||
|
||||
bool shareDecklistsOnLoad = cmd.has_share_decklists_on_load() ? cmd.share_decklists_on_load() : false;
|
||||
bool isTournament = cmd.has_is_tournament() ? cmd.is_tournament() : false;
|
||||
int gamesPerMatch =
|
||||
cmd.has_tournament_settings() ? static_cast<int>(cmd.tournament_settings().games_per_match()) : 1;
|
||||
|
||||
const int gameId = databaseInterface->getNextGameId();
|
||||
if (gameId == -1) {
|
||||
|
|
@ -940,6 +944,10 @@ Server_ProtocolHandler::cmdCreateGame(const Command_CreateGame &cmd, Server_Room
|
|||
.shareDecklistsOnLoad = shareDecklistsOnLoad};
|
||||
|
||||
auto *game = new Server_Game(config, room);
|
||||
game->setIsTournamentGame(isTournament);
|
||||
if (isTournament && game->getTournament()) {
|
||||
game->getTournament()->setGamesPerMatch(static_cast<uint32_t>(qBound(1, gamesPerMatch, MAX_GAMES_PER_MATCH)));
|
||||
}
|
||||
|
||||
game->addPlayer(this, rc, asSpectator, asJudge, false);
|
||||
room->addGame(game);
|
||||
|
|
|
|||
|
|
@ -363,6 +363,18 @@ void Server_Room::broadcastGameListUpdate(const ServerInfo_Game &gameInfo, bool
|
|||
sendRoomEvent(prepareRoomEvent(event), sendToIsl);
|
||||
}
|
||||
|
||||
Server_AbstractUserInterface *Server_Room::getUserInterfaceByName(const QString &name) const
|
||||
{
|
||||
usersLock.lockForRead();
|
||||
auto it = users.constFind(name);
|
||||
Server_AbstractUserInterface *result = nullptr;
|
||||
if (it != users.constEnd()) {
|
||||
result = it.value();
|
||||
}
|
||||
usersLock.unlock();
|
||||
return result;
|
||||
}
|
||||
|
||||
void Server_Room::addGame(Server_Game *game)
|
||||
{
|
||||
ServerInfo_Room roomInfo;
|
||||
|
|
|
|||
|
|
@ -135,6 +135,8 @@ public:
|
|||
void addGame(Server_Game *game);
|
||||
void removeGame(Server_Game *game);
|
||||
|
||||
Server_AbstractUserInterface *getUserInterfaceByName(const QString &name) const;
|
||||
|
||||
void sendRoomEvent(RoomEvent *event, bool sendToIsl = true);
|
||||
RoomEvent *prepareRoomEvent(const ::google::protobuf::Message &roomEvent);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ set(PROTO_FILES
|
|||
command_set_sideboard_lock.proto
|
||||
command_set_sideboard_plan.proto
|
||||
command_shuffle.proto
|
||||
command_tournament.proto
|
||||
command_undo_draw.proto
|
||||
commands.proto
|
||||
context_concede.proto
|
||||
|
|
@ -90,7 +91,6 @@ set(PROTO_FILES
|
|||
event_game_log_notice.proto
|
||||
event_game_say.proto
|
||||
event_game_state_changed.proto
|
||||
event_game_state_changed.proto
|
||||
event_join.proto
|
||||
event_join_room.proto
|
||||
event_kicked.proto
|
||||
|
|
@ -118,6 +118,7 @@ set(PROTO_FILES
|
|||
event_set_card_counter.proto
|
||||
event_set_counter.proto
|
||||
event_shuffle.proto
|
||||
event_tournament_state.proto
|
||||
event_user_joined.proto
|
||||
event_user_left.proto
|
||||
event_user_message.proto
|
||||
|
|
@ -184,6 +185,7 @@ set(PROTO_FILES
|
|||
serverinfo_replay_match.proto
|
||||
serverinfo_report.proto
|
||||
serverinfo_room.proto
|
||||
serverinfo_tournament.proto
|
||||
serverinfo_user.proto
|
||||
serverinfo_user_alt.proto
|
||||
serverinfo_user_session.proto
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
syntax = "proto2";
|
||||
import "game_commands.proto";
|
||||
import "serverinfo_tournament.proto";
|
||||
|
||||
message Command_ReportMatchResult {
|
||||
extend GameCommand {
|
||||
optional Command_ReportMatchResult ext = 1037;
|
||||
}
|
||||
|
||||
optional sint32 game_id = 1 [default = -1];
|
||||
// The winning player's ID. A drawn match is reported with is_draw set and
|
||||
// winner_id left unset so a missing report stays distinguishable from a
|
||||
// reported draw via has_winner_id().
|
||||
optional sint32 winner_id = 2 [default = -1];
|
||||
// Whether the match ended in a draw; winner_id is meaningless then.
|
||||
optional bool is_draw = 3;
|
||||
}
|
||||
|
||||
message Command_AdvanceTournament {
|
||||
extend GameCommand {
|
||||
optional Command_AdvanceTournament ext = 1038;
|
||||
}
|
||||
}
|
||||
|
||||
message Command_TournamentSettingsSelect {
|
||||
extend GameCommand {
|
||||
optional Command_TournamentSettingsSelect ext = 1039;
|
||||
}
|
||||
|
||||
optional TournamentSettings settings = 1;
|
||||
}
|
||||
|
|
@ -24,4 +24,7 @@ message Event_GameStateChanged {
|
|||
|
||||
// the amount of seconds since the game started
|
||||
optional uint32 seconds_elapsed = 5;
|
||||
|
||||
// for tournament sub-games: the ID of the parent tournament game (-1 if not a sub-game)
|
||||
optional sint32 parent_game_id = 10 [default = -1];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,22 @@
|
|||
syntax = "proto2";
|
||||
import "game_event.proto";
|
||||
import "serverinfo_tournament.proto";
|
||||
|
||||
message Event_TournamentState {
|
||||
extend GameEvent {
|
||||
optional Event_TournamentState ext = 2027;
|
||||
}
|
||||
|
||||
enum TournamentPhase {
|
||||
PHASE_DECK_BUILDING = 0;
|
||||
PHASE_PLAYING = 1;
|
||||
PHASE_FINISHED = 2;
|
||||
}
|
||||
|
||||
optional TournamentPhase phase = 1;
|
||||
optional uint32 current_round = 2;
|
||||
optional uint32 total_rounds = 3;
|
||||
repeated TournamentPlayer players = 4;
|
||||
repeated TournamentPairing pairings = 5;
|
||||
optional TournamentSettings settings = 6;
|
||||
}
|
||||
|
|
@ -180,6 +180,15 @@ message GameCommand {
|
|||
/// Server: Server_Player::cmdSetPlaymat
|
||||
/// Client: reflected via player properties changed event
|
||||
SET_PLAYMAT = 1035;
|
||||
|
||||
/// Report the result of a tournament match sub-game.
|
||||
REPORT_MATCH_RESULT = 1037;
|
||||
|
||||
/// Advance the tournament to the next round.
|
||||
ADVANCE_TOURNAMENT = 1038;
|
||||
|
||||
/// Select tournament settings.
|
||||
TOURNAMENT_SETTINGS_SELECT = 1039;
|
||||
}
|
||||
|
||||
extensions 100 to max;
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ message GameEvent {
|
|||
CHANGE_ZONE_PROPERTIES = 2020;
|
||||
REVERSE_TURN = 2021;
|
||||
GAME_LOG_NOTICE = 2022;
|
||||
TOURNAMENT_STATE = 2027;
|
||||
}
|
||||
optional sint32 player_id = 1 [default = -1];
|
||||
extensions 100 to max;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
syntax = "proto2";
|
||||
import "serverinfo_tournament.proto";
|
||||
message RoomCommand {
|
||||
enum RoomCommandType {
|
||||
LEAVE_ROOM = 1000;
|
||||
|
|
@ -69,6 +70,12 @@ message Command_CreateGame {
|
|||
|
||||
// share decklists with all players when selected
|
||||
optional bool share_decklists_on_load = 14;
|
||||
|
||||
// tournament settings shared by game creation and mid-game changes
|
||||
optional TournamentSettings tournament_settings = 15;
|
||||
|
||||
// whether this is a tournament game
|
||||
optional bool is_tournament = 16;
|
||||
}
|
||||
|
||||
message Command_JoinGame {
|
||||
|
|
|
|||
|
|
@ -65,4 +65,7 @@ message ServerInfo_Game {
|
|||
|
||||
// the current host of the game, which may differ from the creator after a host transfer
|
||||
optional ServerInfo_User host_info = 53;
|
||||
|
||||
// whether this game is a tournament game
|
||||
optional bool is_tournament = 54;
|
||||
}
|
||||
|
|
|
|||
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