Compare commits

...

2 commits

Author SHA1 Message Date
BruebachL
ed4eb1cb31
[Server/Client/Protocol] Reporting users + moderation queue functionality (#7091)
Some checks are pending
Build Desktop / Configure (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Docker Image / amd64 & arm64 (push) Waiting to run
* [Server/Client/Protocol] Reporting users + moderation queue functionality

Took 6 minutes

Took 3 minutes

Took 8 seconds

Took 11 minutes

Took 12 minutes

Took 7 minutes

Took 15 seconds

Took 2 minutes

Took 1 minute


Took 30 seconds

Took 16 seconds

* CI Fix

Took 6 minutes

* CI Fix

Took 6 minutes

* [Protocol] Add moderation investigation commands

Adds the protocol layer for the moderation investigation suite:
- Command_GetUserSessions/GetUserAlts/GetModeratorLastLogins/ResetUserPassword/RemoveUserAvatar (1013-1017)
- Response extensions 1215-1219 with ServerInfo messages for sessions, alts, and staff logins
- last_login on Response_ReportUserInfo and warning_il on Response_WarnList

Took 2 minutes

* [Utility] Add warning categories parser with infraction levels

Parses the server's 'officialwarnings' setting (comma-separated, optional
'|IL' suffix) into WarningCategory structs so the client can display the
infraction level of each warning category. Includes GTest coverage.

* [Server] Add moderation investigation tools

Implements the server side of the moderation suite:
- getUserSessions/getUserAlts/getModeratorLastLogins/removeUserAvatar DB methods
- Handlers for all five new commands with audit records (PASSWORD_RESET,
  REMOVE_USER_AVATAR); password resets return a generated temporary password
- cmdGetWarnList now reports per-category infraction levels from the
  officialwarnings setting; cmdReportUserInfo reports last_login
- Update servatrice.ini.example with the warning taxonomy
- Password/avatar mutations report RespNameNotFound when the user does not exist

* [Client] Add moderation tab with investigate, password reset, and avatar removal

- New Moderation tab: search a user to show account info, alternate
  accounts, login sessions, and staff last logins; actions to reset the
  user's password (shows the generated temporary password) and remove the
  user's avatar
- 'Investigate user' entry in the user context menu opens the tab pre-loaded
  for that user
- Warning dialog shows the infraction level of each warning category
- Tab wired into TabSupervisor with a moderator-gated menu action, shortcut,
  and tabs.ini persistence (default closed)

* [Server/Client/Protocol] Address PR #7091 review: security, bug, and perf fixes

Security:
- Promote RESET_USER_PASSWORD to admin-only dispatch (was moderator-accessible)
- Reject password reset on users with equal/higher privilege than caller
- Notify affected user via Event_NotifyUser::CUSTOM when password is reset
- Add server-side category whitelist for reports
- Drop reporter name fallback in comment/details authorization (ID-only)
- Force password change: new DB column + login enforcement + client disconnect

Bugs:
- XSS via QTextEdit::append() → insertPlainText() in report tab and utils
- allNotified initialized to true even with empty recipients list
- Warning combo box: use currentData() instead of baked-in display text
- Report resolution now records who resolved (resolved_by column + audit)

Performance:
- IP-correlation subquery: add 6-month window + LIMIT 200
- getUserSessions: clamp limit to 500

Non-blocking:
- Palette-aware colors in report_utils.cpp (dark/light mode)
- Report list pagination: offset/limit fields + total_count in response
- SessionCommand enum gap comment for reserved values 1201-1203

Schema: 36→37 (force_password_change), 37→38 (resolved_by)

Took 12 minutes


Took 16 seconds

* Fix macOs pedantry

Took 5 minutes

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-08-21 15:00:13 +02:00
BruebachL
9eafd90a91
[Game] Playmats (#7101)
* [Game] Playmats

Took 19 seconds

Took 1 minute

* [Playmats] Add fixed override and configurable fallbacks to settings.

Took 29 minutes

Took 43 seconds

* Add main to test.

Took 1 minute

Took 29 seconds

* Move settings to own group

Took 11 minutes

* Some attempts to refresh macOS compositor

Took 2 minutes

* Try something else

Took 17 minutes

* Don't manipulate live list

Took 11 minutes

* Change things about resolution, address comments.

Took 45 minutes

Took 12 minutes

* Comments.

Took 14 minutes

Took 8 seconds

* Re-order settings menu location

Took 2 minutes

* Rename PlaymatResolution to Info and add enums

Took 8 minutes

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-08-21 10:40:49 +02:00
131 changed files with 6699 additions and 71 deletions

View file

@ -43,7 +43,9 @@ set(cockatrice_SOURCES
src/interface/widgets/dialogs/dlg_load_remote_deck.cpp
src/interface/widgets/dialogs/dlg_local_game_options.cpp
src/interface/widgets/dialogs/dlg_manage_sets.cpp
src/interface/widgets/dialogs/dlg_my_reports.cpp
src/interface/widgets/dialogs/dlg_register.cpp
src/interface/widgets/dialogs/dlg_report_user.cpp
src/interface/widgets/dialogs/dlg_select_set_for_cards.cpp
src/interface/widgets/dialogs/dlg_settings.cpp
src/interface/widgets/dialogs/dlg_startup_card_check.cpp
@ -240,6 +242,10 @@ set(cockatrice_SOURCES
src/interface/widgets/printing_selector/printing_selector_card_selection_widget.cpp
src/interface/widgets/printing_selector/printing_selector_card_sorting_widget.cpp
src/interface/widgets/printing_selector/set_name_and_collectors_number_display_widget.cpp
src/interface/widgets/playmat/playmat_collection_dialog.cpp
src/interface/widgets/playmat/playmat_collection_dialog.h
src/interface/widgets/playmat/playmat_preview_widget.cpp
src/interface/widgets/playmat/playmat_settings_dialog.cpp
src/interface/widgets/quick_settings/settings_button_widget.cpp
src/interface/widgets/quick_settings/settings_popup_widget.cpp
src/interface/widgets/replay/replay_manager.cpp
@ -278,6 +284,8 @@ set(cockatrice_SOURCES
src/interface/widgets/settings_page/user_interface_settings_page.cpp
src/interface/widgets/utility/custom_line_edit.cpp
src/interface/widgets/utility/get_text_with_max.cpp
src/interface/widgets/utility/report_utils.cpp
src/interface/widgets/utility/report_utils.h
src/interface/widgets/utility/sequence_edit.cpp
src/interface/widgets/utility/visibility_change_listener.cpp
src/interface/widgets/utility/visibility_change_listener.h
@ -371,6 +379,8 @@ set(cockatrice_SOURCES
src/interface/widgets/tabs/tab_home.cpp
src/interface/widgets/tabs/tab_logs.cpp
src/interface/widgets/tabs/tab_message.cpp
src/interface/widgets/tabs/tab_moderation.cpp
src/interface/widgets/tabs/tab_report.cpp
src/interface/widgets/tabs/tab_replays.cpp
src/interface/widgets/tabs/tab_room.cpp
src/interface/widgets/tabs/tab_server.cpp

View file

@ -296,6 +296,15 @@ void ConnectionController::onLoginError(int r,
return;
}
case Response::RespPasswordChangeRequired: {
QMessageBox::information(
dialogParent, tr("Password Change Required"),
tr("An administrator has reset your password. Please contact your server administrator to obtain "
"your temporary password, then log in and change it via Account -> Change Password."));
remoteClient->disconnectFromServer();
return;
}
case Response::RespServerFull: {
QMessageBox::critical(dialogParent, tr("Server Full"),
tr("The server has reached its maximum user capacity, please check back later."));

View file

@ -786,6 +786,10 @@ private:
ShortcutGroup::Tabs)},
{"Tabs/aTabLogs",
ShortcutKey(QT_TRANSLATE_NOOP("shortcutsTab", "Logs"), parseSequenceString(""), ShortcutGroup::Tabs)},
{"Tabs/aTabReport",
ShortcutKey(QT_TRANSLATE_NOOP("shortcutsTab", "Report Queue"), parseSequenceString(""), ShortcutGroup::Tabs)},
{"Tabs/aTabModeration",
ShortcutKey(QT_TRANSLATE_NOOP("shortcutsTab", "Moderation"), parseSequenceString(""), ShortcutGroup::Tabs)},
};
};

View file

@ -285,6 +285,10 @@ void GameEventHandler::eventGameStateChanged(const Event_GameStateChanged &event
emit playerJoined(prop);
}
player->processPlayerInfo(playerInfo);
// Extract playmat from player properties for opponent display
if (prop.has_playmat_params()) {
player->setPlaymatFromProperties(prop);
}
if (player->getPlayerInfo()->getLocal()) {
emit localPlayerDeckSelected(player, playerId, playerInfo);
} else {
@ -351,6 +355,11 @@ void GameEventHandler::eventPlayerPropertiesChanged(const Event_PlayerProperties
const ServerInfo_PlayerProperties &prop = event.player_properties();
emit playerPropertiesChanged(prop, eventPlayerId);
// Update playmat from player properties
if (prop.has_playmat_params()) {
player->setPlaymatFromProperties(prop);
}
const auto contextType = static_cast<GameEventContext::ContextType>(getPbExtension(context));
switch (contextType) {
case GameEventContext::READY_START: {

View file

@ -250,6 +250,22 @@ void PlayerLogic::setDeck(const DeckList &_deck)
emit deckChanged();
}
void PlayerLogic::setPlaymatFromProperties(const ServerInfo_PlayerProperties &props)
{
if (props.has_playmat_params() && !props.playmat_params().card_name().empty()) {
const auto &pp = props.playmat_params();
remotePlaymatCard = {QString::fromStdString(pp.card_name()), QString::fromStdString(pp.card_provider_id())};
remotePlaymatParams = {qBound(0.0, pp.margin_pct_l(), 0.95), qBound(0.0, pp.margin_pct_r(), 0.95),
qBound(0.0, pp.vertical_offset(), 1.0), qBound(0.1, pp.zoom(), 4.0)};
hasRemotePlaymat = true;
} else {
remotePlaymatCard = CardRef{};
remotePlaymatParams = PlaymatParams{};
hasRemotePlaymat = false;
}
emit playmatChanged();
}
CounterState *PlayerLogic::addCounter(const ServerInfo_Counter &counter)
{
return addCounter(counter.id(), QString::fromStdString(counter.name()),

View file

@ -17,6 +17,7 @@
#include "../zones/table_zone_logic.h"
#include "player_event_handler.h"
#include "player_info.h"
#include "player_manager.h"
#include <QInputDialog>
#include <QLoggingCategory>
@ -72,6 +73,8 @@ signals:
const QList<const ServerInfo_Card *> &cardList,
bool withWritePermission);
void deckChanged();
/** @brief Emitted when the remote playmat (card/params) is updated from player properties. */
void playmatChanged();
void newCardAdded(AbstractCardItem *card);
void requestCardMenuUpdate(const CardItem *card);
void counterAdded(CounterState *state);
@ -226,6 +229,20 @@ public:
void setZoneId(int _zoneId);
void setPlaymatFromProperties(const ServerInfo_PlayerProperties &props);
const CardRef &getRemotePlaymatCard() const
{
return remotePlaymatCard;
}
const PlaymatParams &getRemotePlaymatParams() const
{
return remotePlaymatParams;
}
bool getHasRemotePlaymat() const
{
return hasRemotePlaymat;
}
private:
AbstractGame *game;
PlayerInfo *playerInfo;
@ -243,6 +260,11 @@ private:
bool dialogSemaphore;
QList<CardItem *> cardsToDelete;
// Playmat from player properties (for opponent display)
CardRef remotePlaymatCard;
PlaymatParams remotePlaymatParams;
bool hasRemotePlaymat = false;
};
class AnnotationDialog : public QInputDialog

View file

@ -14,12 +14,15 @@
#include <QMessageBox>
#include <libcockatrice/card/database/card_database.h>
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/deck_list/playmat_resolver.h>
#include <libcockatrice/protocol/pb/command_deck_select.pb.h>
#include <libcockatrice/protocol/pb/command_ready_start.pb.h>
#include <libcockatrice/protocol/pb/command_set_playmat.pb.h>
#include <libcockatrice/protocol/pb/command_set_sideboard_lock.pb.h>
#include <libcockatrice/protocol/pb/command_set_sideboard_plan.pb.h>
#include <libcockatrice/protocol/pb/response_deck_download.pb.h>
#include <libcockatrice/protocol/pending_command.h>
#include <libcockatrice/settings/interface_settings.h>
#include <libcockatrice/settings/visual_deck_storage_settings.h>
#include <libcockatrice/utility/string_limits.h>
@ -100,6 +103,9 @@ DeckViewContainer::DeckViewContainer(int _playerId, TabGame *parent)
connect(&SettingsCache::instance().visualDeckStorage(), &VisualDeckStorageSettings::visualDeckStorageInGameChanged,
this, &DeckViewContainer::setVisualDeckStorageExists);
connect(&SettingsCache::instance().userInterface(), &InterfaceSettings::playmatSettingsChanged, this,
&DeckViewContainer::onPlaymatSettingsChanged);
switchToDeckSelectView();
}
@ -277,6 +283,8 @@ void DeckViewContainer::loadDeckFromFile(const QString &filePath)
void DeckViewContainer::loadDeckFromDeckList(const DeckList &deck)
{
currentDeck = deck;
QString deckString = deck.writeToString_Native();
if (deckString.length() > MAX_FILE_LENGTH) {
@ -289,6 +297,52 @@ void DeckViewContainer::loadDeckFromDeckList(const DeckList &deck)
PendingCommand *pend = parentGame->getGame()->getGameEventHandler()->prepareGameCommand(cmd);
connect(pend, &PendingCommand::finished, this, &DeckViewContainer::deckSelectFinished);
parentGame->getGame()->getGameEventHandler()->sendGameCommand(pend, playerId);
resolveAndSendPlaymat();
}
void DeckViewContainer::resolveAndSendPlaymat()
{
if (currentDeck.getCardRefList().isEmpty() && currentDeck.getPlaymat().card.isEmpty()) {
return;
}
const auto &settings = SettingsCache::instance().userInterface();
const auto fallbackBehavior = static_cast<PlaymatFallbackMode>(settings.getPlaymatFallbackBehavior());
QList<PlaymatInfo> fallbackList = settings.getPlaymatFallbackList();
// In random mode with 2+ entries, remove the last-resolved mat to avoid repeats.
if (fallbackBehavior == PlaymatFallbackModeRandom && fallbackList.size() > 1) {
fallbackList.removeAll(lastResolvedPlaymat);
}
const PlaymatInfo resolved =
resolvePlaymatForDeck(currentDeck, fallbackList, static_cast<PlaymatMode>(settings.getPlaymatMode()),
fallbackBehavior, playmatRotationIndex);
lastResolvedPlaymat = resolved;
Command_SetPlaymat playmatCmd;
auto *pp = playmatCmd.mutable_playmat_params();
pp->set_card_name(resolved.card.name.toStdString());
pp->set_card_provider_id(resolved.card.providerId.toStdString());
pp->set_margin_pct_l(resolved.params.marginPctL);
pp->set_margin_pct_r(resolved.params.marginPctR);
pp->set_vertical_offset(resolved.params.verticalOffset);
pp->set_zoom(resolved.params.zoom);
PendingCommand *playmatPend = parentGame->getGame()->getGameEventHandler()->prepareGameCommand(playmatCmd);
parentGame->getGame()->getGameEventHandler()->sendGameCommand(playmatPend, playerId);
}
void DeckViewContainer::onPlaymatSettingsChanged()
{
resolveAndSendPlaymat();
}
void DeckViewContainer::advancePlaymatRotation()
{
playmatRotationIndex++;
}
void DeckViewContainer::loadRemoteDeck()
@ -379,6 +433,10 @@ void DeckViewContainer::sideboardPlanChanged()
*/
void DeckViewContainer::sendReadyStartCommand(bool ready)
{
if (ready) {
resolveAndSendPlaymat();
}
Command_ReadyStart cmd;
cmd.set_ready(ready);
parentGame->getGame()->getGameEventHandler()->sendGameCommand(cmd, playerId);
@ -416,6 +474,7 @@ void DeckViewContainer::setSideboardLocked(bool locked)
void DeckViewContainer::setDeck(const DeckList &deck)
{
currentDeck = deck;
deckView->setDeck(deck);
switchToDeckLoadedView();
}

View file

@ -57,6 +57,9 @@ private:
VisualDeckStorageWidget *visualDeckStorageWidget;
TabGame *parentGame;
int playerId;
int playmatRotationIndex = 0; ///< Per-match cursor for round-robin playmat mode.
DeckList currentDeck; ///< Cached deck for live settings re-resolution.
PlaymatInfo lastResolvedPlaymat; ///< Tracks last sent playmat to avoid repeats in random mode.
void tryCreateVisualDeckStorageWidget();
void sendReadyStartCommand(bool ready);
@ -75,6 +78,7 @@ private slots:
void sideboardLockButtonClicked();
void updateSideboardLockButtonText();
void refreshShortcuts();
void onPlaymatSettingsChanged();
signals:
void newCardAdded(AbstractCardItem *card);
void notIdle();
@ -87,6 +91,8 @@ public:
void setSideboardLocked(bool locked);
void setDeck(const DeckList &deck);
void setVisualDeckStorageExists(bool exists);
void advancePlaymatRotation();
void resolveAndSendPlaymat();
public slots:
void loadDeckFromFile(const QString &filePath);

View file

@ -252,17 +252,27 @@ void GameScene::adjustPlayerRotation(int rotationAdjustment)
*/
void GameScene::rearrange()
{
int firstPlayerIndex = 0;
auto playersPlaying = collectActivePlayers(firstPlayerIndex);
playersPlaying = rotatePlayers(playersPlaying, firstPlayerIndex);
if (rearranging) {
needsReArrange = true;
return;
}
rearranging = true;
do {
needsReArrange = false;
int columns = determineColumnCount(playersPlaying.size());
QSizeF sceneSize = computeSceneSizeAndPlayerLayout(playersPlaying, columns);
int firstPlayerIndex = 0;
auto playersPlaying = collectActivePlayers(firstPlayerIndex);
playersPlaying = rotatePlayers(playersPlaying, firstPlayerIndex);
phasesToolbar->setHeight(sceneSize.height());
setSceneRect(0, 0, sceneSize.width(), sceneSize.height());
int columns = determineColumnCount(playersPlaying.size());
QSizeF sceneSize = computeSceneSizeAndPlayerLayout(playersPlaying, columns);
processViewSizeChange(viewSize);
phasesToolbar->setHeight(sceneSize.height());
setSceneRect(0, 0, sceneSize.width(), sceneSize.height());
processViewSizeChange(viewSize);
} while (needsReArrange);
rearranging = false;
}
// ---------- View Size ----------
@ -459,8 +469,14 @@ void GameScene::resizeColumnsAndPlayers(const QList<qreal> &minWidthByColumn, qr
qreal extraWidthPerColumn = (newWidth - minWidth) / playersByColumn.size();
qreal newx = phasesToolbar->getWidth();
for (int col = 0; col < playersByColumn.size(); ++col) {
for (PlayerGraphicsItem *player : playersByColumn[col]) {
// Snapshot the columns: resizing a player's table can synchronously trigger
// GameScene::rearrange (table width -> sizeChanged -> updateBoundingRect ->
// sizeChanged -> rearrange), and rearrange rebuilds playersByColumn. Iterating
// the live container across that re-entrant call would use invalidated iterators.
const QList<QList<PlayerGraphicsItem *>> columns = playersByColumn;
for (int col = 0; col < columns.size(); ++col) {
for (PlayerGraphicsItem *player : columns[col]) {
player->processSceneSizeChange(minWidthByColumn[col] + extraWidthPerColumn);
player->setPos(newx, player->y());
}

View file

@ -55,6 +55,8 @@ private:
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
/**
* @brief Updates which card is currently hovered based on scene coordinates.

View file

@ -114,6 +114,7 @@ void GameView::startRubberBand(const QPointF &_selectionOrigin)
}
selectionOrigin = _selectionOrigin;
previousBandRect = QRect();
rubberBand->setGeometry(QRect(mapFromScene(selectionOrigin), QSize(0, 0)));
rubberBand->show();
}
@ -128,7 +129,17 @@ void GameView::resizeRubberBand(const QPointF &cursorPoint, int selectedCount)
QPoint cursor = cursorPoint.toPoint();
QRect rect = QRect(mapFromScene(selectionOrigin), cursor).normalized();
rubberBand->setGeometry(rect);
if (viewport()) {
// Repaint the union of the previous and current band rects: the vacated
// strip of a child widget is not reliably invalidated on all platforms
// (notably macOS), leaving stale pixels under the selection.
QRect dirty = previousBandRect.isNull() ? rect : previousBandRect.united(rect);
dirty.adjust(-1, -1, 1, 1);
viewport()->update(dirty);
previousBandRect = rect;
}
if (!SettingsCache::instance().userInterface().getShowDragSelectionCount()) {
dragCountLabel->hide();
@ -171,7 +182,13 @@ void GameView::stopRubberBand()
return;
}
// Same rationale as resizeRubberBand: repaint the last known band area
// since hiding a child widget doesn't reliably invalidate its region.
rubberBand->hide();
if (viewport() && !previousBandRect.isNull()) {
viewport()->update(previousBandRect.adjusted(-1, -1, 1, 1));
previousBandRect = QRect();
}
dragCountLabel->hide();
}

View file

@ -27,6 +27,7 @@ private:
QWidget *tallyContainer;
QGridLayout *tallyLayout;
QPointF selectionOrigin;
QRect previousBandRect; ///< Last rubber-band rect for targeted repaint
QList<TallyRow> cachedTallyRows; ///< Cached entries to avoid redundant rebuilds
QSize rebuildTallyLabels(const QList<TallyRow> &entries);

View file

@ -1,6 +1,9 @@
#include "player_graphics_item.h"
#include "../../game/player/player_actions.h"
#include "../../interface/card_picture_loader/card_picture_loader.h"
#include "../../interface/widgets/cards/art_crop_attribution.h"
#include "../../interface/widgets/playmat/playmat_utils.h"
#include "../../interface/widgets/tabs/tab_game.h"
#include "../board/abstract_card_item.h"
#include "../board/counter_general.h"
@ -13,6 +16,9 @@
#include "player_dialogs.h"
#include <QGraphicsView>
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/deck_list/deck_list.h>
#include <libcockatrice/deck_list/playmat_resolver.h>
#include <libcockatrice/settings/interface_settings.h>
PlayerGraphicsItem::PlayerGraphicsItem(PlayerLogic *_player) : player(_player)
@ -28,6 +34,10 @@ PlayerGraphicsItem::PlayerGraphicsItem(PlayerLogic *_player) : player(_player)
connect(player, &PlayerLogic::counterAdded, this, &PlayerGraphicsItem::onCounterAdded);
connect(player, &PlayerLogic::counterRemoved, this, &PlayerGraphicsItem::onCounterRemoved);
connect(player, &PlayerLogic::deckChanged, this, &PlayerGraphicsItem::updatePlaymat);
connect(player, &PlayerLogic::playmatChanged, this, &PlayerGraphicsItem::updatePlaymat);
connect(&SettingsCache::instance().userInterface(), &InterfaceSettings::playmatVisibilityChanged, this,
[this](int) { updatePlaymat(); });
playerMenu = new PlayerMenu(this);
@ -67,6 +77,9 @@ PlayerGraphicsItem::PlayerGraphicsItem(PlayerLogic *_player) : player(_player)
connect(tableZoneGraphicsItem, &TableZone::sizeChanged, this, &PlayerGraphicsItem::updateBoundingRect);
connect(this, &PlayerGraphicsItem::playmatChanged, tableZoneGraphicsItem, &TableZone::onPlaymatChanged);
connect(this, &PlayerGraphicsItem::playmatChanged, stackZoneGraphicsItem, &StackZone::onPlaymatChanged);
updateBoundingRect();
rearrangeZones();
@ -112,7 +125,6 @@ void PlayerGraphicsItem::initializeZones()
rfgZoneGraphicsItem->setPos(base + QPointF(0, 2 * h + h2 + 10));
tableZoneGraphicsItem = new TableZone(player->getTableZone(), mirrored, this);
connect(tableZoneGraphicsItem, &TableZone::sizeChanged, this, &PlayerGraphicsItem::updateBoundingRect);
connect(this, &PlayerGraphicsItem::mirroredChanged, tableZoneGraphicsItem, &TableZone::setMirrored);
stackZoneGraphicsItem =
@ -155,10 +167,61 @@ qreal PlayerGraphicsItem::getMinimumWidth() const
return result;
}
void PlayerGraphicsItem::paint(QPainter * /*painter*/,
const QStyleOptionGraphicsItem * /*option*/,
QWidget * /*widget*/)
void PlayerGraphicsItem::paint(QPainter *painter, const QStyleOptionGraphicsItem *, QWidget *)
{
if (!hasPlaymat || playmatPixmap.isNull()) {
return;
}
// Calculate the combined bounding rect of stack + table zones
QPointF stackPos = stackZoneGraphicsItem->pos();
QPointF tablePos = tableZoneGraphicsItem->pos();
QSizeF stackSize = stackZoneGraphicsItem->boundingRect().size();
QSizeF tableSize = tableZoneGraphicsItem->boundingRect().size();
// Combined area: from stack left edge to table right edge
double combinedLeft = qMin(stackPos.x(), tablePos.x());
double combinedTop = qMin(stackPos.y(), tablePos.y());
double combinedRight = qMax(stackPos.x() + stackSize.width(), tablePos.x() + tableSize.width());
double combinedBottom = qMax(stackPos.y() + stackSize.height(), tablePos.y() + tableSize.height());
QRectF combinedArea(combinedLeft, combinedTop, combinedRight - combinedLeft, combinedBottom - combinedTop);
const QRectF srcRect = computeArtSourceRect(playmatPixmap.size(), playmatParams);
const QRectF dstRect = coverFitRect(combinedArea, srcRect.size());
painter->save();
painter->setClipRect(combinedArea);
painter->setRenderHint(QPainter::SmoothPixmapTransform, true);
// Render from a down-scaled copy of the art so the full-resolution source
// pixmap is never re-sampled at a tiny device size (also much cheaper than
// scaling it on every frame).
const QPixmap scaledPixmap = scaledPlaymatFor(srcRect, painter->worldTransform().mapRect(dstRect).size());
painter->drawPixmap(dstRect, scaledPixmap, QRectF(scaledPixmap.rect()));
painter->restore();
if (!playmatAttribution.isEmpty()) {
paintArtAttribution(*painter, combinedArea, playmatAttribution, Qt::AlignRight | Qt::AlignBottom, 0.8);
}
}
QPixmap PlayerGraphicsItem::scaledPlaymatFor(const QRectF &srcRect, const QSizeF &deviceDstSize)
{
// Bucket the render size so the source pixmap is re-scaled at most once per
// zoom step instead of once per frame.
constexpr int bucketSize = 32;
const QSize target = QSize(qMax(1, qRound(deviceDstSize.width() / bucketSize) * bucketSize),
qMax(1, qRound(deviceDstSize.height() / bucketSize) * bucketSize))
.boundedTo(srcRect.toAlignedRect().size());
if (scaledPlaymatKey != target) {
const QPixmap crop = playmatPixmap.copy(srcRect.toAlignedRect());
scaledPlaymatPixmap = crop.scaled(target, Qt::KeepAspectRatio, Qt::SmoothTransformation);
scaledPlaymatKey = target;
}
return scaledPlaymatPixmap;
}
void PlayerGraphicsItem::processSceneSizeChange(int newPlayerWidth)
@ -303,3 +366,100 @@ void PlayerGraphicsItem::updateBoundingRect()
emit sizeChanged();
}
void PlayerGraphicsItem::updatePlaymat()
{
int visibility = SettingsCache::instance().userInterface().getPlaymatVisibility();
// "Don't use playmats" — never show
if (visibility == PlaymatVisibilityNone) {
clearPlaymat();
return;
}
// "Show own playmat only" — hide playmats for remote players
if (visibility == PlaymatVisibilityOwnOnly && !player->getPlayerInfo()->getLocal()) {
clearPlaymat();
return;
}
CardRef playmatCard;
PlaymatParams params;
if (player->getHasRemotePlaymat()) {
// Prefer the server-confirmed playmat (updated by Command_SetPlaymat).
playmatCard = player->getRemotePlaymatCard();
params = player->getRemotePlaymatParams();
} else if (player->getPlayerInfo()->getLocal()) {
// Local player without a server broadcast yet: apply the full
// settings-based resolution chain (mode, fallback list, behavior).
const auto &settings = SettingsCache::instance().userInterface();
const PlaymatInfo resolved = resolvePlaymatForDeck(
player->getDeck(), settings.getPlaymatFallbackList(), static_cast<PlaymatMode>(settings.getPlaymatMode()),
static_cast<PlaymatFallbackMode>(settings.getPlaymatFallbackBehavior()), 0);
playmatCard = resolved.card;
params = resolved.params;
} else {
// Opponent without a server broadcast: use the deck-embedded playmat.
const DeckList &deck = player->getDeck();
const PlaymatInfo &deckPlaymat = deck.getPlaymat();
if (!deckPlaymat.card.isEmpty()) {
playmatCard = deckPlaymat.card;
params = deckPlaymat.params;
}
}
if (playmatCard.isEmpty()) {
clearPlaymat();
return;
}
playmatParams = params;
scaledPlaymatKey = QSize(); // the art crop depends on the params, drop any cached scale
ExactCard card = CardDatabaseManager::query()->getCard(playmatCard);
if (!card) {
clearPlaymat();
return;
}
playmatAttribution = buildArtAttribution(card);
QPixmap fullRes;
CardPictureLoader::getPixmap(fullRes, card, QSize(745, 1040));
if (fullRes.isNull()) {
disconnect(playmatPixmapConnection);
CardInfo *cardInfo = card.getCardPtr().data();
if (cardInfo) {
playmatPixmapConnection =
connect(cardInfo, &CardInfo::pixmapUpdated, this, &PlayerGraphicsItem::onPlaymatPixmapReady);
}
return;
}
if (!hasPlaymat) {
hasPlaymat = true;
emit playmatChanged(true);
}
playmatPixmap = fullRes;
update();
}
void PlayerGraphicsItem::clearPlaymat()
{
disconnect(playmatPixmapConnection);
playmatAttribution.clear();
if (hasPlaymat) {
hasPlaymat = false;
playmatPixmap = QPixmap();
scaledPlaymatKey = QSize();
emit playmatChanged(false);
update();
}
}
void PlayerGraphicsItem::onPlaymatPixmapReady()
{
updatePlaymat();
}

View file

@ -11,6 +11,7 @@
#include "../game_scene.h"
#include <QGraphicsObject>
#include <libcockatrice/deck_list/deck_list.h>
class HandZone;
class PileZone;
@ -126,6 +127,7 @@ signals:
void playerCountChanged();
void mirroredChanged(bool isMirrored);
void cardInfoRequested(const CardRef &cardRef);
void playmatChanged(bool hasPlaymat);
private:
PlayerLogic *player;
@ -146,9 +148,23 @@ private:
bool mirrored;
bool handVisible = false;
QPixmap playmatPixmap;
QPixmap scaledPlaymatPixmap; // down-scaled copy of playmatPixmap for the current render size
QSize scaledPlaymatKey; // size bucket scaledPlaymatPixmap was rendered for
PlaymatParams playmatParams;
QString playmatAttribution;
bool hasPlaymat = false;
QMetaObject::Connection playmatPixmapConnection;
private slots:
void updateBoundingRect();
void rearrangeZones();
void clearPlaymat();
void updatePlaymat();
void onPlaymatPixmapReady();
private:
QPixmap scaledPlaymatFor(const QRectF &srcRect, const QSizeF &deviceDstSize);
};
#endif // COCKATRICE_PLAYER_GRAPHICS_ITEM_H

View file

@ -31,8 +31,22 @@ QRectF StackZone::boundingRect() const
void StackZone::paint(QPainter *painter, const QStyleOptionGraphicsItem * /*option*/, QWidget * /*widget*/)
{
QBrush brush = themeManager->getExtraBgBrush(ThemeManager::Stack, getLogic()->getPlayer()->getZoneId());
painter->fillRect(boundingRect(), brush);
if (playmatActive) {
// Subtle overlay to distinguish stack zone from table zone (slightly darker)
painter->fillRect(boundingRect(), QColor(0, 0, 0, 80));
} else {
QBrush brush = themeManager->getExtraBgBrush(ThemeManager::Stack, getLogic()->getPlayer()->getZoneId());
painter->fillRect(boundingRect(), brush);
}
}
void StackZone::onPlaymatChanged(bool active)
{
playmatActive = active;
// See TableZone::onPlaymatChanged for the rationale. Translucent overlay
// over a dynamic playmat should not be held in the device cache.
setCacheMode(active ? QGraphicsItem::NoCache : QGraphicsItem::DeviceCoordinateCache);
update();
}
void StackZone::handleDropEvent(const QList<CardDragItem *> &dragItems,

View file

@ -15,9 +15,13 @@ class StackZone : public SelectZone
Q_OBJECT
private:
qreal zoneHeight;
bool playmatActive = false;
private slots:
void updateBg();
public slots:
void onPlaymatChanged(bool active);
public:
StackZone(StackZoneLogic *_logic, int _zoneHeight, QGraphicsItem *parent);
/** @brief Resizes the stack zone height, e.g. when sharing vertical space with the command zone. */

View file

@ -92,14 +92,19 @@ bool TableZone::isInverted() const
void TableZone::paint(QPainter *painter, const QStyleOptionGraphicsItem * /*option*/, QWidget * /*widget*/)
{
QBrush brush = themeManager->getExtraBgBrush(ThemeManager::Table, getLogic()->getPlayer()->getZoneId());
painter->fillRect(boundingRect(), brush);
if (playmatActive) {
// Subtle overlay to distinguish table zone from stack zone
painter->fillRect(boundingRect(), QColor(0, 0, 0, 60));
} else {
QBrush brush = themeManager->getExtraBgBrush(ThemeManager::Table, getLogic()->getPlayer()->getZoneId());
painter->fillRect(boundingRect(), brush);
}
if (active) {
paintZoneOutline(painter);
} else {
// inactive player gets a darker table zone with a semi transparent black mask
// this means if the user provides a custom background it will fade
// this means if the user provides a custom background or playmat it will fade
painter->fillRect(boundingRect(), FADE_MASK);
}
@ -113,6 +118,17 @@ void TableZone::paint(QPainter *painter, const QStyleOptionGraphicsItem * /*opti
paintLandDivider(painter);
}
void TableZone::onPlaymatChanged(bool active)
{
playmatActive = active;
// While a playmat is shown the zone paints a translucent overlay over the
// dynamic playmat behind it. Keep it out of the device cache so the cached
// pixels are never stale relative to the playmat (and to avoid compositing
// artifacts of cached translucent content on some platforms).
setCacheMode(active ? QGraphicsItem::NoCache : QGraphicsItem::DeviceCoordinateCache);
update();
}
/**
Render a soft outline around the edge of the TableZone.

View file

@ -86,6 +86,7 @@ private:
*/
bool active = false;
bool mirrored = false;
bool playmatActive = false;
[[nodiscard]] bool isInverted() const;
@ -95,6 +96,9 @@ private slots:
*/
void updateBg();
public slots:
void onPlaymatChanged(bool active);
public slots:
/**
Reorganizes CardItems in the TableZone
@ -184,8 +188,17 @@ public:
}
void setWidth(qreal _width)
{
// The width is stored as an int; truncate to match the previous implicit conversion.
const int newWidth = static_cast<int>(_width);
if (width == newWidth) {
return;
}
prepareGeometryChange();
width = _width;
width = newWidth;
// The parent player item's boundingRect (which clips the playmat painting) is
// derived from this zone's size. Without this signal the playmat is cut off at
// the stale boundingRect edge whenever the scene is resized wider.
emit sizeChanged();
}
[[nodiscard]] qreal getWidth() const
{

View file

@ -6,6 +6,7 @@ struct ContextJoinGame
{
ContextJoinRoom roomContext;
int gameId;
bool asSpectator = false;
};
#endif // COCKATRICE_CONTEXT_JOIN_GAME_H

View file

@ -55,7 +55,7 @@ bool IntentJoinServerGame::tryJoinGame(TabRoom *room)
return false;
}
if (room->getGameSelector()->joinGameById(context->gameId)) {
if (room->getGameSelector()->joinGameById(context->gameId, context->asSpectator)) {
emitFinished();
return true;
}

View file

@ -2,6 +2,7 @@
#include "../../../client/settings/cache_settings.h"
#include "../../../client/settings/shortcuts_settings.h"
#include "../playmat/playmat_settings_dialog.h"
#include "../settings_page/user_interface_settings_page.h"
#include "../tabs/api/commander_spellbook/commander_bracket_widget.h"
#include "deck_list_style_proxy.h"
@ -11,10 +12,12 @@
#include <QDockWidget>
#include <QHeaderView>
#include <QLabel>
#include <QPushButton>
#include <QSplitter>
#include <QTextEdit>
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/settings/deck_editor_settings.h>
#include <libcockatrice/settings/interface_settings.h>
#include <libcockatrice/utility/macros.h>
#include <libcockatrice/utility/string_limits.h>
@ -228,10 +231,18 @@ void DeckEditorDeckDockWidget::createDeckDock()
upperLayout->addWidget(bannerCardLabel, 4, 0);
upperLayout->addWidget(bannerCardComboBox, 4, 1);
upperLayout->addWidget(deckTagsDisplayWidget, 5, 1);
playmatLabel = new QLabel();
playmatLabel->setObjectName("playmatLabel");
playmatLabel->setText(tr("Playmat"));
playmatSettingsButton = new QPushButton(tr("Edit Playmat..."));
connect(playmatSettingsButton, &QPushButton::clicked, this, &DeckEditorDeckDockWidget::openPlaymatSettings);
upperLayout->addWidget(playmatLabel, 5, 0);
upperLayout->addWidget(playmatSettingsButton, 5, 1);
upperLayout->addWidget(activeGroupCriteriaLabel, 6, 0);
upperLayout->addWidget(activeGroupCriteriaComboBox, 6, 1);
upperLayout->addWidget(deckTagsDisplayWidget, 6, 1);
upperLayout->addWidget(activeGroupCriteriaLabel, 7, 0);
upperLayout->addWidget(activeGroupCriteriaComboBox, 7, 1);
hashLabel1 = new QLabel();
hashLabel1->setObjectName("hashLabel1");
@ -440,6 +451,35 @@ void DeckEditorDeckDockWidget::writeBannerCard(int index)
deckStateManager->setBannerCard(bannerCard);
}
void DeckEditorDeckDockWidget::openPlaymatSettings()
{
PlaymatInfo current = deckStateManager->getMetadata().playmat;
PlaymatSettingsDialog dialog(current.card, current.params, this);
if (dialog.exec() == QDialog::Accepted) {
CardRef newCard = dialog.card();
PlaymatParams newParams = dialog.params();
if (newCard.isEmpty()) {
deckStateManager->setPlaymat(PlaymatInfo{});
} else {
deckStateManager->setPlaymat({newCard, newParams});
}
updatePlaymatLabel();
}
}
void DeckEditorDeckDockWidget::updatePlaymatLabel()
{
CardRef playmat = deckStateManager->getMetadata().playmat.card;
if (playmat.isEmpty()) {
playmatSettingsButton->setText(tr("Edit Playmat..."));
} else {
playmatSettingsButton->setText(tr("Edit Playmat (%1)").arg(playmat.name));
}
}
void DeckEditorDeckDockWidget::applyActiveGroupCriteria()
{
getModel()->setActiveGroupCriteria(
@ -497,6 +537,7 @@ void DeckEditorDeckDockWidget::syncDisplayWidgetsToModel()
syncBannerCardComboBoxSelectionWithDeck();
updateBannerCardComboBox();
bannerCardComboBox->blockSignals(false);
updatePlaymatLabel();
updateHash();
formatComboBox->blockSignals(true);

View file

@ -20,6 +20,7 @@
#include <QTextEdit>
#include <QTreeView>
#include <libcockatrice/card/card_info.h>
#include <libcockatrice/deck_list/deck_list.h>
class CommanderBracketWidget;
class DeckListModel;
@ -33,6 +34,8 @@ public:
DeckListStyleProxy *proxy;
QTreeView *deckView;
QComboBox *bannerCardComboBox;
QLabel *playmatLabel;
QPushButton *playmatSettingsButton;
void createDeckDock();
ExactCard getCurrentCard();
void retranslateUi();
@ -102,6 +105,8 @@ private slots:
void writeName();
void writeComments();
void writeBannerCard(int);
void openPlaymatSettings();
void updatePlaymatLabel();
void applyActiveGroupCriteria();
void setSelectedIndex(const QModelIndex &newCardIndex, bool preserveWidgetFocus);
void updateHash();

View file

@ -142,6 +142,19 @@ void DeckStateManager::setBannerCard(const CardRef &bannerCard)
doMetadataModified();
}
void DeckStateManager::setPlaymat(const PlaymatInfo &playmat)
{
PlaymatInfo previous = deckList->getPlaymat();
if (previous == playmat) {
return;
}
requestHistorySave(tr("Set playmat to %1").arg(playmat.card.name));
deckList->setPlaymat(playmat);
doMetadataModified();
}
void DeckStateManager::setTags(const QStringList &tags)
{
QStringList previous = deckList->getTags();

View file

@ -171,6 +171,7 @@ public:
void setName(const QString &name);
void setComments(const QString &comments);
void setBannerCard(const CardRef &bannerCard);
void setPlaymat(const PlaymatInfo &playmat);
void setTags(const QStringList &tags);
void setFormat(const QString &format);
///@}

View file

@ -0,0 +1,297 @@
#include "dlg_my_reports.h"
#include "../utility/report_utils.h"
#include "abstract_client.h"
#include <QFontDatabase>
#include <QGroupBox>
#include <QHBoxLayout>
#include <QHeaderView>
#include <QLabel>
#include <QLineEdit>
#include <QPushButton>
#include <QSplitter>
#include <QTableWidget>
#include <QTextEdit>
#include <QVBoxLayout>
#include <libcockatrice/protocol/pb/command_report_add_comment.pb.h>
#include <libcockatrice/protocol/pb/command_report_details.pb.h>
#include <libcockatrice/protocol/pb/command_report_my_list.pb.h>
#include <libcockatrice/protocol/pb/response_report_details.pb.h>
#include <libcockatrice/protocol/pb/response_report_my_list.pb.h>
#include <libcockatrice/protocol/pending_command.h>
namespace
{
constexpr int COL_ID = 0;
constexpr int COL_TIME = 1;
constexpr int COL_REPORTED = 2;
constexpr int COL_CATEGORY = 3;
constexpr int COL_GAMEID = 4;
constexpr int COL_STATUS = 5;
constexpr int COL_ASSIGNED = 6;
constexpr int COL_COUNT = 7;
} // namespace
DlgMyReports::DlgMyReports(AbstractClient *_client, QWidget *parent)
: QDialog(parent), client(_client), selectedReportId(-1)
{
setWindowTitle(tr("My Reports"));
setMinimumSize(800, 500);
table = new QTableWidget(0, COL_COUNT);
table->setHorizontalHeaderLabels(
{tr("#"), tr("Time"), tr("Reported User"), tr("Category"), tr("Game ID"), tr("Status"), tr("Assigned To")});
table->setSelectionBehavior(QAbstractItemView::SelectRows);
table->setSelectionMode(QAbstractItemView::SingleSelection);
table->setEditTriggers(QAbstractItemView::NoEditTriggers);
table->setSortingEnabled(true);
table->verticalHeader()->setVisible(false);
table->setAlternatingRowColors(true);
table->horizontalHeader()->setSectionResizeMode(COL_TIME, QHeaderView::ResizeToContents);
table->horizontalHeader()->setSectionResizeMode(COL_REPORTED, QHeaderView::Stretch);
connect(table, &QTableWidget::itemSelectionChanged, this, &DlgMyReports::onSelectionChanged);
auto *detailsGroup = new QGroupBox(tr("Report Details"));
descriptionEdit = new QTextEdit;
descriptionEdit->setReadOnly(true);
descriptionEdit->setFixedHeight(80);
auto *chatGroup = new QGroupBox(tr("Chat Log Context"));
chatLogEdit = new QTextEdit;
chatLogEdit->setReadOnly(true);
QFont monoFont("monospace");
monoFont.setStyleHint(QFont::Monospace);
const int systemPointSize = QFontDatabase::systemFont(QFontDatabase::GeneralFont).pointSize();
if (systemPointSize > 0) {
monoFont.setPointSize(systemPointSize);
}
chatLogEdit->setFont(monoFont);
auto *chatLayout = new QVBoxLayout(chatGroup);
chatLayout->setContentsMargins(4, 4, 4, 4);
chatLayout->addWidget(chatLogEdit);
auto *commentsLabel = new QLabel(tr("Comments:"));
commentsEdit = new QTextEdit;
commentsEdit->setReadOnly(true);
commentsEdit->setFixedHeight(120);
auto *addCommentLabel = new QLabel(tr("Add a comment:"));
commentInput = new QLineEdit;
commentInput->setPlaceholderText(tr("Type your comment here..."));
commentButton = new QPushButton(tr("Send"));
commentButton->setEnabled(false);
connect(commentButton, &QPushButton::clicked, this, &DlgMyReports::addComment);
connect(commentInput, &QLineEdit::returnPressed, this, &DlgMyReports::addComment);
auto *detailsLayout = new QVBoxLayout(detailsGroup);
detailsLayout->setContentsMargins(4, 4, 4, 4);
detailsLayout->addWidget(descriptionEdit);
detailsLayout->addWidget(chatGroup);
detailsLayout->addWidget(commentsLabel);
detailsLayout->addWidget(commentsEdit);
detailsLayout->addWidget(addCommentLabel);
auto *commentRow = new QHBoxLayout;
commentRow->addWidget(commentInput);
commentRow->addWidget(commentButton);
detailsLayout->addLayout(commentRow);
closeButton = new QPushButton(tr("Close"));
connect(closeButton, &QPushButton::clicked, this, &QDialog::accept);
refreshButton = new QPushButton(tr("Refresh"));
connect(refreshButton, &QPushButton::clicked, this, &DlgMyReports::refreshList);
statusLabel = new QLabel;
auto *bottomBar = new QHBoxLayout;
bottomBar->addWidget(statusLabel);
bottomBar->addStretch();
bottomBar->addWidget(refreshButton);
bottomBar->addWidget(closeButton);
auto *layout = new QVBoxLayout(this);
layout->addWidget(table, 1);
layout->addWidget(detailsGroup);
layout->addLayout(bottomBar);
setActionsEnabled(false);
refreshList();
}
void DlgMyReports::refreshList()
{
selectedReportIdBeforeRefresh = selectedReportId;
commentDraftBeforeRefresh = commentInput->text();
statusLabel->setText(tr("Loading..."));
refreshButton->setEnabled(false);
table->setRowCount(0);
currentReports.clear();
descriptionEdit->clear();
chatLogEdit->clear();
commentsEdit->clear();
commentInput->clear();
setActionsEnabled(false);
selectedReportId = -1;
Command_ReportMyList cmd;
PendingCommand *pend = client->prepareSessionCommand(cmd);
connect(pend, &PendingCommand::finished, this, &DlgMyReports::reportListResponse);
client->sendCommand(pend);
}
void DlgMyReports::reportListResponse(const Response &response)
{
refreshButton->setEnabled(true);
if (response.response_code() != Response::RespOk) {
statusLabel->setText(tr("Failed to load reports."));
return;
}
const Response_ReportMyList &resp = response.GetExtension(Response_ReportMyList::ext);
currentReports.clear();
for (int i = 0; i < resp.reports_size(); ++i) {
currentReports.append(resp.reports(i));
}
table->setSortingEnabled(false);
table->setRowCount(currentReports.size());
for (int row = 0; row < currentReports.size(); ++row) {
const ServerInfo_Report &r = currentReports[row];
report_utils::fillReportTableRow(table, row, r, COL_ID, COL_TIME, COL_REPORTED, COL_CATEGORY, COL_GAMEID,
COL_STATUS, COL_ASSIGNED);
}
table->setSortingEnabled(true);
table->resizeColumnsToContents();
table->horizontalHeader()->setSectionResizeMode(COL_REPORTED, QHeaderView::Stretch);
if (selectedReportIdBeforeRefresh >= 0) {
for (int row = 0; row < table->rowCount(); ++row) {
if (table->item(row, COL_ID) &&
table->item(row, COL_ID)->data(Qt::UserRole).toInt() == selectedReportIdBeforeRefresh) {
table->setCurrentCell(row, 0);
break;
}
}
}
if (commentInput->text().isEmpty()) {
commentInput->setText(commentDraftBeforeRefresh);
}
statusLabel->setText(tr("%1 report(s)").arg(currentReports.size()));
}
void DlgMyReports::onSelectionChanged()
{
const int row = table->currentRow();
if (row < 0 || !table->item(row, COL_ID)) {
descriptionEdit->clear();
chatLogEdit->clear();
commentsEdit->clear();
commentInput->clear();
commentButton->setEnabled(false);
selectedReportId = -1;
return;
}
const int reportId = table->item(row, COL_ID)->data(Qt::UserRole).toInt();
selectedReportId = reportId;
for (const ServerInfo_Report &r : currentReports) {
if (r.report_id() == reportId) {
descriptionEdit->setPlainText(QString::fromStdString(r.description()));
break;
}
}
chatLogEdit->setPlainText(tr("Loading..."));
commentsEdit->setPlainText(tr("Loading..."));
Command_ReportDetails cmd;
cmd.set_report_id(reportId);
PendingCommand *pend = client->prepareSessionCommand(cmd);
connect(pend, &PendingCommand::finished, this, &DlgMyReports::reportDetailsResponse);
client->sendCommand(pend);
QString status = table->item(row, COL_STATUS)->text();
bool canComment = (status == "open" || status == "assigned");
commentButton->setEnabled(canComment);
commentInput->setEnabled(canComment);
if (!canComment) {
commentInput->setPlaceholderText(tr("This report is closed."));
} else {
commentInput->setPlaceholderText(tr("Type your comment here..."));
}
}
void DlgMyReports::reportDetailsResponse(const Response &response)
{
if (response.response_code() != Response::RespOk) {
if (selectedReportId == -1) {
return;
}
chatLogEdit->clear();
commentsEdit->setPlainText(tr("Failed to load report details."));
return;
}
const Response_ReportDetails &resp = response.GetExtension(Response_ReportDetails::ext);
const ServerInfo_Report &r = resp.report();
if (selectedReportId != r.report_id()) {
return;
}
loadReportDetails(r);
}
void DlgMyReports::loadReportDetails(const ServerInfo_Report &report)
{
report_utils::renderReportDetails(chatLogEdit, commentsEdit, report, tr("No comments yet."), tr("[Moderator]"),
tr("[You]"));
}
void DlgMyReports::addComment()
{
if (selectedReportId < 0) {
return;
}
QString text = commentInput->text().trimmed();
if (text.isEmpty()) {
return;
}
commentButton->setEnabled(false);
Command_ReportAddComment cmd;
cmd.set_report_id(selectedReportId);
cmd.set_comment(text.toStdString());
PendingCommand *pend = client->prepareSessionCommand(cmd);
connect(pend, &PendingCommand::finished, this, &DlgMyReports::addCommentResponse);
client->sendCommand(pend);
}
void DlgMyReports::addCommentResponse(const Response &response)
{
if (response.response_code() == Response::RespOk) {
commentInput->clear();
refreshList();
} else {
commentButton->setEnabled(true);
}
}
void DlgMyReports::setActionsEnabled(bool enabled)
{
commentButton->setEnabled(enabled);
commentInput->setEnabled(enabled);
}

View file

@ -0,0 +1,52 @@
#ifndef COCKATRICE_DLG_MY_REPORTS_H
#define COCKATRICE_DLG_MY_REPORTS_H
#include <QDialog>
#include <QList>
#include <libcockatrice/protocol/pb/response.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_report.pb.h>
class AbstractClient;
class QTableWidget;
class QTextEdit;
class QLineEdit;
class QPushButton;
class QLabel;
class DlgMyReports : public QDialog
{
Q_OBJECT
public:
explicit DlgMyReports(AbstractClient *_client, QWidget *parent = nullptr);
private slots:
void refreshList();
void reportListResponse(const Response &response);
void onSelectionChanged();
void reportDetailsResponse(const Response &response);
void addComment();
void addCommentResponse(const Response &response);
private:
void loadReportDetails(const ServerInfo_Report &report);
void setActionsEnabled(bool enabled);
AbstractClient *client;
QTableWidget *table;
QTextEdit *descriptionEdit;
QTextEdit *chatLogEdit;
QTextEdit *commentsEdit;
QLineEdit *commentInput;
QPushButton *commentButton;
QPushButton *refreshButton;
QPushButton *closeButton;
QLabel *statusLabel;
QList<ServerInfo_Report> currentReports;
int selectedReportId;
int selectedReportIdBeforeRefresh = -1;
QString commentDraftBeforeRefresh;
};
#endif // COCKATRICE_DLG_MY_REPORTS_H

View file

@ -0,0 +1,191 @@
#include "dlg_report_user.h"
#include "abstract_client.h"
#include <QComboBox>
#include <QDialogButtonBox>
#include <QFontDatabase>
#include <QGridLayout>
#include <QGroupBox>
#include <QLabel>
#include <QLineEdit>
#include <QMessageBox>
#include <QPushButton>
#include <QTextEdit>
#include <QVBoxLayout>
#include <libcockatrice/protocol/pb/command_report.pb.h>
#include <libcockatrice/protocol/pending_command.h>
DlgReportUser::DlgReportUser(AbstractClient *_client,
const QString &_reportedUser,
int _gameId,
const QString &_autoChatLog,
QWidget *parent)
: QDialog(parent), client(_client), reportedUser(_reportedUser), gameId(_gameId)
{
setWindowTitle(tr("Report User"));
setMinimumWidth(500);
auto *infoLabel =
new QLabel(tr("Reports are reviewed by moderators. False reports may result in account penalties."));
infoLabel->setWordWrap(true);
infoLabel->setStyleSheet("color: palette(placeholderText); padding: 5px;");
auto *reportGroup = new QGroupBox(tr("Report Details"));
auto *reportGrid = new QGridLayout(reportGroup);
reportGrid->addWidget(new QLabel(tr("Reported User:")), 0, 0);
reportedUserLabel = new QLabel(reportedUser);
reportedUserLabel->setStyleSheet("font-weight: bold;");
reportGrid->addWidget(reportedUserLabel, 0, 1);
reportGrid->addWidget(new QLabel(tr("Game ID:")), 1, 0);
if (gameId >= 0) {
gameIdLabel = new QLabel(QString::number(gameId));
gameIdLabel->setStyleSheet("font-weight: bold;");
reportGrid->addWidget(gameIdLabel, 1, 1);
} else {
gameIdEdit = new QLineEdit;
gameIdEdit->setPlaceholderText(tr("(Optional) Enter game ID if available"));
gameIdEdit->setToolTip(tr("If the report is related to a specific game, enter its ID."));
reportGrid->addWidget(gameIdEdit, 1, 1);
gameIdLabel = nullptr;
}
auto *categoryGroup = new QGroupBox(tr("Category"));
auto *categoryGrid = new QGridLayout(categoryGroup);
categoryBox = new QComboBox;
categoryBox->addItem(tr("Cheating / Unsporting behavior"), "cheating");
categoryBox->setItemData(categoryBox->count() - 1,
tr("Using external tools, card marked manipulation, or exploiting game bugs"),
Qt::ToolTipRole);
categoryBox->addItem(tr("Harassment / Abuse"), "harassment");
categoryBox->setItemData(categoryBox->count() - 1, tr("Threatening, bullying, or persistent unwanted contact"),
Qt::ToolTipRole);
categoryBox->addItem(tr("Hate speech"), "hate_speech");
categoryBox->setItemData(categoryBox->count() - 1,
tr("Discriminatory language targeting race, gender, religion, etc."), Qt::ToolTipRole);
categoryBox->addItem(tr("Spam"), "spam");
categoryBox->setItemData(categoryBox->count() - 1, tr("Repeated unwanted messages or advertisements"),
Qt::ToolTipRole);
categoryBox->addItem(tr("Other"), "other");
categoryBox->setItemData(categoryBox->count() - 1, tr("Any behavior not covered by the above categories"),
Qt::ToolTipRole);
categoryGrid->addWidget(new QLabel(tr("Category:")), 0, 0);
categoryGrid->addWidget(categoryBox, 0, 1);
auto *descGroup = new QGroupBox(tr("Description"));
auto *descLayout = new QVBoxLayout(descGroup);
descriptionEdit = new QTextEdit;
descriptionEdit->setPlaceholderText(
tr("Please describe what happened. Include dates, game details, or any evidence if available."));
descriptionEdit->setFixedHeight(120);
descLayout->addWidget(descriptionEdit);
auto *chatGroup = new QGroupBox(tr("Chat Log Context"));
auto *chatLayout = new QVBoxLayout(chatGroup);
chatLogEdit = new QTextEdit;
chatLogEdit->setReadOnly(true);
QFont monoFont("monospace");
monoFont.setStyleHint(QFont::Monospace);
const int systemPointSize = QFontDatabase::systemFont(QFontDatabase::GeneralFont).pointSize();
if (systemPointSize > 0) {
monoFont.setPointSize(systemPointSize);
}
chatLogEdit->setFont(monoFont);
if (!_autoChatLog.isEmpty()) {
chatLogEdit->setPlainText(_autoChatLog);
} else {
chatLogEdit->setPlaceholderText(tr("No chat context available (not triggered from chat)."));
}
chatLogEdit->setFixedHeight(100);
chatLayout->addWidget(chatLogEdit);
auto *chatNote = new QLabel(
tr("This chat log is captured from your local chat window and may not reflect the full conversation."));
chatNote->setWordWrap(true);
chatNote->setStyleSheet("color: palette(placeholderText);");
chatLayout->addWidget(chatNote);
buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel);
buttonBox->button(QDialogButtonBox::Ok)->setText(tr("Submit Report"));
connect(buttonBox, &QDialogButtonBox::accepted, this, &DlgReportUser::actSubmit);
connect(buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject);
auto *layout = new QVBoxLayout(this);
layout->addWidget(infoLabel);
layout->addWidget(reportGroup);
layout->addWidget(categoryGroup);
layout->addWidget(descGroup);
layout->addWidget(chatGroup);
layout->addWidget(buttonBox);
}
void DlgReportUser::actSubmit()
{
const QString description = descriptionEdit->toPlainText().trimmed();
if (description.isEmpty()) {
QMessageBox::warning(this, tr("Missing description"), tr("Please describe what happened before submitting."));
return;
}
QMessageBox::StandardButton reply =
QMessageBox::question(this, tr("Confirm Report"),
tr("Submit report against %1 for %2?").arg(reportedUser, categoryBox->currentText()),
QMessageBox::Yes | QMessageBox::No);
if (reply != QMessageBox::Yes) {
return;
}
buttonBox->setEnabled(false);
buttonBox->button(QDialogButtonBox::Ok)->setText(tr("Submitting..."));
Command_Report cmd;
cmd.set_reported_user(reportedUser.toStdString());
cmd.set_category(categoryBox->currentData().toString().toStdString());
cmd.set_description(description.toStdString());
if (gameId >= 0) {
cmd.set_game_id(gameId);
} else if (gameIdEdit && !gameIdEdit->text().trimmed().isEmpty()) {
bool ok;
int manualGameId = gameIdEdit->text().trimmed().toInt(&ok);
if (ok && manualGameId > 0) {
cmd.set_game_id(manualGameId);
}
}
const QString chatLog = chatLogEdit->toPlainText().trimmed();
if (!chatLog.isEmpty()) {
cmd.set_chat_log(chatLog.toStdString());
}
PendingCommand *pend = client->prepareSessionCommand(cmd);
connect(pend, &PendingCommand::finished, this, &DlgReportUser::reportResponse);
client->sendCommand(pend);
}
void DlgReportUser::reportResponse(const Response &response)
{
buttonBox->setEnabled(true);
buttonBox->button(QDialogButtonBox::Ok)->setText(tr("Submit Report"));
if (response.response_code() == Response::RespOk) {
QMessageBox::information(this, tr("Report Submitted"),
tr("Your report has been submitted and will be reviewed by a moderator. Thank you."));
accept();
} else if (response.response_code() == Response::RespTooManyRequests) {
QMessageBox::warning(this, tr("Submission Failed"),
tr("You have reached the daily report limit. Please try again later."));
} else if (response.response_code() == Response::RespNameNotFound) {
QMessageBox::warning(
this, tr("Submission Failed"),
tr("The reported user could not be found. Guests (unregistered users) cannot be reported."));
} else {
QMessageBox::warning(this, tr("Submission Failed"), tr("Failed to submit report. Please try again."));
}
}

View file

@ -0,0 +1,42 @@
#ifndef COCKATRICE_DLG_REPORT_USER_H
#define COCKATRICE_DLG_REPORT_USER_H
#include <QDialog>
#include <libcockatrice/protocol/pb/response.pb.h>
class AbstractClient;
class QComboBox;
class QDialogButtonBox;
class QLineEdit;
class QTextEdit;
class QLabel;
class DlgReportUser : public QDialog
{
Q_OBJECT
public:
DlgReportUser(AbstractClient *_client,
const QString &_reportedUser,
int _gameId = -1,
const QString &_autoChatLog = QString(),
QWidget *parent = nullptr);
private slots:
void actSubmit();
void reportResponse(const Response &response);
private:
AbstractClient *client;
QString reportedUser;
int gameId;
QLabel *reportedUserLabel;
QLabel *gameIdLabel = nullptr;
QLineEdit *gameIdEdit = nullptr;
QComboBox *categoryBox;
QTextEdit *descriptionEdit;
QTextEdit *chatLogEdit;
QDialogButtonBox *buttonBox;
};
#endif // COCKATRICE_DLG_REPORT_USER_H

View file

@ -0,0 +1,188 @@
#include "playmat_collection_dialog.h"
#include "../../../client/settings/cache_settings.h"
#include "playmat_settings_dialog.h"
#include <QComboBox>
#include <QDialogButtonBox>
#include <QHBoxLayout>
#include <QLabel>
#include <QListWidget>
#include <QPushButton>
#include <QVBoxLayout>
#include <libcockatrice/settings/interface_settings.h>
PlaymatCollectionDialog::PlaymatCollectionDialog(QWidget *parent) : QDialog(parent)
{
setMinimumWidth(420);
setupUi();
retranslateUi();
}
void PlaymatCollectionDialog::accept()
{
auto &interfaceSettings = SettingsCache::instance().userInterface();
interfaceSettings.setPlaymatFallbackList(playmats);
interfaceSettings.setPlaymatFallbackBehavior(modeCombo->currentData().toInt());
QDialog::accept();
}
int PlaymatCollectionDialog::currentRow() const
{
return playmatList->currentRow();
}
void PlaymatCollectionDialog::setupUi()
{
auto &interfaceSettings = SettingsCache::instance().userInterface();
playmats = interfaceSettings.getPlaymatFallbackList();
playmatList = new QListWidget;
for (const PlaymatInfo &entry : playmats) {
playmatList->addItem(entry.card.name);
}
connect(playmatList, &QListWidget::itemSelectionChanged, this, &PlaymatCollectionDialog::selectionChanged);
connect(playmatList, &QListWidget::itemDoubleClicked, this, [this](QListWidgetItem *) { editPlaymat(); });
addButton = new QPushButton;
editButton = new QPushButton;
removeButton = new QPushButton;
moveUpButton = new QPushButton;
moveDownButton = new QPushButton;
connect(addButton, &QPushButton::clicked, this, &PlaymatCollectionDialog::addPlaymat);
connect(editButton, &QPushButton::clicked, this, &PlaymatCollectionDialog::editPlaymat);
connect(removeButton, &QPushButton::clicked, this, &PlaymatCollectionDialog::removePlaymat);
connect(moveUpButton, &QPushButton::clicked, this, &PlaymatCollectionDialog::movePlaymatUp);
connect(moveDownButton, &QPushButton::clicked, this, &PlaymatCollectionDialog::movePlaymatDown);
auto *listButtons = new QVBoxLayout;
listButtons->addWidget(addButton);
listButtons->addWidget(editButton);
listButtons->addWidget(removeButton);
listButtons->addWidget(moveUpButton);
listButtons->addWidget(moveDownButton);
listButtons->addStretch();
auto *listRow = new QHBoxLayout;
listRow->addWidget(playmatList, 1);
listRow->addLayout(listButtons);
modeCombo = new QComboBox;
modeCombo->addItem(QString(), PlaymatFallbackModeFixed);
modeCombo->addItem(QString(), PlaymatFallbackModeRoundRobin);
modeCombo->addItem(QString(), PlaymatFallbackModeRandom);
const int modeIndex = modeCombo->findData(interfaceSettings.getPlaymatFallbackBehavior());
if (modeIndex >= 0) {
modeCombo->setCurrentIndex(modeIndex);
}
auto *modeRow = new QHBoxLayout;
modeLabel = new QLabel;
modeLabel->setBuddy(modeCombo);
modeRow->addWidget(modeLabel);
modeRow->addWidget(modeCombo, 1);
auto *buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel);
connect(buttonBox, &QDialogButtonBox::accepted, this, &PlaymatCollectionDialog::accept);
connect(buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject);
auto *root = new QVBoxLayout;
root->addLayout(listRow);
root->addLayout(modeRow);
root->addWidget(buttonBox);
setLayout(root);
selectionChanged();
}
void PlaymatCollectionDialog::selectionChanged()
{
const bool hasSelection = playmatList->currentRow() >= 0;
editButton->setEnabled(hasSelection);
removeButton->setEnabled(hasSelection);
moveUpButton->setEnabled(hasSelection && playmatList->currentRow() > 0);
moveDownButton->setEnabled(hasSelection && playmatList->currentRow() < playmatList->count() - 1);
}
void PlaymatCollectionDialog::addPlaymat()
{
PlaymatSettingsDialog dialog(CardRef{}, PlaymatParams{}, this);
if (dialog.exec() == QDialog::Accepted) {
const CardRef card = dialog.card();
if (!card.isEmpty()) {
PlaymatInfo res = {card, dialog.params()};
playmats.append(res);
playmatList->addItem(res.card.name);
playmatList->setCurrentRow(playmatList->count() - 1);
}
}
}
void PlaymatCollectionDialog::editPlaymat()
{
const int row = currentRow();
if (row < 0) {
return;
}
const PlaymatInfo &current = playmats.at(row);
PlaymatSettingsDialog dialog(current.card, current.params, this);
if (dialog.exec() == QDialog::Accepted) {
const CardRef card = dialog.card();
if (card.isEmpty()) {
return; // Removal is handled by the Remove button
}
playmats[row] = {card, dialog.params()};
playmatList->item(row)->setText(card.name);
}
}
void PlaymatCollectionDialog::removePlaymat()
{
const int row = currentRow();
if (row < 0) {
return;
}
playmats.removeAt(row);
delete playmatList->takeItem(row);
selectionChanged();
}
void PlaymatCollectionDialog::movePlaymatUp()
{
const int row = currentRow();
if (row <= 0) {
return;
}
playmats.swapItemsAt(row, row - 1);
playmatList->insertItem(row - 1, playmatList->takeItem(row));
playmatList->setCurrentRow(row - 1);
selectionChanged();
}
void PlaymatCollectionDialog::movePlaymatDown()
{
const int row = currentRow();
if (row < 0 || row >= playmats.size() - 1) {
return;
}
playmats.swapItemsAt(row, row + 1);
playmatList->insertItem(row + 1, playmatList->takeItem(row));
playmatList->setCurrentRow(row + 1);
selectionChanged();
}
void PlaymatCollectionDialog::retranslateUi()
{
setWindowTitle(tr("Default Playmats"));
addButton->setText(tr("Add..."));
editButton->setText(tr("Edit..."));
removeButton->setText(tr("Remove"));
moveUpButton->setText(tr("Move Up"));
moveDownButton->setText(tr("Move Down"));
modeLabel->setText(tr("List mode:"));
modeCombo->setItemText(0, tr("Fixed (always the first entry)"));
modeCombo->setItemText(1, tr("Round-robin (cycle through entries)"));
modeCombo->setItemText(2, tr("Random (pick one per game)"));
}

View file

@ -0,0 +1,54 @@
#ifndef COCKATRICE_PLAYMAT_COLLECTION_DIALOG_H
#define COCKATRICE_PLAYMAT_COLLECTION_DIALOG_H
#include <QDialog>
#include <libcockatrice/utility/playmat_params.h>
class QComboBox;
class QLabel;
class QListWidget;
class QListWidgetItem;
class QPushButton;
/**
* @brief Dialog for editing the user-level playmat collection.
*
* The collection is the fallback used when a deck has no playmat of its own.
* It supports multiple entries and a pick mode (always first / round-robin /
* random). The dialog edits a working copy and writes it to the settings only
* when accepted.
*/
class PlaymatCollectionDialog : public QDialog
{
Q_OBJECT
public:
explicit PlaymatCollectionDialog(QWidget *parent = nullptr);
void accept() override;
private slots:
void addPlaymat();
void editPlaymat();
void removePlaymat();
void movePlaymatUp();
void movePlaymatDown();
void selectionChanged();
private:
void setupUi();
void retranslateUi();
int currentRow() const;
QList<PlaymatInfo> playmats; ///< Working copy edited by the dialog.
QListWidget *playmatList;
QComboBox *modeCombo;
QLabel *modeLabel;
QPushButton *addButton;
QPushButton *editButton;
QPushButton *removeButton;
QPushButton *moveUpButton;
QPushButton *moveDownButton;
};
#endif // COCKATRICE_PLAYMAT_COLLECTION_DIALOG_H

View file

@ -0,0 +1,99 @@
#include "playmat_preview_widget.h"
#include "../cards/art_crop_attribution.h"
#include "playmat_utils.h"
#include <QLinearGradient>
#include <QPainter>
#include <QPainterPath>
PlaymatPreviewWidget::PlaymatPreviewWidget(QWidget *parent) : QWidget(parent)
{
setMinimumSize(400, 120);
setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
}
void PlaymatPreviewWidget::setPixmap(const QPixmap &pixmap)
{
sourcePixmap = pixmap;
update();
}
void PlaymatPreviewWidget::setParams(const PlaymatParams &p)
{
params = p;
update();
}
void PlaymatPreviewWidget::setAttribution(const QString &attribution)
{
attributionText = attribution;
update();
}
void PlaymatPreviewWidget::paintEvent(QPaintEvent *)
{
QPainter painter(this);
painter.setRenderHints(QPainter::Antialiasing | QPainter::SmoothPixmapTransform | QPainter::TextAntialiasing);
const QRect rect = this->rect();
const QColor accentColor(100, 116, 139);
// Background
const QRectF cardRect = QRectF(rect).adjusted(3, 2, -3, -2);
QLinearGradient bg(cardRect.topLeft(), cardRect.topRight());
bg.setColorAt(0, accentColor.darker(320));
bg.setColorAt(1, QColor(18, 22, 30));
painter.setPen(Qt::NoPen);
painter.setBrush(bg);
painter.drawRoundedRect(cardRect, 6, 6);
painter.setBrush(accentColor);
painter.drawRoundedRect(QRectF(cardRect.left(), cardRect.top(), 3, cardRect.height()), 2, 2);
if (sourcePixmap.isNull()) {
painter.setPen(QColor(150, 150, 150));
painter.drawText(rect, Qt::AlignCenter, tr("No card selected"));
return;
}
// Draw the playmat art using the same logic as PlayerGraphicsItem
// The preview area represents the combined stack+table play area
// Stack is ~20% width on the left, table is ~80% on the right
const QRectF playArea = cardRect.adjusted(6, 4, -4, -4);
const QRectF srcRect = computeArtSourceRect(sourcePixmap.size(), params);
const QRectF dstRect = coverFitRect(playArea, srcRect.size());
painter.setClipRect(playArea.toRect());
painter.drawPixmap(dstRect, sourcePixmap, srcRect);
painter.setClipping(false);
// Draw zone divider: stack is roughly the left portion
const double stackWidthRatio = 0.18; // Stack is about 18% of total play area
const double stackDividerX = playArea.left() + playArea.width() * stackWidthRatio;
// Subtle semi-transparent overlays to distinguish zones
// Stack zone overlay (slightly darker)
QRectF stackOverlay(playArea.left(), playArea.top(), playArea.width() * stackWidthRatio, playArea.height());
painter.fillRect(stackOverlay, QColor(0, 0, 0, 40));
// Table zone overlay (very subtle)
QRectF tableOverlay(stackDividerX, playArea.top(), playArea.width() * (1.0 - stackWidthRatio), playArea.height());
painter.fillRect(tableOverlay, QColor(0, 0, 0, 20));
// Zone divider line
painter.setPen(QPen(QColor(255, 255, 255, 50), 1));
painter.drawLine(QPointF(stackDividerX, playArea.top()), QPointF(stackDividerX, playArea.bottom()));
// Land divider line (about 60% down the table area)
const double landDividerY = playArea.top() + playArea.height() * 0.65;
painter.setPen(QPen(QColor(255, 255, 255, 30), 1));
painter.drawLine(QPointF(stackDividerX, landDividerY), QPointF(playArea.right(), landDividerY));
// Border around entire play area
painter.setPen(QPen(QColor(70, 80, 95, 120), 1));
painter.setBrush(Qt::NoBrush);
painter.drawRoundedRect(playArea.adjusted(0, 0, -1, -1), 3, 3);
paintArtAttribution(painter, playArea, attributionText, Qt::AlignRight | Qt::AlignBottom, 0.8);
}

View file

@ -0,0 +1,35 @@
#ifndef COCKATRICE_PLAYMAT_PREVIEW_WIDGET_H
#define COCKATRICE_PLAYMAT_PREVIEW_WIDGET_H
#include <QPixmap>
#include <QWidget>
#include <libcockatrice/deck_list/deck_list.h>
/**
* @brief Preview widget that shows how a playmat card art will appear
* across the combined table + stack play area.
*
* Renders a miniature mockup with the card art applied using the
* given PlaymatParams, including faint zone divider lines.
*/
class PlaymatPreviewWidget : public QWidget
{
Q_OBJECT
public:
explicit PlaymatPreviewWidget(QWidget *parent = nullptr);
void setPixmap(const QPixmap &pixmap);
void setParams(const PlaymatParams &params);
void setAttribution(const QString &attribution);
protected:
void paintEvent(QPaintEvent *event) override;
private:
QPixmap sourcePixmap;
PlaymatParams params;
QString attributionText;
};
#endif // COCKATRICE_PLAYMAT_PREVIEW_WIDGET_H

View file

@ -0,0 +1,277 @@
#include "playmat_settings_dialog.h"
#include "../../card_picture_loader/card_picture_loader.h"
#include "../cards/art_crop_attribution.h"
#include "../utility/completer_utils.h"
#include "card_database_display_model.h"
#include "card_database_model.h"
#include "playmat_preview_widget.h"
#include <QComboBox>
#include <QCompleter>
#include <QDialogButtonBox>
#include <QDoubleSpinBox>
#include <QFormLayout>
#include <QGroupBox>
#include <QHBoxLayout>
#include <QLabel>
#include <QLineEdit>
#include <QPainter>
#include <QPainterPath>
#include <QPushButton>
#include <QVBoxLayout>
#include <libcockatrice/card/database/card_database_manager.h>
PlaymatSettingsDialog::PlaymatSettingsDialog(const CardRef &initialCard,
const PlaymatParams &initialParams,
QWidget *parent)
: QDialog(parent), currentCard(initialCard), currentParams(initialParams)
{
setMinimumWidth(500);
setupUi();
// Seed UI from initial values
if (!initialCard.name.isEmpty()) {
searchBar->setText(initialCard.name);
onCardNameChanged(initialCard.name);
// onCardNameChanged leaves the printing combo on the first printing in
// the database, which would silently change the deck's stored playmat
// card on accept. Restore the stored printing when it resolves locally.
const int storedPrintingIndex = providerComboBox->findData(initialCard.providerId);
if (storedPrintingIndex != -1) {
providerComboBox->setCurrentIndex(storedPrintingIndex);
} else {
// Stored printing not in the local database: keep it rather than
// silently substituting the first printing.
currentCard.providerId = initialCard.providerId;
reloadPreview();
}
}
marginLSpin->setValue(initialParams.marginPctL);
marginRSpin->setValue(initialParams.marginPctR);
verticalOffsetSpin->setValue(initialParams.verticalOffset);
zoomSpin->setValue(initialParams.zoom);
retranslateUi();
}
CardRef PlaymatSettingsDialog::card() const
{
return currentCard;
}
PlaymatParams PlaymatSettingsDialog::params() const
{
return currentParams;
}
QDoubleSpinBox *PlaymatSettingsDialog::makeSpinBox(double min, double max, double value, double step)
{
auto *spin = new QDoubleSpinBox;
spin->setRange(min, max);
spin->setSingleStep(step);
spin->setDecimals(3);
spin->setValue(value);
return spin;
}
void PlaymatSettingsDialog::initializeSearchBar()
{
searchBar = new QLineEdit;
cardDatabaseModel = new CardDatabaseModel(CardDatabaseManager::getInstance(), false, this);
cardDatabaseDisplayModel = new CardDatabaseDisplayModel(this);
cardDatabaseDisplayModel->setSourceModel(cardDatabaseModel);
const CardCompleterSetup cardSetup = createCardCompleter(cardDatabaseDisplayModel, this, 15);
searchModel = cardSetup.searchModel;
proxyModel = cardSetup.proxyModel;
completer = cardSetup.completer;
searchBar->setCompleter(completer);
connectCardCompleterSearch(searchBar, cardSetup);
connect(completer, static_cast<void (QCompleter::*)(const QString &)>(&QCompleter::activated), this,
[this](const QString &completion) {
if (searchBar->text() != completion) {
searchBar->setText(completion);
searchBar->setCursorPosition(searchBar->text().length());
}
onCardNameChanged(completion);
});
connect(searchBar, &QLineEdit::returnPressed, this, [this]() { onCardNameChanged(searchBar->text()); });
}
void PlaymatSettingsDialog::setupUi()
{
initializeSearchBar();
providerComboBox = new QComboBox;
connect(providerComboBox, &QComboBox::currentIndexChanged, this, [this]() {
currentCard.providerId = providerComboBox->currentData().toString();
reloadPreview();
onParamChanged();
});
marginLSpin = makeSpinBox(0.0, 0.95, currentParams.marginPctL, 0.01);
marginRSpin = makeSpinBox(0.0, 0.95, currentParams.marginPctR, 0.01);
verticalOffsetSpin = makeSpinBox(0.0, 1.0, currentParams.verticalOffset, 0.01);
zoomSpin = makeSpinBox(0.1, 4.0, currentParams.zoom, 0.05);
auto *form = new QFormLayout;
cardNameLabel = new QLabel;
printingLabel = new QLabel;
leftMarginLabel = new QLabel;
rightMarginLabel = new QLabel;
verticalOffsetLabel = new QLabel;
zoomLabel = new QLabel;
form->addRow(cardNameLabel, searchBar);
form->addRow(printingLabel, providerComboBox);
form->addRow(leftMarginLabel, marginLSpin);
form->addRow(rightMarginLabel, marginRSpin);
form->addRow(verticalOffsetLabel, verticalOffsetSpin);
form->addRow(zoomLabel, zoomSpin);
controlsGroup = new QGroupBox;
controlsGroup->setLayout(form);
preview = new PlaymatPreviewWidget;
auto *previewLayout = new QVBoxLayout;
previewLayout->addWidget(preview);
previewGroup = new QGroupBox;
previewGroup->setLayout(previewLayout);
auto *buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel);
removeButton = new QPushButton;
buttons->addButton(removeButton, QDialogButtonBox::ResetRole);
connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept);
connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
connect(removeButton, &QPushButton::clicked, this, [this]() {
currentCard = CardRef{}; // empty signals removal
accept();
});
auto *root = new QVBoxLayout;
root->addWidget(controlsGroup);
root->addWidget(previewGroup);
root->addWidget(buttons);
setLayout(root);
connect(marginLSpin, &QDoubleSpinBox::valueChanged, this, &PlaymatSettingsDialog::onParamChanged);
connect(marginRSpin, &QDoubleSpinBox::valueChanged, this, &PlaymatSettingsDialog::onParamChanged);
connect(verticalOffsetSpin, &QDoubleSpinBox::valueChanged, this, &PlaymatSettingsDialog::onParamChanged);
connect(zoomSpin, &QDoubleSpinBox::valueChanged, this, &PlaymatSettingsDialog::onParamChanged);
}
void PlaymatSettingsDialog::populateProviderCombo(const QString &cardName)
{
providerComboBox->clear();
auto card = CardDatabaseManager::query()->getCard({cardName});
const auto &sets = card.getInfo().getSets();
for (const auto &printings : sets) {
for (const auto &p : printings) {
QString setName = p.getSet()->getLongName();
QString collector = p.getProperty("num");
QString uuid = p.getUuid();
QString label = setName;
if (!collector.isEmpty()) {
label += " #" + collector;
}
providerComboBox->addItem(label, uuid);
}
}
}
void PlaymatSettingsDialog::onCardNameChanged(const QString &name)
{
if (name.isEmpty()) {
currentPixmap = QPixmap();
preview->setPixmap(currentPixmap);
return;
}
const ExactCard card = CardDatabaseManager::query()->getCard({name});
if (!card) {
currentPixmap = QPixmap();
preview->setPixmap(currentPixmap);
providerComboBox->clear();
return;
}
currentCard.name = name;
populateProviderCombo(name);
if (providerComboBox->count() == 0) {
currentPixmap = QPixmap();
preview->setPixmap(currentPixmap);
currentCard.providerId.clear();
return;
}
currentCard.providerId = providerComboBox->currentData().toString();
reloadPreview();
}
void PlaymatSettingsDialog::reloadPreview()
{
if (currentCard.name.isEmpty()) {
return;
}
ExactCard card = CardDatabaseManager::query()->getCard({currentCard.name, currentCard.providerId});
if (!card) {
return;
}
disconnect(pixmapUpdatedConnection);
QPixmap fullRes;
CardPictureLoader::getPixmap(fullRes, card, QSize(745, 1040));
if (fullRes.isNull()) {
CardInfo *cardInfo = card.getCardPtr().data();
if (cardInfo) {
pixmapUpdatedConnection = connect(cardInfo, &CardInfo::pixmapUpdated, this, [this]() { reloadPreview(); });
}
return;
}
currentPixmap = fullRes;
preview->setPixmap(currentPixmap);
preview->setParams(currentParams);
preview->setAttribution(buildArtAttribution(card));
}
void PlaymatSettingsDialog::onParamChanged()
{
currentParams.marginPctL = marginLSpin->value();
currentParams.marginPctR = marginRSpin->value();
currentParams.verticalOffset = verticalOffsetSpin->value();
currentParams.zoom = zoomSpin->value();
preview->setParams(currentParams);
}
void PlaymatSettingsDialog::retranslateUi()
{
setWindowTitle(tr("Playmat Settings"));
searchBar->setPlaceholderText(tr("Type a card name..."));
cardNameLabel->setText(tr("Card name:"));
printingLabel->setText(tr("Printing:"));
leftMarginLabel->setText(tr("Left margin (%):"));
rightMarginLabel->setText(tr("Right margin (%):"));
verticalOffsetLabel->setText(tr("Vertical offset:"));
zoomLabel->setText(tr("Zoom:"));
controlsGroup->setTitle(tr("Parameters"));
previewGroup->setTitle(tr("Preview"));
removeButton->setText(tr("Remove Playmat"));
}

View file

@ -0,0 +1,85 @@
#ifndef COCKATRICE_PLAYMAT_SETTINGS_DIALOG_H
#define COCKATRICE_PLAYMAT_SETTINGS_DIALOG_H
#include <QDialog>
#include <QPixmap>
#include <libcockatrice/deck_list/deck_list.h>
class QComboBox;
class QCompleter;
class QDoubleSpinBox;
class QGroupBox;
class QLabel;
class QLineEdit;
class QPushButton;
class CardDatabaseModel;
class CardDatabaseDisplayModel;
class CardSearchModel;
class CardCompleterProxyModel;
class PlaymatPreviewWidget;
/**
* @brief Dialog for configuring the playmat card art for a deck.
*
* Allows the user to select a card from the database and adjust
* positioning parameters (margins, zoom, vertical offset) for how
* the card art appears as a playmat background across the
* combined table + stack play area.
*/
class PlaymatSettingsDialog : public QDialog
{
Q_OBJECT
public:
explicit PlaymatSettingsDialog(const CardRef &initialCard = {},
const PlaymatParams &initialParams = {},
QWidget *parent = nullptr);
CardRef card() const;
PlaymatParams params() const;
private slots:
void onCardNameChanged(const QString &name);
void reloadPreview();
void onParamChanged();
private:
void setupUi();
void populateProviderCombo(const QString &cardName);
void initializeSearchBar();
void retranslateUi();
QDoubleSpinBox *makeSpinBox(double min, double max, double value, double step);
QLineEdit *searchBar;
QCompleter *completer;
CardDatabaseModel *cardDatabaseModel;
CardDatabaseDisplayModel *cardDatabaseDisplayModel;
CardSearchModel *searchModel;
CardCompleterProxyModel *proxyModel;
QComboBox *providerComboBox;
QMetaObject::Connection pixmapUpdatedConnection;
QLabel *cardNameLabel;
QLabel *printingLabel;
QLabel *leftMarginLabel;
QLabel *rightMarginLabel;
QLabel *verticalOffsetLabel;
QLabel *zoomLabel;
QGroupBox *controlsGroup;
QGroupBox *previewGroup;
QPushButton *removeButton;
QDoubleSpinBox *marginLSpin;
QDoubleSpinBox *marginRSpin;
QDoubleSpinBox *verticalOffsetSpin;
QDoubleSpinBox *zoomSpin;
PlaymatPreviewWidget *preview;
QPixmap currentPixmap;
CardRef currentCard;
PlaymatParams currentParams;
};
#endif // COCKATRICE_PLAYMAT_SETTINGS_DIALOG_H

View file

@ -0,0 +1,69 @@
#ifndef COCKATRICE_PLAYMAT_UTILS_H
#define COCKATRICE_PLAYMAT_UTILS_H
#include <QRectF>
#include <QSize>
#include <QSizeF>
#include <libcockatrice/deck_list/deck_list.h>
/**
* @brief Computes the source region of the full-resolution card image to use as a playmat.
*
* Parameters are relative to the full card image: horizontal margins trim the card
* borders, the vertical offset positions a square viewing window, and zoom scales
* into that window. The result is clamped to the card image bounds.
*
* @param fullCardSize Size of the full card image.
* @param params Positioning parameters.
* @return Source rectangle in full-card image pixel coordinates.
*/
inline QRectF computeArtSourceRect(const QSize &fullCardSize, const PlaymatParams &params)
{
const qreal srcW = fullCardSize.width();
const qreal srcH = fullCardSize.height();
const qreal marginL = params.marginPctL * srcW;
const qreal marginR = params.marginPctR * srcW;
// Guard against margins summing to >= 1 (both are individually in range),
// which would otherwise make the viewing window negative or zero.
const qreal visibleW = qMax(0.0, srcW - marginL - marginR);
const qreal visibleH = visibleW; // square viewing window, keeps art unskewed
const qreal vCenter = params.verticalOffset * srcH;
qreal srcY = vCenter - visibleH / 2.0;
srcY = qBound(0.0, srcY, srcH - visibleH);
// Guard the zoom divisor; everything that produces params clamps zoom to
// [0.1, 4.0] already, this keeps the render path self-contained.
const qreal zoom = qBound(0.1, params.zoom, 4.0);
const qreal zoomedW = visibleW / zoom;
const qreal zoomedH = visibleH / zoom;
const qreal zoomedX = marginL + (visibleW - zoomedW) / 2.0;
const qreal zoomedY = srcY + (visibleH - zoomedH) / 2.0;
return QRectF(zoomedX, zoomedY, zoomedW, zoomedH);
}
/**
* @brief Returns the destination rectangle that fits a source of the given aspect
* ratio into dstArea using "cover" semantics (no distortion, overflows cropped).
*
* @param dstArea Area to fill.
* @param srcSize Size of the source; only its aspect ratio matters.
* @return Destination rectangle centered in dstArea.
*/
inline QRectF coverFitRect(const QRectF &dstArea, const QSizeF &srcSize)
{
const qreal srcAspect = srcSize.width() / srcSize.height();
const qreal dstAspect = dstArea.width() / dstArea.height();
if (srcAspect > dstAspect) {
const qreal dstW = dstArea.height() * srcAspect;
return QRectF(dstArea.left() + (dstArea.width() - dstW) / 2.0, dstArea.top(), dstW, dstArea.height());
}
const qreal dstH = dstArea.width() / srcAspect;
return QRectF(dstArea.left(), dstArea.top() + (dstArea.height() - dstH) / 2.0, dstArea.width(), dstH);
}
#endif // COCKATRICE_PLAYMAT_UTILS_H

View file

@ -273,6 +273,14 @@ void ChatView::appendMessage(QString message,
// messageType should be Event_RoomSay::UserMessage though we don't actually check
bool isUserMessage = !(userName.toLower() == "servatrice" || userName.isEmpty());
bool sameSender = isUserMessage && userName == lastSender;
if (isUserMessage) {
chatHistory.append({userName, message, QDateTime::currentDateTime()});
while (chatHistory.size() > MAX_CHAT_HISTORY) {
chatHistory.removeFirst();
}
}
QTextCursor cursor = prepareBlock(sameSender);
lastSender = userName;
@ -650,6 +658,19 @@ void ChatView::clearChat()
document()->clear();
lastSender = "";
evenNumber = true;
chatHistory.clear();
}
QString ChatView::getRecentChatLog(int maxMessages) const
{
QStringList lines;
int start = qMax(0, chatHistory.size() - maxMessages);
for (int i = start; i < chatHistory.size(); ++i) {
const ChatLogEntry &entry = chatHistory.at(i);
lines.append(
QString("[%1] %2: %3").arg(entry.timestamp.toString("hh:mm:ss")).arg(entry.userName).arg(entry.message));
}
return lines.join("\n");
}
void ChatView::redactMessages(const QString &userName, int amount)

View file

@ -33,6 +33,13 @@ public:
QTextBlock block;
};
struct ChatLogEntry
{
QString userName;
QString message;
QDateTime timestamp;
};
class ChatView : public QTextBrowser
{
Q_OBJECT
@ -65,6 +72,8 @@ private:
QString hoveredContent;
QAction *messageClicked;
QMap<QString, QVector<UserMessagePosition>> userMessagePositions;
QList<ChatLogEntry> chatHistory;
static constexpr int MAX_CHAT_HISTORY = 200;
[[nodiscard]] QTextFragment getFragmentUnderMouse(const QPoint &pos) const;
QTextCursor prepareBlock(bool same = false);
@ -107,6 +116,7 @@ public:
bool playerBold = false);
void clearChat();
void redactMessages(const QString &userName, int amount);
QString getRecentChatLog(int maxMessages = 50) const;
protected:
void enterEvent(QEnterEvent *event) override;

View file

@ -359,7 +359,11 @@ void GameSelector::joinGame(const bool asSpectator, const bool asJudge)
return;
}
const ServerInfo_Game &game = gameListModel->getGame(ind.data(Qt::UserRole).toInt());
joinGame(gameListModel->getGame(ind.data(Qt::UserRole).toInt()), asSpectator, asJudge);
}
void GameSelector::joinGame(const ServerInfo_Game &game, const bool asSpectator, const bool asJudge)
{
if (tabSupervisor->switchToGameTabIfAlreadyExists(game.game_id())) {
return;
}
@ -414,18 +418,16 @@ void GameSelector::joinGame(const bool asSpectator, const bool asJudge)
disableButtons();
}
bool GameSelector::joinGameById(int gameId)
bool GameSelector::joinGameById(const int gameId, const bool asSpectator)
{
auto *model = gameListView->model();
for (int row = 0; row < model->rowCount(); ++row) {
QModelIndex idx = model->index(row, 0);
const ServerInfo_Game &game = gameListModel->getGame(idx.data(Qt::UserRole).toInt());
if (game.game_id() == gameId) {
gameListView->setCurrentIndex(idx);
joinGame();
return true;
for (int row = 0; row < gameListModel->rowCount(); ++row) {
const ServerInfo_Game &game = gameListModel->getGame(row);
if (game.game_id() != gameId) {
continue;
}
joinGame(game, asSpectator);
return true;
}
qWarning() << "Game" << gameId << "not found";

View file

@ -171,6 +171,17 @@ private:
*/
void joinGame(bool asSpectator = false, bool asJudge = false);
/**
* @brief Performs the join or spectate action for a specific game.
* @param game The game to join.
* @param asSpectator True to join as a spectator, false to join as a player.
* @param asJudge True to join as a judge, false to join as a player.
*
* Unlike the selection-based overload, this does not depend on the game being
* visible in the filtered game list.
*/
void joinGame(const ServerInfo_Game &game, bool asSpectator = false, bool asJudge = false);
public:
/**
* @brief Constructs a GameSelector widget.
@ -202,7 +213,16 @@ public:
* @param info The ServerInfo_Game object containing information about the game to update.
*/
void processGameInfo(const ServerInfo_Game &info);
bool joinGameById(int gameId);
/**
* @brief Finds a game by ID and joins or spectates it.
* @param gameId The ID of the game to join.
* @param asSpectator True to join as a spectator, false to join as a player.
* @return True if the game was found and joined, false otherwise.
*
* Unlike the selection-based overload, this does not depend on the game
* being visible in the filtered game list.
*/
bool joinGameById(int gameId, bool asSpectator = false);
};
#endif

View file

@ -1,5 +1,6 @@
#include "user_context_menu.h"
#include "../../dialogs/dlg_report_user.h"
#include "../../interface/widgets/tabs/tab_account.h"
#include "../../interface/widgets/tabs/tab_game.h"
#include "../../interface/widgets/tabs/tab_supervisor.h"
@ -41,6 +42,7 @@ UserContextMenu::UserContextMenu(TabSupervisor *_tabSupervisor, QWidget *parent,
aAddToIgnoreList = new QAction(QString(), this);
aRemoveFromIgnoreList = new QAction(QString(), this);
aKick = new QAction(QString(), this);
aReport = new QAction(QString(), this);
aWarnUser = new QAction(QString(), this);
aWarnHistory = new QAction(QString(), this);
aBan = new QAction(QString(), this);
@ -50,6 +52,7 @@ UserContextMenu::UserContextMenu(TabSupervisor *_tabSupervisor, QWidget *parent,
aPromoteToJudge = new QAction(QString(), this);
aDemoteFromJudge = new QAction(QString(), this);
aGetAdminNotes = new QAction(QString(), this);
aInvestigateUser = new QAction(QString(), this);
retranslateUi();
}
@ -64,6 +67,7 @@ void UserContextMenu::retranslateUi()
aAddToIgnoreList->setText(tr("Add to &ignore list"));
aRemoveFromIgnoreList->setText(tr("Remove from &ignore list"));
aKick->setText(tr("Kick from &game"));
aReport->setText(tr("Report user"));
aWarnUser->setText(tr("Warn user"));
aWarnHistory->setText(tr("View user's war&n history"));
aBan->setText(tr("Ban from &server"));
@ -73,6 +77,7 @@ void UserContextMenu::retranslateUi()
aPromoteToJudge->setText(tr("Promote user to &judge"));
aDemoteFromJudge->setText(tr("Demote user from judge"));
aGetAdminNotes->setText(tr("View admin notes"));
aInvestigateUser->setText(tr("Investigate user"));
}
void UserContextMenu::gamesOfUserReceived(const Response &resp, const CommandContainer &commandContainer)
@ -144,7 +149,8 @@ void UserContextMenu::warnUser_processGetWarningsListResponse(const Response &r)
if (response.warning_size() > 0) {
for (int i = 0; i < response.warning_size(); ++i) {
dlg->addWarningOption(QString::fromStdString(response.warning(i)).simplified());
int startingIl = i < response.warning_il_size() ? response.warning_il(i) : 1;
dlg->addWarningOption(QString::fromStdString(response.warning(i)).simplified(), startingIl);
}
}
dlg->show();
@ -395,6 +401,9 @@ void UserContextMenu::showContextMenu(const QPoint &pos,
aRemoveMessages = new QAction(tr("Remove this user's messages"), this);
menu->addAction(aRemoveMessages);
}
if (userListProxy->isOwnUserRegistered()) {
menu->addAction(aReport);
}
if (game && (game->isHost() || !tabSupervisor->getAdminLocked())) {
menu->addSeparator();
menu->addAction(aKick);
@ -408,6 +417,7 @@ void UserContextMenu::showContextMenu(const QPoint &pos,
menu->addAction(aBanHistory);
menu->addSeparator();
menu->addAction(aGetAdminNotes);
menu->addAction(aInvestigateUser);
menu->addSeparator();
if (userLevel.testFlag(ServerInfo_User::IsModerator) &&
@ -431,6 +441,7 @@ void UserContextMenu::showContextMenu(const QPoint &pos,
aDetails->setEnabled(true);
aChat->setEnabled(anotherUser && online);
aShowGames->setEnabled(online);
aReport->setEnabled(anotherUser);
aAddToBuddyList->setEnabled(anotherUser);
aRemoveFromBuddyList->setEnabled(anotherUser);
aAddToIgnoreList->setEnabled(anotherUser);
@ -441,6 +452,7 @@ void UserContextMenu::showContextMenu(const QPoint &pos,
aBan->setEnabled(anotherUser);
aBanHistory->setEnabled(anotherUser);
aGetAdminNotes->setEnabled(anotherUser);
aInvestigateUser->setEnabled(anotherUser);
aPromoteToMod->setEnabled(anotherUser);
aDemoteFromMod->setEnabled(anotherUser);
@ -462,6 +474,15 @@ void UserContextMenu::showContextMenu(const QPoint &pos,
execRemoveFromIgnore(userName);
} else if (actionClicked == aKick) {
execKick(playerId);
} else if (actionClicked == aReport) {
int gameId = game ? game->getGameMetaInfo()->gameId() : -1;
QString autoChatLog;
if (chatView) {
autoChatLog = chatView->getRecentChatLog(50);
}
auto dlgReport = new DlgReportUser(client, userName, gameId, autoChatLog, static_cast<QWidget *>(parent()));
dlgReport->setAttribute(Qt::WA_DeleteOnClose);
dlgReport->exec();
} else if (actionClicked == aBan) {
execBan(userName);
} else if (actionClicked == aPromoteToMod || actionClicked == aDemoteFromMod) {
@ -476,6 +497,8 @@ void UserContextMenu::showContextMenu(const QPoint &pos,
execWarnHistory(userName);
} else if (actionClicked == aGetAdminNotes) {
execAdminNotes(userName);
} else if (actionClicked == aInvestigateUser) {
execInvestigateUser(userName);
} else if (actionClicked == aCopyToClipBoard) {
QClipboard *clipboard = QGuiApplication::clipboard();
clipboard->setText(deckHash);
@ -652,6 +675,11 @@ void UserContextMenu::execAdminNotes(const QString &userName)
client->sendCommand(pend);
}
void UserContextMenu::execInvestigateUser(const QString &userName)
{
tabSupervisor->openTabModeration(userName);
}
void UserContextMenu::execAdjustMod(const QString &userName, bool shouldBeMod)
{
Command_AdjustMod cmd;

View file

@ -41,12 +41,14 @@ private:
QAction *aAddToBuddyList, *aRemoveFromBuddyList;
QAction *aAddToIgnoreList, *aRemoveFromIgnoreList;
QAction *aKick;
QAction *aReport;
QAction *aBan, *aBanHistory;
QAction *aPromoteToMod, *aDemoteFromMod;
QAction *aPromoteToJudge, *aDemoteFromJudge;
QAction *aWarnUser, *aWarnHistory;
QAction *aGetAdminNotes;
std::function<QList<GameInviteOption>()> gameInviteLinkProvider;
QAction *aInvestigateUser;
signals:
void openMessageDialog(const QString &userName, bool focus);
private slots:
@ -118,6 +120,7 @@ public:
void execBanHistory(const QString &userName);
void execWarnHistory(const QString &userName);
void execAdminNotes(const QString &userName);
void execInvestigateUser(const QString &userName);
void execAdjustMod(const QString &userName, bool shouldBeMod);
void execAdjustJudge(const QString &userName, bool shouldBeJudge);

View file

@ -151,7 +151,7 @@ WarningDialog::WarningDialog(const QString userName, const QString clientID, QWi
warnClientID = new QLineEdit(clientID);
warnClientID->setMaxLength(MAX_NAME_LENGTH);
warningOption = new QComboBox();
warningOption->addItem("");
warningOption->addItem("", "");
deleteMessages = new QCheckBox(tr("Redact all messages from this user in all rooms"));
@ -184,7 +184,7 @@ void WarningDialog::okClicked()
return;
}
if (warningOption->currentText().simplified().isEmpty()) {
if (warningOption->currentData().toString().simplified().isEmpty()) {
QMessageBox::critical(this, tr("Error"),
tr("Warning to use can not be blank, please select a valid warning to send."));
return;
@ -205,7 +205,7 @@ QString WarningDialog::getWarnID() const
QString WarningDialog::getReason() const
{
return warningOption->currentText().simplified();
return warningOption->currentData().toString().simplified();
}
int WarningDialog::getDeleteMessages() const
@ -213,9 +213,13 @@ int WarningDialog::getDeleteMessages() const
return deleteMessages->isChecked() ? -1 : 0;
}
void WarningDialog::addWarningOption(const QString warning)
void WarningDialog::addWarningOption(const QString warning, int startingIl)
{
warningOption->addItem(warning);
if (startingIl > 1) {
warningOption->addItem(tr("%1 (IL %2)").arg(warning).arg(startingIl), warning);
} else {
warningOption->addItem(warning, warning);
}
}
void BanDialog::okClicked()

View file

@ -85,7 +85,7 @@ public:
[[nodiscard]] QString getWarnID() const;
[[nodiscard]] QString getReason() const;
[[nodiscard]] int getDeleteMessages() const;
void addWarningOption(const QString warning);
void addWarningOption(const QString warning, int startingIl = 1);
};
class AdminNotesDialog : public QDialog

View file

@ -7,11 +7,14 @@
#include "../dialogs/override_printing_warning.h"
#include "../interface/theme_manager.h"
#include "../interface/widgets/general/background_sources.h"
#include "../playmat/playmat_collection_dialog.h"
#include "../playmat/playmat_settings_dialog.h"
#include <QApplication>
#include <QColorDialog>
#include <QDesktopServices>
#include <QGridLayout>
#include <QHBoxLayout>
#include <QMessageBox>
#include <QStyleFactory>
#include <QTimer>
@ -325,10 +328,52 @@ AppearanceSettingsPage::AppearanceSettingsPage()
tableGroupBox = new QGroupBox;
tableGroupBox->setLayout(tableGrid);
// Playmat settings
playmatVisibilityCombo.addItem(tr("Show all playmats"), PlaymatVisibilityAll);
playmatVisibilityCombo.addItem(tr("Show own playmat only"), PlaymatVisibilityOwnOnly);
playmatVisibilityCombo.addItem(tr("Don't use playmats"), PlaymatVisibilityNone);
int visIdx = playmatVisibilityCombo.findData(settings.userInterface().getPlaymatVisibility());
if (visIdx >= 0) {
playmatVisibilityCombo.setCurrentIndex(visIdx);
}
connect(&playmatVisibilityCombo, qOverload<int>(&QComboBox::currentIndexChanged), this, [this](int index) {
SettingsCache::instance().userInterface().setPlaymatVisibility(playmatVisibilityCombo.itemData(index).toInt());
});
playmatVisibilityLabel.setBuddy(&playmatVisibilityCombo);
// Playmat mode: Override / Fallback / Deck-only
playmatModeCombo.addItem(tr("Override deck playmat"), PlaymatModeOverrideDeck);
playmatModeCombo.addItem(tr("Fallback if deck has none"), PlaymatModeFallback);
playmatModeCombo.addItem(tr("Deck only, ignore collection"), PlaymatModeDeckOnly);
int modeIdx = playmatModeCombo.findData(settings.userInterface().getPlaymatMode());
if (modeIdx >= 0) {
playmatModeCombo.setCurrentIndex(modeIdx);
}
connect(&playmatModeCombo, qOverload<int>(&QComboBox::currentIndexChanged), this, [this](int index) {
SettingsCache::instance().userInterface().setPlaymatMode(playmatModeCombo.itemData(index).toInt());
});
playmatModeLabel.setBuddy(&playmatModeCombo);
// User-level playmat settings: fallback collection.
connect(&playmatDefaultEditButton, &QPushButton::clicked, this,
&AppearanceSettingsPage::openPlaymatCollectionDialog);
auto *playmatGrid = new QGridLayout;
playmatGrid->addWidget(&playmatVisibilityLabel, 0, 0, 1, 1);
playmatGrid->addWidget(&playmatVisibilityCombo, 0, 1, 1, 1);
playmatGrid->addWidget(&playmatModeLabel, 1, 0, 1, 1);
playmatGrid->addWidget(&playmatModeCombo, 1, 1, 1, 1);
playmatGrid->addWidget(&playmatDefaultLabel, 2, 0, 1, 1);
playmatGrid->addWidget(&playmatDefaultEditButton, 2, 1, 1, 1);
playmatGroupBox = new QGroupBox;
playmatGroupBox->setLayout(playmatGrid);
// putting it all together
auto *mainLayout = new QVBoxLayout;
mainLayout->addWidget(themeGroupBox);
mainLayout->addWidget(homeTabGroupBox);
mainLayout->addWidget(playmatGroupBox);
mainLayout->addWidget(stylingGroupBox);
mainLayout->addWidget(menuGroupBox);
mainLayout->addWidget(printingsGroupBox);
@ -431,6 +476,12 @@ void AppearanceSettingsPage::cardViewExpandedRowsMaxChanged(int value)
}
}
void AppearanceSettingsPage::openPlaymatCollectionDialog()
{
PlaymatCollectionDialog dialog(this);
dialog.exec();
}
void AppearanceSettingsPage::retranslateUi()
{
themeGroupBox->setTitle(tr("Theme settings"));
@ -489,4 +540,9 @@ void AppearanceSettingsPage::retranslateUi()
tableGroupBox->setTitle(tr("Table grid layout"));
invertVerticalCoordinateCheckBox.setText(tr("Invert vertical coordinate"));
minPlayersForMultiColumnLayoutLabel.setText(tr("Minimum player count for multi-column layout:"));
}
playmatGroupBox->setTitle(tr("Playmat settings"));
playmatVisibilityLabel.setText(tr("Playmat visibility:"));
playmatModeLabel.setText(tr("Default collection behavior:"));
playmatDefaultLabel.setText(tr("Default playmat collection:"));
playmatDefaultEditButton.setText(tr("Edit..."));
}

View file

@ -24,6 +24,7 @@ private slots:
void cardViewInitialRowsMaxChanged(int value);
void cardViewExpandedRowsMaxChanged(int value);
void openPlaymatCollectionDialog();
private:
QLabel themeLabel;
@ -59,6 +60,12 @@ private:
QCheckBox horizontalHandCheckBox;
QCheckBox leftJustifiedHandCheckBox;
QCheckBox invertVerticalCoordinateCheckBox;
QLabel playmatVisibilityLabel;
QComboBox playmatVisibilityCombo;
QLabel playmatModeLabel;
QComboBox playmatModeCombo;
QLabel playmatDefaultLabel;
QPushButton playmatDefaultEditButton;
QGroupBox *themeGroupBox;
QGroupBox *homeTabGroupBox;
QGroupBox *stylingGroupBox;
@ -67,6 +74,7 @@ private:
QGroupBox *cardsGroupBox;
QGroupBox *cardLayoutGroupBox;
QGroupBox *handGroupBox;
QGroupBox *playmatGroupBox;
QGroupBox *tableGroupBox;
QGroupBox *cardCountersGroupBox;
QList<QLabel *> cardCounterNames;

View file

@ -1,6 +1,7 @@
#include "tab_account.h"
#include "../client/sound_engine.h"
#include "../interface/widgets/dialogs/dlg_my_reports.h"
#include "../interface/widgets/server/user/user_info_box.h"
#include "../interface/widgets/server/user/user_list_manager.h"
#include "../interface/widgets/server/user/user_list_widget.h"
@ -49,6 +50,11 @@ TabAccount::TabAccount(TabSupervisor *_tabSupervisor, AbstractClient *_client, c
auto *vbox = new QVBoxLayout;
vbox->addWidget(userInfoBox);
myReportsButton = new QPushButton(tr("My Reports"));
connect(myReportsButton, &QPushButton::clicked, this, &TabAccount::openMyReports);
vbox->addWidget(myReportsButton);
vbox->addWidget(allUsersList);
auto *addToBuddyList = new QHBoxLayout;
@ -126,6 +132,7 @@ void TabAccount::addToList(const std::string &listName, const QString &userName)
void TabAccount::retranslateUi()
{
myReportsButton->setText(tr("My Reports"));
allUsersList->retranslateUi();
buddyList->retranslateUi();
ignoreList->retranslateUi();
@ -240,3 +247,10 @@ void TabAccount::processRemoveFromListEvent(const Event_RemoveFromList &event)
userList->deleteUser(user);
}
void TabAccount::openMyReports()
{
auto *dlg = new DlgMyReports(client, this);
dlg->setAttribute(Qt::WA_DeleteOnClose);
dlg->exec();
}

View file

@ -18,6 +18,7 @@ class Event_RemoveFromList;
class Event_UserJoined;
class Event_UserLeft;
class LineEditUnfocusable;
class QPushButton;
class Response;
class ServerInfo_User;
class UserInfoBox;
@ -41,6 +42,7 @@ private slots:
void processRemoveFromListEvent(const Event_RemoveFromList &event);
void addToIgnoreList();
void addToBuddyList();
void openMyReports();
private:
AbstractClient *client;
@ -50,6 +52,7 @@ private:
UserInfoBox *userInfoBox;
LineEditUnfocusable *addBuddyEdit;
LineEditUnfocusable *addIgnoreEdit;
QPushButton *myReportsButton;
void addToList(const std::string &listName, const QString &userName);
public:

View file

@ -911,6 +911,7 @@ void TabGame::stopGame()
QMapIterator<int, TabbedDeckViewContainer *> i(deckViewContainers);
while (i.hasNext()) {
i.next();
i.value()->playerDeckView->advancePlaymatRotation();
i.value()->show();
}

View file

@ -0,0 +1,462 @@
#include "tab_moderation.h"
#include "abstract_client.h"
#include "tab_supervisor.h"
#include <QDateTime>
#include <QGridLayout>
#include <QGroupBox>
#include <QHBoxLayout>
#include <QHeaderView>
#include <QLabel>
#include <QLineEdit>
#include <QMessageBox>
#include <QPushButton>
#include <QSplitter>
#include <QTableWidget>
#include <QTextEdit>
#include <QVBoxLayout>
#include <libcockatrice/protocol/pb/command_report_user_info.pb.h>
#include <libcockatrice/protocol/pb/moderator_commands.pb.h>
#include <libcockatrice/protocol/pb/response_moderator_last_logins.pb.h>
#include <libcockatrice/protocol/pb/response_remove_user_avatar.pb.h>
#include <libcockatrice/protocol/pb/response_report_user_info.pb.h>
#include <libcockatrice/protocol/pb/response_reset_user_password.pb.h>
#include <libcockatrice/protocol/pb/response_user_alts.pb.h>
#include <libcockatrice/protocol/pb/response_user_sessions.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_user.pb.h>
#include <libcockatrice/protocol/pending_command.h>
namespace
{
constexpr int COL_ALTS_USER = 0;
constexpr int COL_ALTS_EMAIL = 1;
constexpr int COL_ALTS_CLIENTID = 2;
constexpr int COL_ALTS_REGISTERED = 3;
constexpr int COL_ALTS_LAST_LOGIN = 4;
constexpr int COL_ALTS_WARNS = 5;
constexpr int COL_ALTS_BANS = 6;
constexpr int COL_ALTS_ACTIVE = 7;
constexpr int COL_ALTS_COUNT = 8;
constexpr int COL_SESSIONS_IP = 0;
constexpr int COL_SESSIONS_CLIENTID = 1;
constexpr int COL_SESSIONS_START = 2;
constexpr int COL_SESSIONS_END = 3;
constexpr int COL_SESSIONS_TYPE = 4;
constexpr int COL_SESSIONS_COUNT = 5;
constexpr int COL_STAFF_USER = 0;
constexpr int COL_STAFF_LEVEL = 1;
constexpr int COL_STAFF_LAST_LOGIN = 2;
constexpr int COL_STAFF_COUNT = 3;
} // namespace
TabModeration::TabModeration(TabSupervisor *_tabSupervisor, AbstractClient *_client, const QString &initialUser)
: Tab(_tabSupervisor), client(_client)
{
auto *centralWidget = new QWidget(this);
setCentralWidget(centralWidget);
searchEdit = new QLineEdit;
searchEdit->setClearButtonEnabled(true);
connect(searchEdit, &QLineEdit::returnPressed, this, &TabModeration::investigateUser);
investigateButton = new QPushButton;
connect(investigateButton, &QPushButton::clicked, this, &TabModeration::investigateUser);
resetPasswordButton = new QPushButton;
connect(resetPasswordButton, &QPushButton::clicked, this, &TabModeration::resetPassword);
removeAvatarButton = new QPushButton;
connect(removeAvatarButton, &QPushButton::clicked, this, &TabModeration::removeAvatar);
auto *topBar = new QHBoxLayout;
topBar->addWidget(searchEdit);
topBar->addWidget(investigateButton);
topBar->addStretch();
topBar->addWidget(resetPasswordButton);
topBar->addWidget(removeAvatarButton);
userInfoGroup = new QGroupBox;
userInfoNameLabel = new QLabel;
userInfoNameValue = new QLabel;
userInfoRegisteredLabel = new QLabel;
userInfoRegisteredValue = new QLabel;
userInfoLastLoginLabel = new QLabel;
userInfoLastLoginValue = new QLabel;
userInfoStatusLabel = new QLabel;
userInfoStatusValue = new QLabel;
userInfoCountsLabel = new QLabel;
userInfoCountsValue = new QLabel;
userInfoNotesLabel = new QLabel;
userInfoNotesEdit = new QTextEdit;
userInfoNotesEdit->setReadOnly(true);
auto *infoGrid = new QGridLayout;
infoGrid->addWidget(userInfoNameLabel, 0, 0);
infoGrid->addWidget(userInfoNameValue, 0, 1);
infoGrid->addWidget(userInfoRegisteredLabel, 0, 2);
infoGrid->addWidget(userInfoRegisteredValue, 0, 3);
infoGrid->addWidget(userInfoLastLoginLabel, 1, 0);
infoGrid->addWidget(userInfoLastLoginValue, 1, 1);
infoGrid->addWidget(userInfoStatusLabel, 1, 2);
infoGrid->addWidget(userInfoStatusValue, 1, 3);
infoGrid->addWidget(userInfoCountsLabel, 2, 0);
infoGrid->addWidget(userInfoCountsValue, 2, 1, 1, 3);
infoGrid->addWidget(userInfoNotesLabel, 3, 0, Qt::AlignTop);
infoGrid->addWidget(userInfoNotesEdit, 3, 1, 1, 3);
infoGrid->setColumnStretch(1, 1);
infoGrid->setColumnStretch(3, 1);
auto *infoLayout = new QVBoxLayout(userInfoGroup);
infoLayout->addLayout(infoGrid);
auto configureTable = [](QTableWidget *table) {
table->setSelectionBehavior(QAbstractItemView::SelectRows);
table->setSelectionMode(QAbstractItemView::SingleSelection);
table->setEditTriggers(QAbstractItemView::NoEditTriggers);
table->verticalHeader()->setVisible(false);
table->setAlternatingRowColors(true);
table->horizontalHeader()->setSectionResizeMode(QHeaderView::ResizeToContents);
table->horizontalHeader()->setStretchLastSection(true);
};
altsGroup = new QGroupBox;
altsTable = new QTableWidget(0, COL_ALTS_COUNT);
configureTable(altsTable);
auto *altsLayout = new QVBoxLayout(altsGroup);
altsLayout->addWidget(altsTable);
sessionsGroup = new QGroupBox;
sessionsTable = new QTableWidget(0, COL_SESSIONS_COUNT);
configureTable(sessionsTable);
auto *sessionsLayout = new QVBoxLayout(sessionsGroup);
sessionsLayout->addWidget(sessionsTable);
staffGroup = new QGroupBox;
staffTable = new QTableWidget(0, COL_STAFF_COUNT);
configureTable(staffTable);
refreshStaffButton = new QPushButton;
connect(refreshStaffButton, &QPushButton::clicked, this, &TabModeration::requestModeratorLogins);
auto *staffHeader = new QHBoxLayout;
staffHeader->addStretch();
staffHeader->addWidget(refreshStaffButton);
auto *staffLayout = new QVBoxLayout(staffGroup);
staffLayout->addWidget(staffTable);
staffLayout->addLayout(staffHeader);
auto *splitter = new QSplitter(Qt::Vertical);
splitter->addWidget(userInfoGroup);
splitter->addWidget(altsGroup);
splitter->addWidget(sessionsGroup);
splitter->addWidget(staffGroup);
splitter->setStretchFactor(0, 1);
splitter->setStretchFactor(1, 2);
splitter->setStretchFactor(2, 2);
splitter->setStretchFactor(3, 1);
auto *mainLayout = new QVBoxLayout(centralWidget);
mainLayout->addLayout(topBar);
mainLayout->addWidget(splitter);
retranslateUi();
clearUserData();
requestModeratorLogins();
investigate(initialUser);
}
void TabModeration::retranslateUi()
{
searchEdit->setPlaceholderText(tr("User name"));
investigateButton->setText(tr("Investigate"));
resetPasswordButton->setText(tr("Reset Password"));
removeAvatarButton->setText(tr("Remove Avatar"));
refreshStaffButton->setText(tr("Refresh"));
userInfoGroup->setTitle(tr("User Info"));
userInfoNameLabel->setText(tr("Name:"));
userInfoRegisteredLabel->setText(tr("Registered:"));
userInfoLastLoginLabel->setText(tr("Last login:"));
userInfoStatusLabel->setText(tr("Status:"));
userInfoCountsLabel->setText(tr("Counts:"));
userInfoNotesLabel->setText(tr("Admin notes:"));
altsGroup->setTitle(tr("Alts"));
sessionsGroup->setTitle(tr("Sessions"));
staffGroup->setTitle(tr("Staff Last Logins"));
altsTable->setHorizontalHeaderLabels({tr("User"), tr("eMail"), tr("Client ID"), tr("Registered"), tr("Last login"),
tr("Warns"), tr("Bans"), tr("Active")});
sessionsTable->setHorizontalHeaderLabels({tr("IP"), tr("Client ID"), tr("Start"), tr("End"), tr("Type")});
staffTable->setHorizontalHeaderLabels({tr("User"), tr("Level"), tr("Last login")});
}
QString TabModeration::formatEpoch(quint64 ts) const
{
if (ts == 0) {
return tr("Unknown");
}
return QDateTime::fromSecsSinceEpoch(ts).toLocalTime().toString("yyyy-MM-dd HH:mm");
}
void TabModeration::clearUserData()
{
currentUser.clear();
userInfoNameValue->clear();
userInfoRegisteredValue->clear();
userInfoLastLoginValue->clear();
userInfoStatusValue->clear();
userInfoCountsValue->clear();
userInfoNotesEdit->clear();
altsTable->setRowCount(0);
sessionsTable->setRowCount(0);
resetPasswordButton->setEnabled(false);
removeAvatarButton->setEnabled(false);
}
void TabModeration::investigate(const QString &userName)
{
if (userName.isEmpty()) {
return;
}
searchEdit->setText(userName);
investigateUser();
}
void TabModeration::investigateUser()
{
const QString userName = searchEdit->text().simplified();
if (userName.isEmpty()) {
return;
}
currentUser = userName;
resetPasswordButton->setEnabled(true);
removeAvatarButton->setEnabled(true);
altsTable->setRowCount(0);
sessionsTable->setRowCount(0);
requestUserInfo(userName);
requestSessions(userName);
requestAlts(userName);
}
void TabModeration::requestUserInfo(const QString &userName)
{
userInfoNameValue->setText(userName);
userInfoRegisteredValue->setText(tr("Loading..."));
userInfoLastLoginValue->setText(tr("Loading..."));
userInfoStatusValue->clear();
userInfoCountsValue->clear();
userInfoNotesEdit->clear();
Command_ReportUserInfo cmd;
cmd.set_user_name(userName.toStdString());
PendingCommand *pend = client->prepareModeratorCommand(cmd);
connect(pend, &PendingCommand::finished, this, &TabModeration::userInfoResponse);
client->sendCommand(pend);
}
void TabModeration::userInfoResponse(const Response &response)
{
if (response.response_code() != Response::RespOk) {
userInfoRegisteredValue->clear();
userInfoLastLoginValue->clear();
userInfoStatusValue->setText(tr("Error loading user info."));
return;
}
const Response_ReportUserInfo &resp = response.GetExtension(Response_ReportUserInfo::ext);
if (resp.has_user_name() && QString::fromStdString(resp.user_name()) != currentUser) {
return;
}
userInfoRegisteredValue->setText(formatEpoch(resp.registration_time()));
userInfoLastLoginValue->setText(formatEpoch(resp.last_login()));
QStringList statusParts;
statusParts << (resp.is_active() ? tr("active") : tr("inactive"));
if (resp.has_is_admin() && resp.is_admin()) {
statusParts << tr("admin");
}
userInfoStatusValue->setText(statusParts.join(", "));
userInfoCountsValue->setText(tr("Reports: %1 Bans: %2 Warnings: %3")
.arg(resp.total_reports())
.arg(resp.total_bans())
.arg(resp.total_warns()));
if (resp.has_admin_notes() && !resp.admin_notes().empty()) {
userInfoNotesEdit->setPlainText(QString::fromStdString(resp.admin_notes()));
} else {
userInfoNotesEdit->setPlainText(tr("(none)"));
}
}
void TabModeration::requestSessions(const QString &userName)
{
Command_GetUserSessions cmd;
cmd.set_user_name(userName.toStdString());
PendingCommand *pend = client->prepareModeratorCommand(cmd);
connect(pend, &PendingCommand::finished, this, &TabModeration::sessionsResponse);
client->sendCommand(pend);
}
void TabModeration::sessionsResponse(const Response &response)
{
sessionsTable->setRowCount(0);
if (response.response_code() != Response::RespOk) {
return;
}
const Response_UserSessions &resp = response.GetExtension(Response_UserSessions::ext);
sessionsTable->setRowCount(resp.sessions_size());
for (int i = 0; i < resp.sessions_size(); ++i) {
const ServerInfo_UserSession &session = resp.sessions(i);
sessionsTable->setItem(i, COL_SESSIONS_IP, new QTableWidgetItem(QString::fromStdString(session.ip_address())));
sessionsTable->setItem(i, COL_SESSIONS_CLIENTID,
new QTableWidgetItem(QString::fromStdString(session.clientid())));
sessionsTable->setItem(i, COL_SESSIONS_START, new QTableWidgetItem(formatEpoch(session.start_time())));
sessionsTable->setItem(
i, COL_SESSIONS_END,
new QTableWidgetItem(session.end_time() == 0 ? tr("Active") : formatEpoch(session.end_time())));
sessionsTable->setItem(i, COL_SESSIONS_TYPE,
new QTableWidgetItem(QString::fromStdString(session.connection_type())));
}
}
void TabModeration::requestAlts(const QString &userName)
{
Command_GetUserAlts cmd;
cmd.set_user_name(userName.toStdString());
PendingCommand *pend = client->prepareModeratorCommand(cmd);
connect(pend, &PendingCommand::finished, this, &TabModeration::altsResponse);
client->sendCommand(pend);
}
void TabModeration::altsResponse(const Response &response)
{
altsTable->setRowCount(0);
if (response.response_code() != Response::RespOk) {
return;
}
const Response_UserAlts &resp = response.GetExtension(Response_UserAlts::ext);
altsTable->setRowCount(resp.alts_size());
for (int i = 0; i < resp.alts_size(); ++i) {
const ServerInfo_UserAlt &alt = resp.alts(i);
altsTable->setItem(i, COL_ALTS_USER, new QTableWidgetItem(QString::fromStdString(alt.user_name())));
altsTable->setItem(i, COL_ALTS_EMAIL, new QTableWidgetItem(QString::fromStdString(alt.email())));
altsTable->setItem(i, COL_ALTS_CLIENTID, new QTableWidgetItem(QString::fromStdString(alt.clientid())));
altsTable->setItem(i, COL_ALTS_REGISTERED, new QTableWidgetItem(formatEpoch(alt.registration_time())));
altsTable->setItem(i, COL_ALTS_LAST_LOGIN, new QTableWidgetItem(formatEpoch(alt.last_login())));
altsTable->setItem(i, COL_ALTS_WARNS, new QTableWidgetItem(QString::number(alt.warn_count())));
altsTable->setItem(i, COL_ALTS_BANS, new QTableWidgetItem(QString::number(alt.ban_count())));
altsTable->setItem(i, COL_ALTS_ACTIVE, new QTableWidgetItem(alt.is_active() ? tr("yes") : tr("no")));
}
}
void TabModeration::requestModeratorLogins()
{
Command_GetModeratorLastLogins cmd;
PendingCommand *pend = client->prepareModeratorCommand(cmd);
connect(pend, &PendingCommand::finished, this, &TabModeration::moderatorLoginsResponse);
client->sendCommand(pend);
}
void TabModeration::moderatorLoginsResponse(const Response &response)
{
staffTable->setRowCount(0);
if (response.response_code() != Response::RespOk) {
return;
}
const Response_ModeratorLastLogins &resp = response.GetExtension(Response_ModeratorLastLogins::ext);
staffTable->setRowCount(resp.logins_size());
for (int i = 0; i < resp.logins_size(); ++i) {
const ServerInfo_ModeratorLogin &login = resp.logins(i);
staffTable->setItem(i, COL_STAFF_USER, new QTableWidgetItem(QString::fromStdString(login.user_name())));
staffTable->setItem(i, COL_STAFF_LAST_LOGIN, new QTableWidgetItem(formatEpoch(login.last_login())));
QStringList levels;
if (login.user_level() & ServerInfo_User::IsAdmin) {
levels << tr("Admin");
}
if (login.user_level() & ServerInfo_User::IsModerator) {
levels << tr("Moderator");
}
if (login.user_level() & ServerInfo_User::IsJudge) {
levels << tr("Judge");
}
staffTable->setItem(i, COL_STAFF_LEVEL, new QTableWidgetItem(levels.join(" / ")));
}
}
void TabModeration::resetPassword()
{
if (currentUser.isEmpty()) {
return;
}
QMessageBox::StandardButton choice =
QMessageBox::warning(this, tr("Reset Password"),
tr("Reset the password of %1? A temporary password will be generated and shown to you. "
"The user must change it on their first login.")
.arg(currentUser),
QMessageBox::Ok | QMessageBox::Cancel, QMessageBox::Cancel);
if (choice != QMessageBox::Ok) {
return;
}
Command_ResetUserPassword cmd;
cmd.set_user_name(currentUser.toStdString());
PendingCommand *pend = client->prepareModeratorCommand(cmd);
connect(pend, &PendingCommand::finished, this, &TabModeration::resetPasswordResponse);
client->sendCommand(pend);
}
void TabModeration::resetPasswordResponse(const Response &response)
{
if (response.response_code() != Response::RespOk) {
QMessageBox::critical(this, tr("Error"), tr("Password reset failed."));
return;
}
const Response_ResetUserPassword &resp = response.GetExtension(Response_ResetUserPassword::ext);
QMessageBox::information(this, tr("Password reset"),
tr("Temporary password for %1:\n\n%2\n\nPass it to the user through a secure channel.")
.arg(QString::fromStdString(resp.user_name()))
.arg(QString::fromStdString(resp.temporary_password())));
}
void TabModeration::removeAvatar()
{
if (currentUser.isEmpty()) {
return;
}
QMessageBox::StandardButton choice =
QMessageBox::warning(this, tr("Remove Avatar"),
tr("Remove the avatar of %1? The user will have to upload a new one.").arg(currentUser),
QMessageBox::Ok | QMessageBox::Cancel, QMessageBox::Cancel);
if (choice != QMessageBox::Ok) {
return;
}
Command_RemoveUserAvatar cmd;
cmd.set_user_name(currentUser.toStdString());
PendingCommand *pend = client->prepareModeratorCommand(cmd);
connect(pend, &PendingCommand::finished, this, &TabModeration::removeAvatarResponse);
client->sendCommand(pend);
}
void TabModeration::removeAvatarResponse(const Response &response)
{
if (response.response_code() != Response::RespOk) {
QMessageBox::critical(this, tr("Error"), tr("Could not remove the avatar."));
return;
}
const Response_RemoveUserAvatar &resp = response.GetExtension(Response_RemoveUserAvatar::ext);
QMessageBox::information(this, tr("Avatar removed"),
tr("The avatar of %1 has been removed.").arg(QString::fromStdString(resp.user_name())));
}

View file

@ -0,0 +1,83 @@
#ifndef TAB_MODERATION_H
#define TAB_MODERATION_H
#include "tab.h"
#include <libcockatrice/protocol/pb/response.pb.h>
class AbstractClient;
class QGroupBox;
class QLabel;
class QLineEdit;
class QPushButton;
class QTableWidget;
class QTextEdit;
/**
* Staff investigation tool. Lets moderators look up a user's account data,
* alternate accounts, login sessions, and staff login activity, and offers
* the password-reset and remove-avatar actions.
*/
class TabModeration : public Tab
{
Q_OBJECT
public:
explicit TabModeration(TabSupervisor *_tabSupervisor, AbstractClient *_client, const QString &initialUser = {});
void retranslateUi() override;
[[nodiscard]] QString getTabText() const override
{
return tr("Moderation");
}
void investigate(const QString &userName);
private slots:
void investigateUser();
void userInfoResponse(const Response &response);
void sessionsResponse(const Response &response);
void altsResponse(const Response &response);
void moderatorLoginsResponse(const Response &response);
void resetPassword();
void resetPasswordResponse(const Response &response);
void removeAvatar();
void removeAvatarResponse(const Response &response);
private:
void requestUserInfo(const QString &userName);
void requestSessions(const QString &userName);
void requestAlts(const QString &userName);
void requestModeratorLogins();
void clearUserData();
[[nodiscard]] QString formatEpoch(quint64 ts) const;
AbstractClient *client;
QString currentUser;
QLineEdit *searchEdit;
QPushButton *investigateButton;
QPushButton *resetPasswordButton;
QPushButton *removeAvatarButton;
QGroupBox *userInfoGroup;
QLabel *userInfoNameLabel;
QLabel *userInfoNameValue;
QLabel *userInfoRegisteredLabel;
QLabel *userInfoRegisteredValue;
QLabel *userInfoLastLoginLabel;
QLabel *userInfoLastLoginValue;
QLabel *userInfoStatusLabel;
QLabel *userInfoStatusValue;
QLabel *userInfoCountsLabel;
QLabel *userInfoCountsValue;
QLabel *userInfoNotesLabel;
QTextEdit *userInfoNotesEdit;
QGroupBox *altsGroup;
QTableWidget *altsTable;
QGroupBox *sessionsGroup;
QTableWidget *sessionsTable;
QGroupBox *staffGroup;
QTableWidget *staffTable;
QPushButton *refreshStaffButton;
};
#endif // TAB_MODERATION_H

View file

@ -0,0 +1,927 @@
#include "tab_report.h"
#include "../utility/report_utils.h"
#include "abstract_client.h"
#include "tab_supervisor.h"
#include <QCheckBox>
#include <QComboBox>
#include <QDateTime>
#include <QFontDatabase>
#include <QGridLayout>
#include <QGroupBox>
#include <QHBoxLayout>
#include <QHeaderView>
#include <QInputDialog>
#include <QLabel>
#include <QLineEdit>
#include <QMessageBox>
#include <QPushButton>
#include <QSignalBlocker>
#include <QSplitter>
#include <QTableWidget>
#include <QTextCursor>
#include <QTextEdit>
#include <QTimer>
#include <QVBoxLayout>
#include <QWidget>
#include <libcockatrice/protocol/pb/command_replay_download_by_game_id.pb.h>
#include <libcockatrice/protocol/pb/command_report_add_comment.pb.h>
#include <libcockatrice/protocol/pb/command_report_assign.pb.h>
#include <libcockatrice/protocol/pb/command_report_details.pb.h>
#include <libcockatrice/protocol/pb/command_report_list.pb.h>
#include <libcockatrice/protocol/pb/command_report_resolve.pb.h>
#include <libcockatrice/protocol/pb/command_report_stats.pb.h>
#include <libcockatrice/protocol/pb/command_report_user_info.pb.h>
#include <libcockatrice/protocol/pb/game_replay.pb.h>
#include <libcockatrice/protocol/pb/response_replay_download_by_game_id.pb.h>
#include <libcockatrice/protocol/pb/response_report_details.pb.h>
#include <libcockatrice/protocol/pb/response_report_list.pb.h>
#include <libcockatrice/protocol/pb/response_report_stats.pb.h>
#include <libcockatrice/protocol/pb/response_report_user_info.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_report.pb.h>
#include <libcockatrice/protocol/pending_command.h>
namespace
{
constexpr int COL_ID = 0;
constexpr int COL_TIME = 1;
constexpr int COL_REPORTER = 2;
constexpr int COL_REPORTED = 3;
constexpr int COL_CATEGORY = 4;
constexpr int COL_GAMEID = 5;
constexpr int COL_STATUS = 6;
constexpr int COL_ASSIGNED = 7;
constexpr int COL_REPLAY = 8;
constexpr int COL_ROOM = 9;
constexpr int COL_COUNT = 10;
constexpr int REFRESH_INTERVAL_MS = 300000;
} // namespace
TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : Tab(_tabSupervisor), client(_client)
{
auto *centralWidget = new QWidget(this);
setCentralWidget(centralWidget);
searchEdit = new QLineEdit;
searchEdit->setClearButtonEnabled(true);
connect(searchEdit, &QLineEdit::textChanged, this, &TabReport::applyFilters);
statusFilter = new QComboBox;
connect(statusFilter, &QComboBox::currentIndexChanged, this, &TabReport::applyFilters);
unresolvedOnlyBox = new QCheckBox;
unresolvedOnlyBox->setChecked(true);
#if QT_VERSION >= QT_VERSION_CHECK(6, 7, 0)
connect(unresolvedOnlyBox, &QCheckBox::checkStateChanged, this, &TabReport::refreshList);
#else
connect(unresolvedOnlyBox, &QCheckBox::stateChanged, this, &TabReport::refreshList);
#endif
refreshButton = new QPushButton;
connect(refreshButton, &QPushButton::clicked, this, &TabReport::refreshList);
refreshTimer = new QTimer(this);
refreshTimer->setInterval(REFRESH_INTERVAL_MS);
connect(refreshTimer, &QTimer::timeout, this, [this]() {
if (tabSupervisor->currentWidget() == this) {
refreshList();
}
});
refreshTimer->start();
auto *topBar = new QHBoxLayout;
topBar->addWidget(searchEdit);
topBar->addWidget(statusFilter);
topBar->addWidget(unresolvedOnlyBox);
topBar->addStretch();
topBar->addWidget(refreshButton);
statsLabel = new QLabel;
table = new QTableWidget(0, COL_COUNT);
table->setSelectionBehavior(QAbstractItemView::SelectRows);
table->setSelectionMode(QAbstractItemView::SingleSelection);
table->setEditTriggers(QAbstractItemView::NoEditTriggers);
table->setSortingEnabled(true);
table->verticalHeader()->setVisible(false);
table->setAlternatingRowColors(true);
table->horizontalHeader()->setSectionResizeMode(COL_TIME, QHeaderView::ResizeToContents);
table->horizontalHeader()->setSectionResizeMode(COL_REPORTED, QHeaderView::Stretch);
table->horizontalHeader()->setSectionResizeMode(COL_REPORTER, QHeaderView::ResizeToContents);
connect(table, &QTableWidget::itemSelectionChanged, this, &TabReport::onSelectionChanged);
descGroup = new QGroupBox;
descriptionEdit = new QTextEdit;
descriptionEdit->setReadOnly(true);
auto *descLayout = new QVBoxLayout(descGroup);
descLayout->setContentsMargins(4, 4, 4, 4);
descLayout->addWidget(descriptionEdit);
chatGroup = new QGroupBox;
chatLogEdit = new QTextEdit;
chatLogEdit->setReadOnly(true);
QFont monoFont("monospace");
monoFont.setStyleHint(QFont::Monospace);
const int systemPointSize = QFontDatabase::systemFont(QFontDatabase::GeneralFont).pointSize();
if (systemPointSize > 0) {
monoFont.setPointSize(systemPointSize);
}
chatLogEdit->setFont(monoFont);
auto *chatLayout = new QVBoxLayout(chatGroup);
chatLayout->setContentsMargins(4, 4, 4, 4);
chatLayout->addWidget(chatLogEdit);
commentsGroup = new QGroupBox;
commentsEdit = new QTextEdit;
commentsEdit->setReadOnly(true);
auto *commentInputRow = new QHBoxLayout;
commentInput = new QLineEdit;
commentButton = new QPushButton;
commentButton->setEnabled(false);
connect(commentButton, &QPushButton::clicked, this, &TabReport::addComment);
connect(commentInput, &QLineEdit::returnPressed, this, &TabReport::addComment);
commentInputRow->addWidget(commentInput);
commentInputRow->addWidget(commentButton);
auto *commentsLayout = new QVBoxLayout(commentsGroup);
commentsLayout->setContentsMargins(4, 4, 4, 4);
commentsLayout->addWidget(commentsEdit);
commentsLayout->addLayout(commentInputRow);
userContextGroup = new QGroupBox;
userContextName = new QLabel;
userContextAccountAge = new QLabel;
userContextReports = new QLabel;
userContextBans = new QLabel;
userContextWarns = new QLabel;
userContextNotes = new QTextEdit;
userContextNotes->setReadOnly(true);
userContextNotes->setMaximumHeight(80);
userContextRecentReports = new QTextEdit;
userContextRecentReports->setReadOnly(true);
userContextRecentReports->setMaximumHeight(120);
userContextUserLabel = new QLabel;
userContextAgeLabel = new QLabel;
userContextReportsLabel = new QLabel;
userContextBansLabel = new QLabel;
userContextWarnsLabel = new QLabel;
userContextNotesLabel = new QLabel;
userContextRecentReportsLabel = new QLabel;
auto *ucLayout = new QGridLayout(userContextGroup);
ucLayout->setContentsMargins(8, 8, 8, 8);
ucLayout->addWidget(userContextUserLabel, 0, 0);
ucLayout->addWidget(userContextName, 0, 1, 1, 3);
ucLayout->addWidget(userContextAgeLabel, 1, 0);
ucLayout->addWidget(userContextAccountAge, 1, 1);
ucLayout->addWidget(userContextReportsLabel, 1, 2);
ucLayout->addWidget(userContextReports, 1, 3);
ucLayout->addWidget(userContextBansLabel, 2, 0);
ucLayout->addWidget(userContextBans, 2, 1);
ucLayout->addWidget(userContextWarnsLabel, 2, 2);
ucLayout->addWidget(userContextWarns, 2, 3);
ucLayout->addWidget(userContextNotesLabel, 3, 0, Qt::AlignTop);
ucLayout->addWidget(userContextNotes, 3, 1, 1, 3);
ucLayout->addWidget(userContextRecentReportsLabel, 4, 0, 1, 4);
ucLayout->addWidget(userContextRecentReports, 5, 0, 1, 4);
userContextGroup->setVisible(false);
statsGroup = new QGroupBox;
statsGroup->setCheckable(true);
statsTotalLabel = new QLabel;
statsTrendLabel = new QLabel;
statsCategoriesLabel = new QLabel;
statsDetailText = new QTextEdit;
statsDetailText->setReadOnly(true);
statsDetailText->setMaximumHeight(150);
auto *statsContent = new QWidget;
auto *sgLayout = new QVBoxLayout(statsContent);
sgLayout->setContentsMargins(8, 8, 8, 8);
sgLayout->addWidget(statsTotalLabel);
sgLayout->addWidget(statsTrendLabel);
sgLayout->addWidget(statsCategoriesLabel);
sgLayout->addWidget(statsDetailText);
auto *statsGroupLayout = new QVBoxLayout(statsGroup);
statsGroupLayout->setContentsMargins(0, 0, 0, 0);
statsGroupLayout->addWidget(statsContent);
statsGroup->setChecked(true);
connect(statsGroup, &QGroupBox::toggled, this, [this, statsContent](bool checked) {
statsContent->setVisible(checked);
if (checked) {
requestStats();
}
});
detailSplitter = new QSplitter(Qt::Vertical);
detailSplitter->addWidget(descGroup);
detailSplitter->addWidget(chatGroup);
detailSplitter->addWidget(commentsGroup);
detailSplitter->setStretchFactor(0, 1);
detailSplitter->setStretchFactor(1, 1);
detailSplitter->setStretchFactor(2, 2);
assignButton = new QPushButton;
resolveButton = new QPushButton;
resolveWithNoteButton = new QPushButton;
dismissButton = new QPushButton;
viewReplayButton = new QPushButton;
joinGameButton = new QPushButton;
connect(assignButton, &QPushButton::clicked, this, &TabReport::assignReport);
connect(resolveButton, &QPushButton::clicked, this, [this]() { resolveReport(false, false); });
connect(resolveWithNoteButton, &QPushButton::clicked, this, [this]() { resolveReport(false, true); });
connect(dismissButton, &QPushButton::clicked, this, [this]() { resolveReport(true, true); });
connect(viewReplayButton, &QPushButton::clicked, this, &TabReport::viewReplay);
connect(joinGameButton, &QPushButton::clicked, this, &TabReport::joinGame);
statusLabel = new QLabel;
auto *actionBar = new QHBoxLayout;
actionBar->addWidget(assignButton);
actionBar->addWidget(resolveButton);
actionBar->addWidget(resolveWithNoteButton);
actionBar->addWidget(dismissButton);
actionBar->addSpacing(20);
actionBar->addWidget(viewReplayButton);
actionBar->addWidget(joinGameButton);
actionBar->addStretch();
actionBar->addWidget(statusLabel);
auto *layout = new QVBoxLayout(centralWidget);
layout->addLayout(topBar);
layout->addWidget(statsLabel);
layout->addWidget(table, 2);
layout->addWidget(detailSplitter, 1);
layout->addWidget(userContextGroup);
layout->addWidget(statsGroup);
layout->addLayout(actionBar);
retranslateUi();
setActionsEnabled(false);
refreshList();
}
void TabReport::retranslateUi()
{
searchEdit->setPlaceholderText(tr("Search by username, category..."));
statusFilter->clear();
statusFilter->addItem(tr("All Statuses"), "");
statusFilter->addItem(tr("Open"), "open");
statusFilter->addItem(tr("Assigned"), "assigned");
statusFilter->addItem(tr("Resolved"), "resolved");
statusFilter->addItem(tr("Dismissed"), "dismissed");
unresolvedOnlyBox->setText(tr("Unresolved only"));
refreshButton->setText(tr("Refresh"));
table->setHorizontalHeaderLabels({tr("#"), tr("Time"), tr("Reporter"), tr("Reported User"), tr("Category"),
tr("Game ID"), tr("Status"), tr("Assigned To"), tr("Replay"), tr("Room")});
descGroup->setTitle(tr("Description"));
chatGroup->setTitle(tr("Chat Log Context"));
chatGroup->setToolTip(
tr("Chat log attached by the reporter. It is captured from their client and may be incomplete or edited."));
commentsGroup->setTitle(tr("Comments / Thread"));
descriptionEdit->setPlaceholderText(tr("No description."));
chatLogEdit->setPlaceholderText(tr("No chat log attached."));
commentsEdit->setPlaceholderText(tr("No comments yet."));
commentInput->setPlaceholderText(tr("Type a reply..."));
commentButton->setText(tr("Send"));
userContextGroup->setTitle(tr("Reported User Context"));
userContextUserLabel->setText(tr("User:"));
userContextAgeLabel->setText(tr("Account Age:"));
userContextReportsLabel->setText(tr("Reports:"));
userContextBansLabel->setText(tr("Bans:"));
userContextWarnsLabel->setText(tr("Warns:"));
userContextNotesLabel->setText(tr("Admin Notes:"));
userContextRecentReportsLabel->setText(tr("Recent Reports Against User:"));
userContextAccountAge->setText(QString());
userContextReports->setText(QString());
userContextBans->setText(QString());
userContextWarns->setText(QString());
statsGroup->setTitle(tr("Report Statistics"));
assignButton->setText(tr("Assign to Me"));
resolveButton->setText(tr("Resolve"));
resolveWithNoteButton->setText(tr("Resolve with note..."));
dismissButton->setText(tr("Dismiss..."));
viewReplayButton->setText(tr("View Replay"));
joinGameButton->setText(tr("Join Game"));
}
void TabReport::refreshList()
{
selectedReportIdBeforeRefresh = selectedReportId();
commentDraftBeforeRefresh = commentInput->text();
previousSelectedReportValid = selectedReportInfo(previousSelectedReport);
refreshButton->setEnabled(false);
statusLabel->setText(tr("Loading..."));
table->setRowCount(0);
allReports.clear();
filteredReports.clear();
commentButton->setEnabled(false);
setActionsEnabled(false);
Command_ReportList cmd;
cmd.set_unresolved_only(unresolvedOnlyBox->isChecked());
PendingCommand *pend = client->prepareModeratorCommand(cmd);
connect(pend, &PendingCommand::finished, this, &TabReport::reportListResponse);
client->sendCommand(pend);
}
void TabReport::reportListResponse(const Response &response)
{
refreshButton->setEnabled(true);
if (response.response_code() != Response::RespOk) {
statusLabel->setText(tr("Failed to load reports."));
return;
}
const Response_ReportList &resp = response.GetExtension(Response_ReportList::ext);
allReports.clear();
for (int i = 0; i < resp.reports_size(); ++i) {
allReports.append(resp.reports(i));
}
applyFilters();
updateStats();
if (selectedReportIdBeforeRefresh >= 0) {
bool found = false;
for (int row = 0; row < table->rowCount(); ++row) {
if (table->item(row, COL_ID) &&
table->item(row, COL_ID)->data(Qt::UserRole).toInt() == selectedReportIdBeforeRefresh) {
found = true;
{
QSignalBlocker blocker(table);
table->setCurrentCell(row, 0);
}
break;
}
}
if (found) {
bool dataChanged = false;
ServerInfo_Report newReport;
if (!selectedReportInfo(newReport) || !previousSelectedReportValid ||
previousSelectedReport.description() != newReport.description() ||
previousSelectedReport.status() != newReport.status() ||
previousSelectedReport.resolution_note() != newReport.resolution_note() ||
previousSelectedReport.assigned_mod_name() != newReport.assigned_mod_name()) {
dataChanged = true;
}
if (dataChanged || detailsRequestedReportId != selectedReportIdBeforeRefresh) {
loadReportDetails(selectedReportIdBeforeRefresh);
}
updateActionStates();
} else {
descriptionEdit->clear();
chatLogEdit->clear();
commentsEdit->clear();
setActionsEnabled(false);
userContextGroup->setVisible(false);
}
}
if (commentInput->text().isEmpty()) {
commentInput->setText(commentDraftBeforeRefresh);
}
if (statsGroup->isChecked()) {
requestStats();
}
}
void TabReport::applyFilters()
{
filteredReports.clear();
const QString searchText = searchEdit->text().toLower();
const QString statusFilterValue = statusFilter->currentData().toString();
for (const ServerInfo_Report &r : allReports) {
bool matchesSearch = searchText.isEmpty() ||
QString::fromStdString(r.reporter_name()).toLower().contains(searchText) ||
QString::fromStdString(r.reported_user_name()).toLower().contains(searchText) ||
QString::fromStdString(r.category()).toLower().contains(searchText) ||
QString::fromStdString(r.description()).toLower().contains(searchText);
bool matchesStatus = statusFilterValue.isEmpty() || QString::fromStdString(r.status()) == statusFilterValue;
if (matchesSearch && matchesStatus) {
filteredReports.append(r);
}
}
table->setSortingEnabled(false);
table->setRowCount(filteredReports.size());
for (int row = 0; row < filteredReports.size(); ++row) {
const ServerInfo_Report &r = filteredReports[row];
report_utils::fillReportTableRow(table, row, r, COL_ID, COL_TIME, COL_REPORTED, COL_CATEGORY, COL_GAMEID,
COL_STATUS, COL_ASSIGNED);
table->setItem(row, COL_REPORTER, new QTableWidgetItem(QString::fromStdString(r.reporter_name())));
table->setItem(row, COL_REPLAY,
new QTableWidgetItem(r.has_replay_id() && r.replay_id() > 0 ? tr("Yes") : tr("No")));
table->setItem(row, COL_ROOM,
new QTableWidgetItem(r.has_room_id() && r.room_id() > 0 ? QString::number(r.room_id()) : ""));
}
table->setSortingEnabled(true);
table->resizeColumnsToContents();
table->horizontalHeader()->setSectionResizeMode(COL_REPORTED, QHeaderView::Stretch);
statusLabel->setText(tr("%1 report(s)").arg(filteredReports.size()));
}
void TabReport::updateStats()
{
int open = 0, assigned = 0, resolved = 0, dismissed = 0;
for (const ServerInfo_Report &r : allReports) {
QString status = QString::fromStdString(r.status());
if (status == "open") {
open++;
} else if (status == "assigned") {
assigned++;
} else if (status == "resolved") {
resolved++;
} else if (status == "dismissed") {
dismissed++;
}
}
statsLabel->setText(tr("%1 open | %2 assigned | %3 resolved | %4 dismissed")
.arg(open)
.arg(assigned)
.arg(resolved)
.arg(dismissed));
}
void TabReport::onSelectionChanged()
{
const int row = table->currentRow();
if (row < 0 || !table->item(row, COL_ID)) {
descriptionEdit->clear();
chatLogEdit->clear();
commentsEdit->clear();
commentInput->clear();
commentButton->setEnabled(false);
setActionsEnabled(false);
userContextGroup->setVisible(false);
return;
}
const int reportId = table->item(row, COL_ID)->data(Qt::UserRole).toInt();
loadReportDetails(reportId);
updateActionStates();
}
void TabReport::loadReportDetails(int reportId)
{
detailsRequestedReportId = reportId;
for (const ServerInfo_Report &r : filteredReports) {
if (r.report_id() == reportId) {
descriptionEdit->setPlainText(QString::fromStdString(r.description()));
QString reportedUser = QString::fromStdString(r.reported_user_name());
if (!reportedUser.isEmpty()) {
requestUserInfo(reportedUser);
} else {
userContextGroup->setVisible(false);
}
QString status = QString::fromStdString(r.status());
bool canComment = (status == "open" || status == "assigned");
commentButton->setEnabled(canComment);
commentInput->setEnabled(canComment);
if (!canComment) {
commentInput->setPlaceholderText(tr("This report is closed."));
} else {
commentInput->setPlaceholderText(tr("Type a reply..."));
}
break;
}
}
chatLogEdit->setPlainText(tr("Loading..."));
commentsEdit->setPlainText(tr("Loading..."));
Command_ReportDetails cmd;
cmd.set_report_id(reportId);
const int seq = ++detailsRequestSeq;
PendingCommand *pend = client->prepareSessionCommand(cmd);
connect(pend, &PendingCommand::finished, this, [this, seq](const Response &response) {
if (seq != detailsRequestSeq) {
return;
}
reportDetailsResponse(response);
});
client->sendCommand(pend);
}
void TabReport::reportDetailsResponse(const Response &response)
{
if (response.response_code() != Response::RespOk) {
if (selectedReportId() == detailsRequestedReportId) {
chatLogEdit->clear();
commentsEdit->setPlainText(tr("Failed to load report details."));
}
return;
}
const Response_ReportDetails &resp = response.GetExtension(Response_ReportDetails::ext);
const ServerInfo_Report &r = resp.report();
if (selectedReportId() != r.report_id()) {
return;
}
report_utils::renderReportDetails(chatLogEdit, commentsEdit, r, tr("No comments yet."), tr("[Moderator]"),
tr("[Reporter]"));
}
void TabReport::updateActionStates()
{
const int row = table->currentRow();
if (row < 0 || !table->item(row, COL_STATUS)) {
setActionsEnabled(false);
return;
}
ServerInfo_Report report;
if (!selectedReportInfo(report)) {
setActionsEnabled(false);
return;
}
const QString status = QString::fromStdString(report.status());
assignButton->setEnabled(status == "open");
resolveButton->setEnabled(status == "open" || status == "assigned");
resolveWithNoteButton->setEnabled(status == "open" || status == "assigned");
dismissButton->setEnabled(status == "open" || status == "assigned");
const bool hasGameId = report.game_id() > 0;
const bool hasReplay = hasGameId && report.has_replay_id() && report.replay_id() > 0;
viewReplayButton->setEnabled(hasReplay);
joinGameButton->setEnabled(hasGameId && report.has_room_id() && report.room_id() > 0);
}
void TabReport::setActionsEnabled(bool enabled)
{
assignButton->setEnabled(enabled);
resolveButton->setEnabled(enabled);
resolveWithNoteButton->setEnabled(enabled);
dismissButton->setEnabled(enabled);
viewReplayButton->setEnabled(false);
joinGameButton->setEnabled(false);
commentButton->setEnabled(enabled);
commentInput->setEnabled(enabled);
}
int TabReport::selectedReportId() const
{
const int row = table->currentRow();
if (row < 0 || !table->item(row, COL_ID)) {
return -1;
}
return table->item(row, COL_ID)->data(Qt::UserRole).toInt();
}
bool TabReport::selectedReportInfo(ServerInfo_Report &info) const
{
const int reportId = selectedReportId();
if (reportId < 0) {
return false;
}
for (const ServerInfo_Report &r : filteredReports) {
if (r.report_id() == reportId) {
info = r;
return true;
}
}
return false;
}
void TabReport::assignReport()
{
const int reportId = selectedReportId();
if (reportId < 0) {
return;
}
setActionsEnabled(false);
statusLabel->setText(tr("Assigning..."));
Command_ReportAssign cmd;
cmd.set_report_id(reportId);
PendingCommand *pend = client->prepareModeratorCommand(cmd);
connect(pend, &PendingCommand::finished, this, &TabReport::assignResponse);
client->sendCommand(pend);
}
void TabReport::assignResponse(const Response &response)
{
if (response.response_code() == Response::RespOk) {
statusLabel->setText(tr("Assigned."));
refreshList();
} else {
statusLabel->setText(tr("Assignment failed."));
updateActionStates();
}
}
void TabReport::resolveReport(bool dismissed, bool promptNote)
{
const int reportId = selectedReportId();
if (reportId < 0) {
return;
}
QString note;
if (promptNote) {
bool ok;
note = QInputDialog::getText(this, dismissed ? tr("Dismiss Report") : tr("Resolve Report"),
dismissed ? tr("Optional note:") : tr("Resolution note (optional):"),
QLineEdit::Normal, QString(), &ok);
if (!ok) {
return;
}
}
setActionsEnabled(false);
statusLabel->setText(dismissed ? tr("Dismissing...") : tr("Resolving..."));
Command_ReportResolve cmd;
cmd.set_report_id(reportId);
cmd.set_dismissed(dismissed);
if (!note.isEmpty()) {
cmd.set_resolution_note(note.toStdString());
}
PendingCommand *pend = client->prepareModeratorCommand(cmd);
connect(pend, &PendingCommand::finished, this, &TabReport::resolveResponse);
client->sendCommand(pend);
}
void TabReport::resolveResponse(const Response &response)
{
if (response.response_code() == Response::RespOk) {
statusLabel->setText(tr("Done."));
refreshList();
} else {
statusLabel->setText(tr("Action failed."));
updateActionStates();
}
}
void TabReport::viewReplay()
{
ServerInfo_Report report;
if (!selectedReportInfo(report) || report.game_id() <= 0) {
return;
}
setActionsEnabled(false);
statusLabel->setText(tr("Loading replay..."));
Command_ReplayDownloadByGameId cmd;
cmd.set_game_id(report.game_id());
PendingCommand *pend = client->prepareModeratorCommand(cmd);
connect(pend, &PendingCommand::finished, this, &TabReport::viewReplayResponse);
client->sendCommand(pend);
}
void TabReport::viewReplayResponse(const Response &response)
{
if (response.response_code() != Response::RespOk) {
statusLabel->setText(tr("No replay available for this game."));
updateActionStates();
return;
}
const Response_ReplayDownloadByGameId &resp = response.GetExtension(Response_ReplayDownloadByGameId::ext);
GameReplay *replay = new GameReplay;
if (replay->ParseFromString(resp.replay_data())) {
emit openReplay(replay);
statusLabel->setText(tr("Replay opened."));
} else {
delete replay;
statusLabel->setText(tr("Failed to parse replay."));
}
updateActionStates();
}
void TabReport::joinGame()
{
ServerInfo_Report report;
if (!selectedReportInfo(report) || report.game_id() <= 0) {
return;
}
const int roomId = report.has_room_id() ? report.room_id() : -1;
if (roomId <= 0) {
statusLabel->setText(tr("No room recorded for this report, use the replay instead."));
return;
}
emit requestJoinGame(report.game_id(), roomId);
statusLabel->setText(tr("Attempting to join game..."));
}
void TabReport::addComment()
{
const int reportId = selectedReportId();
if (reportId < 0) {
return;
}
QString text = commentInput->text().trimmed();
if (text.isEmpty()) {
return;
}
commentButton->setEnabled(false);
Command_ReportAddComment cmd;
cmd.set_report_id(reportId);
cmd.set_comment(text.toStdString());
PendingCommand *pend = client->prepareSessionCommand(cmd);
connect(pend, &PendingCommand::finished, this, &TabReport::addCommentResponse);
client->sendCommand(pend);
}
void TabReport::addCommentResponse(const Response &response)
{
if (response.response_code() == Response::RespOk) {
commentInput->clear();
refreshList();
} else {
commentButton->setEnabled(true);
statusLabel->setText(tr("Failed to send comment."));
}
}
void TabReport::requestUserInfo(const QString &userName)
{
if (userName.isEmpty()) {
userContextGroup->setVisible(false);
return;
}
lastRequestedUser = userName;
userContextGroup->setVisible(true);
userContextName->setText(userName);
userContextAccountAge->setText(tr("Loading..."));
userContextReports->clear();
userContextBans->clear();
userContextWarns->clear();
userContextNotes->clear();
userContextRecentReports->clear();
Command_ReportUserInfo cmd;
cmd.set_user_name(userName.toStdString());
PendingCommand *pend = client->prepareModeratorCommand(cmd);
connect(pend, &PendingCommand::finished, this, &TabReport::userInfoResponse);
client->sendCommand(pend);
}
void TabReport::userInfoResponse(const Response &response)
{
if (response.response_code() != Response::RespOk) {
userContextAccountAge->setText(tr("Error loading user info."));
return;
}
const Response_ReportUserInfo &resp = response.GetExtension(Response_ReportUserInfo::ext);
if (resp.has_user_name() && QString::fromStdString(resp.user_name()) != lastRequestedUser) {
return;
}
QDateTime regTime = QDateTime::fromSecsSinceEpoch(resp.registration_time());
qint64 daysSinceReg = regTime.daysTo(QDateTime::currentDateTime());
userContextAccountAge->setText(tr("%1 days (since %2)").arg(daysSinceReg).arg(regTime.toString("yyyy-MM-dd")));
userContextReports->setText(QString::number(resp.total_reports()));
userContextBans->setText(QString::number(resp.total_bans()));
userContextWarns->setText(QString::number(resp.total_warns()));
if (resp.has_admin_notes() && !resp.admin_notes().empty()) {
userContextNotes->setPlainText(QString::fromStdString(resp.admin_notes()));
} else {
userContextNotes->setPlainText(tr("(none)"));
}
userContextRecentReports->clear();
if (resp.recent_reports_size() == 0) {
userContextRecentReports->setPlainText(tr("No previous reports against this user."));
} else {
for (int i = 0; i < resp.recent_reports_size(); ++i) {
const ServerInfo_Report &r = resp.recent_reports(i);
QDateTime dt = QDateTime::fromSecsSinceEpoch(r.report_time());
userContextRecentReports->moveCursor(QTextCursor::End);
userContextRecentReports->insertPlainText(QString("[%1] #%2 by %3 [%4]: %5\n")
.arg(dt.toString("yyyy-MM-dd"))
.arg(r.report_id())
.arg(QString::fromStdString(r.reporter_name()))
.arg(QString::fromStdString(r.status()))
.arg(QString::fromStdString(r.category())));
}
}
}
void TabReport::requestStats()
{
statsTotalLabel->setText(tr("Loading statistics..."));
statsTrendLabel->clear();
statsCategoriesLabel->clear();
statsDetailText->clear();
Command_ReportStats cmd;
PendingCommand *pend = client->prepareModeratorCommand(cmd);
connect(pend, &PendingCommand::finished, this, &TabReport::statsResponse);
client->sendCommand(pend);
}
void TabReport::statsResponse(const Response &response)
{
if (response.response_code() != Response::RespOk) {
statsTotalLabel->setText(tr("Error loading statistics."));
return;
}
const Response_ReportStats &resp = response.GetExtension(Response_ReportStats::ext);
statsTotalLabel->setText(tr("Total: %1 reports (%2 open, %3 assigned, %4 resolved/dismissed)")
.arg(resp.total_reports())
.arg(resp.total_pending())
.arg(resp.total_assigned())
.arg(resp.total_resolved()));
statsTrendLabel->setText(tr("Last 24h: %1 | Last 7d: %2 | Last 30d: %3 | Avg resolution: %4h")
.arg(resp.reports_last_24h())
.arg(resp.reports_last_7d())
.arg(resp.reports_last_30d())
.arg(resp.avg_resolution_hours(), 0, 'f', 1));
QString weekCompare =
tr("This week: %1 vs last week: %2 (%3%)")
.arg(resp.reports_this_week())
.arg(resp.reports_last_week())
.arg(resp.reports_last_week() > 0 ? QString::number(((resp.reports_this_week() - resp.reports_last_week()) *
100.0 / resp.reports_last_week()),
'f', 0)
: resp.reports_this_week() > 0 ? "new"
: "0");
statsTrendLabel->setText(statsTrendLabel->text() + " | " + weekCompare);
statsCategoriesLabel->setText(tr("By category:"));
statsDetailText->clear();
statsDetailText->moveCursor(QTextCursor::End);
statsDetailText->insertPlainText(tr("=== Top Categories ===") + "\n");
for (int i = 0; i < resp.category_counts_size(); ++i) {
const ReportCategoryCount &cc = resp.category_counts(i);
QString cat = QString::fromStdString(cc.category());
cat.replace('_', ' ');
cat = cat.left(1).toUpper() + cat.mid(1);
statsDetailText->moveCursor(QTextCursor::End);
statsDetailText->insertPlainText(QString(" %1: %2\n").arg(cat).arg(cc.count()));
}
statsDetailText->moveCursor(QTextCursor::End);
statsDetailText->insertPlainText("\n" + tr("=== Most Reported Users ===") + "\n");
for (int i = 0; i < resp.top_reported_users_size(); ++i) {
const ReportTopUser &tu = resp.top_reported_users(i);
statsDetailText->moveCursor(QTextCursor::End);
statsDetailText->insertPlainText(
QString(" %1: %2 reports\n").arg(QString::fromStdString(tu.user_name())).arg(tu.count()));
}
statsDetailText->moveCursor(QTextCursor::End);
statsDetailText->insertPlainText("\n" + tr("=== Top Reporters ===") + "\n");
for (int i = 0; i < resp.top_reporters_size(); ++i) {
const ReportTopUser &tu = resp.top_reporters(i);
statsDetailText->moveCursor(QTextCursor::End);
statsDetailText->insertPlainText(
QString(" %1: %2 reports filed\n").arg(QString::fromStdString(tu.user_name())).arg(tu.count()));
}
}

View file

@ -0,0 +1,124 @@
#ifndef TAB_REPORT_H
#define TAB_REPORT_H
#include "tab.h"
#include <QList>
#include <libcockatrice/protocol/pb/response.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_report.pb.h>
class AbstractClient;
class QCheckBox;
class QComboBox;
class QGroupBox;
class QLabel;
class QLineEdit;
class QPushButton;
class QSplitter;
class QTableWidget;
class QTextEdit;
class QTimer;
class GameReplay;
class TabReport : public Tab
{
Q_OBJECT
public:
TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client);
void retranslateUi() override;
[[nodiscard]] QString getTabText() const override
{
return tr("Report Queue");
}
signals:
void openReplay(GameReplay *replay);
void requestJoinGame(int gameId, int roomId);
private slots:
void refreshList();
void reportListResponse(const Response &response);
void assignReport();
void assignResponse(const Response &response);
void resolveReport(bool dismissed, bool promptNote);
void resolveResponse(const Response &response);
void onSelectionChanged();
void viewReplay();
void viewReplayResponse(const Response &response);
void joinGame();
void addComment();
void addCommentResponse(const Response &response);
void requestUserInfo(const QString &userName);
void userInfoResponse(const Response &response);
void reportDetailsResponse(const Response &response);
void requestStats();
void statsResponse(const Response &response);
private:
int selectedReportId() const;
bool selectedReportInfo(ServerInfo_Report &info) const;
void setActionsEnabled(bool enabled);
void updateActionStates();
void applyFilters();
void updateStats();
void loadReportDetails(int reportId);
AbstractClient *client;
QLineEdit *searchEdit;
QComboBox *statusFilter;
QCheckBox *unresolvedOnlyBox;
QPushButton *refreshButton;
QLabel *statsLabel;
QTableWidget *table;
QSplitter *detailSplitter;
QGroupBox *descGroup;
QGroupBox *chatGroup;
QGroupBox *commentsGroup;
QTextEdit *descriptionEdit;
QTextEdit *chatLogEdit;
QTextEdit *commentsEdit;
QLineEdit *commentInput;
QPushButton *commentButton;
QPushButton *assignButton;
QPushButton *resolveButton;
QPushButton *resolveWithNoteButton;
QPushButton *dismissButton;
QPushButton *viewReplayButton;
QPushButton *joinGameButton;
QLabel *statusLabel;
QTimer *refreshTimer;
QGroupBox *userContextGroup;
QLabel *userContextName;
QLabel *userContextAccountAge;
QLabel *userContextReports;
QLabel *userContextBans;
QLabel *userContextWarns;
QTextEdit *userContextNotes;
QTextEdit *userContextRecentReports;
QLabel *userContextUserLabel;
QLabel *userContextAgeLabel;
QLabel *userContextReportsLabel;
QLabel *userContextBansLabel;
QLabel *userContextWarnsLabel;
QLabel *userContextNotesLabel;
QLabel *userContextRecentReportsLabel;
QString lastRequestedUser;
QGroupBox *statsGroup;
QLabel *statsTotalLabel;
QLabel *statsTrendLabel;
QLabel *statsCategoriesLabel;
QTextEdit *statsDetailText;
QList<ServerInfo_Report> allReports;
QList<ServerInfo_Report> filteredReports;
int detailsRequestedReportId = -1;
int detailsRequestSeq = 0;
int selectedReportIdBeforeRefresh = -1;
QString commentDraftBeforeRefresh;
ServerInfo_Report previousSelectedReport;
bool previousSelectedReportValid = false;
};
#endif // TAB_REPORT_H

View file

@ -2,6 +2,7 @@
#include "../../../client/settings/cache_settings.h"
#include "../../../client/settings/shortcuts_settings.h"
#include "../../intents/intent_join_server_game.h"
#include "../interface/pixel_map_generator.h"
#include "../interface/widgets/server/game_link.h"
#include "../interface/widgets/server/user/user_list_manager.h"
@ -18,7 +19,9 @@
#include "tab_home.h"
#include "tab_logs.h"
#include "tab_message.h"
#include "tab_moderation.h"
#include "tab_replays.h"
#include "tab_report.h"
#include "tab_room.h"
#include "tab_server.h"
#include "tab_visual_database_display.h"
@ -31,6 +34,7 @@
#include <QPainter>
#include <QSystemTrayIcon>
#include <libcockatrice/network/client/abstract/abstract_client.h>
#include <libcockatrice/network/client/remote/remote_client.h>
#include <libcockatrice/protocol/pb/event_game_joined.pb.h>
#include <libcockatrice/protocol/pb/event_notify_user.pb.h>
#include <libcockatrice/protocol/pb/event_user_message.pb.h>
@ -39,6 +43,7 @@
#include <libcockatrice/protocol/pb/room_event.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_room.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_user.pb.h>
#include <libcockatrice/protocol/pending_command.h>
#include <libcockatrice/settings/chat_settings.h>
#include <libcockatrice/settings/deck_editor_settings.h>
#include <libcockatrice/settings/interface_settings.h>
@ -113,7 +118,7 @@ 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), isLocalGame(false)
tabLog(nullptr), tabReport(nullptr), tabModeration(nullptr), isLocalGame(false)
{
setElideMode(Qt::ElideRight);
setMovable(true);
@ -190,6 +195,14 @@ TabSupervisor::TabSupervisor(AbstractClient *_client, QMenu *tabsMenu, QWidget *
aTabLog->setCheckable(true);
connect(aTabLog, &QAction::triggered, this, &TabSupervisor::actTabLog);
aTabReport = new QAction(this);
aTabReport->setCheckable(true);
connect(aTabReport, &QAction::triggered, this, &TabSupervisor::actTabReport);
aTabModeration = new QAction(this);
aTabModeration->setCheckable(true);
connect(aTabModeration, &QAction::triggered, this, &TabSupervisor::actTabModeration);
connect(&SettingsCache::instance().shortcuts(), &ShortcutsSettings::shortCutChanged, this,
&TabSupervisor::refreshShortcuts);
refreshShortcuts();
@ -229,6 +242,8 @@ void TabSupervisor::retranslateUi()
aTabReplays->setText(tr("Game Replays"));
aTabAdmin->setText(tr("Administration"));
aTabLog->setText(tr("Logs"));
aTabReport->setText(tr("Report Queue"));
aTabModeration->setText(tr("Moderation"));
// tabs
QList<Tab *> tabs;
@ -238,6 +253,8 @@ void TabSupervisor::retranslateUi()
tabs.append(tabAdmin);
tabs.append(tabAccount);
tabs.append(tabLog);
tabs.append(tabReport);
tabs.append(tabModeration);
QMapIterator<int, TabRoom *> roomIterator(roomTabs);
while (roomIterator.hasNext()) {
tabs.append(roomIterator.next().value());
@ -284,6 +301,8 @@ void TabSupervisor::refreshShortcuts()
aTabReplays->setShortcuts(shortcuts.getShortcut("Tabs/aTabReplays"));
aTabAdmin->setShortcuts(shortcuts.getShortcut("Tabs/aTabAdmin"));
aTabLog->setShortcuts(shortcuts.getShortcut("Tabs/aTabLog"));
aTabReport->setShortcuts(shortcuts.getShortcut("Tabs/aTabReport"));
aTabModeration->setShortcuts(shortcuts.getShortcut("Tabs/aTabModeration"));
}
void TabSupervisor::closeEvent(QCloseEvent *event)
@ -485,6 +504,8 @@ void TabSupervisor::start(const ServerInfo_User &_userInfo)
tabsMenu->addAction(aTabAdmin);
tabsMenu->addAction(aTabLog);
tabsMenu->addAction(aTabCardArtRules);
tabsMenu->addAction(aTabReport);
tabsMenu->addAction(aTabModeration);
if (SettingsCache::instance().tabs().getTabAdminOpen()) {
openTabAdmin();
@ -492,6 +513,12 @@ void TabSupervisor::start(const ServerInfo_User &_userInfo)
if (SettingsCache::instance().tabs().getTabLogOpen()) {
openTabLog();
}
if (SettingsCache::instance().tabs().getTabReportOpen()) {
openTabReport();
}
if (SettingsCache::instance().tabs().getTabModerationOpen()) {
openTabModeration();
}
openTabCardArtRules();
}
@ -505,6 +532,8 @@ void TabSupervisor::startLocal(const QList<AbstractClient *> &_clients)
tabAccount = nullptr;
tabAdmin = nullptr;
tabLog = nullptr;
tabReport = nullptr;
tabModeration = nullptr;
isLocalGame = true;
userInfo = new ServerInfo_User;
localClients = _clients;
@ -546,6 +575,12 @@ void TabSupervisor::stop()
if (tabLog) {
tabLog->close();
}
if (tabReport) {
tabReport->close();
}
if (tabModeration) {
tabModeration->close();
}
}
QList<Tab *> tabsToDelete;
@ -783,6 +818,59 @@ void TabSupervisor::openTabLog()
aTabLog->setChecked(true);
}
void TabSupervisor::actTabReport(bool checked)
{
SettingsCache::instance().tabs().setTabReportOpen(checked);
if (checked && !tabReport) {
openTabReport();
setCurrentWidget(tabReport);
} else if (!checked && tabReport) {
tabReport->closeRequest();
}
}
void TabSupervisor::openTabReport()
{
tabReport = new TabReport(this, client);
myAddTab(tabReport, aTabReport);
connect(tabReport, &TabReport::openReplay, this, &TabSupervisor::openReplay);
connect(tabReport, &TabReport::requestJoinGame, this, &TabSupervisor::joinReportGame);
connect(tabReport, &QObject::destroyed, this, [this] {
tabReport = nullptr;
aTabReport->setChecked(false);
});
aTabReport->setChecked(true);
}
void TabSupervisor::actTabModeration(bool checked)
{
SettingsCache::instance().tabs().setTabModerationOpen(checked);
if (checked && !tabModeration) {
openTabModeration();
setCurrentWidget(tabModeration);
} else if (!checked && tabModeration) {
tabModeration->closeRequest();
}
}
void TabSupervisor::openTabModeration(const QString &userName)
{
if (tabModeration) {
setCurrentWidget(tabModeration);
if (!userName.isEmpty()) {
tabModeration->investigate(userName);
}
return;
}
tabModeration = new TabModeration(this, client, userName);
myAddTab(tabModeration, aTabModeration);
connect(tabModeration, &QObject::destroyed, this, [this] {
tabModeration = nullptr;
aTabModeration->setChecked(false);
});
aTabModeration->setChecked(true);
}
void TabSupervisor::updatePingTime(int value, int max)
{
if (!tabServer) {
@ -900,6 +988,30 @@ void TabSupervisor::replayLeft(TabGame *tab)
replayTabs.removeOne(tab);
}
void TabSupervisor::joinReportGame(const int gameId, const int roomId)
{
auto *remoteClient = qobject_cast<RemoteClient *>(client);
if (!remoteClient) {
actShowPopup(tr("Report joins are only available on a remote server."));
return;
}
auto ctx = std::make_unique<ContextJoinGame>();
ctx->roomContext.serverContext.hostname = remoteClient->peerName();
ctx->roomContext.serverContext.port = QString::number(remoteClient->peerPort());
ctx->roomContext.roomId = roomId;
ctx->gameId = gameId;
ctx->asSpectator = true;
auto *joinGameIntent = new IntentJoinServerGame(this, remoteClient, std::move(ctx));
joinGameIntent->setParent(this);
connect(joinGameIntent, &Intent::failed, this, [gameId](const QString &reason) {
actShowPopup(tr("Could not join game %1.\n%2").arg(gameId).arg(reason));
});
joinGameIntent->execute();
}
TabMessage *TabSupervisor::addMessageTab(const QString &receiverName, bool focus)
{
if (receiverName == QString::fromStdString(userInfo->name())) {
@ -1284,6 +1396,24 @@ void TabSupervisor::processNotifyUserEvent(const Event_NotifyUser &event)
}
break;
}
case Event_NotifyUser::REPORT_RESOLVED: {
QString title = QString::fromStdString(event.custom_title()).simplified();
QString content = QString::fromStdString(event.custom_content()).trimmed();
if (!title.isEmpty() && !content.isEmpty()) {
actShowPopup(title + "\n" + content);
QApplication::alert(this);
}
break;
}
case Event_NotifyUser::REPORT_COMMENT: {
QString title = QString::fromStdString(event.custom_title()).simplified();
QString content = QString::fromStdString(event.custom_content()).trimmed();
if (!title.isEmpty() && !content.isEmpty()) {
actShowPopup(title + "\n" + content);
QApplication::alert(this);
}
break;
}
default:;
}
}

View file

@ -40,6 +40,8 @@ class TabDeckStorage;
class TabReplays;
class TabAdmin;
class TabMessage;
class TabReport;
class TabModeration;
class TabAccount;
class TabDeckEditor;
class TabLog;
@ -103,6 +105,8 @@ private:
TabAdmin *tabAdmin;
TabCardArtRules *tabCardArtRules;
TabLog *tabLog;
TabReport *tabReport;
TabModeration *tabModeration;
QMap<int, TabRoom *> roomTabs;
QMap<int, TabGame *> gameTabs;
QList<TabGame *> replayTabs;
@ -112,7 +116,7 @@ private:
QAction *aTabHome, *aTabDeckEditor, *aTabVisualDeckEditor, *aTabEdhRec, *aTabArchidekt, *aTabVisualDeckStorage,
*aTabVisualDatabaseDisplay, *aTabServer, *aTabAccount, *aTabDeckStorage, *aTabReplays, *aTabAdmin,
*aTabCardArtRules, *aTabLog;
*aTabCardArtRules, *aTabLog, *aTabReport, *aTabModeration;
int myAddTab(Tab *tab, QAction *manager = nullptr);
void addCloseButtonToTab(Tab *tab, int tabIndex, QAction *manager);
@ -183,6 +187,8 @@ public slots:
TabArchidekt *addArchidektTab();
TabEdhRec *addEdhrecTab(const CardInfoPtr &cardToQuery, bool isCommander = false);
void openReplay(GameReplay *replay);
void joinReportGame(int gameId, int roomId);
void openTabModeration(const QString &userName = {});
void switchToFirstAvailableNetworkTab();
void maximizeMainWindow();
void actTabVisualDeckStorage(bool checked);
@ -198,6 +204,8 @@ private slots:
void actTabDeckStorage(bool checked);
void actTabAdmin(bool checked);
void actTabLog(bool checked);
void actTabReport(bool checked);
void actTabModeration(bool checked);
void openTabVisualDeckStorage();
void openTabHome();
@ -208,6 +216,7 @@ private slots:
void actTabCardArtRules(bool checked);
void openTabCardArtRules();
void openTabLog();
void openTabReport();
void updateCurrent(int index);
void updatePingTime(int value, int max);

View file

@ -0,0 +1,119 @@
#include "report_utils.h"
#include <QApplication>
#include <QBrush>
#include <QDateTime>
#include <QPalette>
#include <QTableWidget>
#include <QTextCursor>
#include <QTextEdit>
namespace report_utils
{
namespace
{
QColor reportStatusColor(const QString &status)
{
const QColor text = qApp->palette().color(QPalette::Text);
const int luminance = (299 * text.red() + 587 * text.green() + 114 * text.blue()) / 1000;
const bool dark = luminance < 128;
if (status == "open") {
return dark ? QColor("#e06060") : QColor("#c0392b");
}
if (status == "assigned") {
return dark ? QColor("#e0a030") : QColor("#d68910");
}
if (status == "resolved") {
return dark ? QColor("#50c878") : QColor("#1e8449");
}
if (status == "dismissed") {
return qApp->palette().color(QPalette::PlaceholderText);
}
return QColor();
}
void setReportStatusItemColor(QTableWidgetItem *item, const QString &status)
{
const QColor color = reportStatusColor(status);
if (color.isValid()) {
item->setForeground(QBrush(color));
}
}
} // namespace
QString formatReportCategory(const QString &category)
{
QString result = category;
result.replace('_', ' ');
return result.left(1).toUpper() + result.mid(1);
}
QString formatReportTime(qint64 secondsSinceEpoch)
{
const QDateTime dt = QDateTime::fromSecsSinceEpoch(secondsSinceEpoch);
return dt.toString("yyyy-MM-dd hh:mm");
}
void fillReportTableRow(QTableWidget *table,
int row,
const ServerInfo_Report &report,
int idColumn,
int timeColumn,
int reportedColumn,
int categoryColumn,
int gameIdColumn,
int statusColumn,
int assignedColumn)
{
auto *idItem = new QTableWidgetItem(QString::number(report.report_id()));
idItem->setData(Qt::UserRole, report.report_id());
table->setItem(row, idColumn, idItem);
table->setItem(row, timeColumn, new QTableWidgetItem(formatReportTime(report.report_time())));
table->setItem(row, reportedColumn, new QTableWidgetItem(QString::fromStdString(report.reported_user_name())));
table->setItem(row, categoryColumn,
new QTableWidgetItem(formatReportCategory(QString::fromStdString(report.category()))));
table->setItem(row, gameIdColumn,
new QTableWidgetItem(report.game_id() > 0 ? QString::number(report.game_id()) : QString()));
auto *statusItem = new QTableWidgetItem(QString::fromStdString(report.status()));
setReportStatusItemColor(statusItem, QString::fromStdString(report.status()));
table->setItem(row, statusColumn, statusItem);
table->setItem(row, assignedColumn, new QTableWidgetItem(QString::fromStdString(report.assigned_mod_name())));
}
void renderReportDetails(QTextEdit *chatLogEdit,
QTextEdit *commentsEdit,
const ServerInfo_Report &report,
const QString &commentsEmptyText,
const QString &moderatorPrefix,
const QString &nonModeratorPrefix)
{
if (report.has_chat_log() && !report.chat_log().empty()) {
chatLogEdit->setPlainText(QString::fromStdString(report.chat_log()));
} else {
chatLogEdit->clear();
}
commentsEdit->clear();
if (report.comments_size() == 0) {
commentsEdit->setPlainText(commentsEmptyText);
return;
}
for (int i = 0; i < report.comments_size(); ++i) {
const ServerInfo_ReportComment &c = report.comments(i);
const QString author = QString::fromStdString(c.author_name());
const QString text = QString::fromStdString(c.comment_text());
const QString prefix = c.is_moderator() ? moderatorPrefix : nonModeratorPrefix;
commentsEdit->moveCursor(QTextCursor::End);
commentsEdit->insertPlainText(
QString("[%1] %2 %3:\n%4\n\n").arg(formatReportTime(c.comment_time()), prefix, author, text));
}
}
} // namespace report_utils

View file

@ -0,0 +1,36 @@
#ifndef REPORT_UTILS_H
#define REPORT_UTILS_H
#include <QString>
#include <libcockatrice/protocol/pb/serverinfo_report.pb.h>
class QTableWidget;
class QTextEdit;
namespace report_utils
{
QString formatReportCategory(const QString &category);
QString formatReportTime(qint64 secondsSinceEpoch);
void fillReportTableRow(QTableWidget *table,
int row,
const ServerInfo_Report &report,
int idColumn,
int timeColumn,
int reportedColumn,
int categoryColumn,
int gameIdColumn,
int statusColumn,
int assignedColumn);
void renderReportDetails(QTextEdit *chatLogEdit,
QTextEdit *commentsEdit,
const ServerInfo_Report &report,
const QString &commentsEmptyText,
const QString &moderatorPrefix,
const QString &nonModeratorPrefix);
} // namespace report_utils
#endif // REPORT_UTILS_H

View file

@ -11,6 +11,7 @@ set(HEADERS
libcockatrice/deck_list/deck_list_history_manager.h
libcockatrice/deck_list/deck_list_node_tree.h
libcockatrice/deck_list/deck_list_memento.h
libcockatrice/deck_list/playmat_resolver.h
libcockatrice/deck_list/sideboard_plan.h
)
@ -26,6 +27,7 @@ add_library(
libcockatrice/deck_list/deck_list.cpp
libcockatrice/deck_list/deck_list_history_manager.cpp
libcockatrice/deck_list/deck_list_node_tree.cpp
libcockatrice/deck_list/playmat_resolver.cpp
libcockatrice/deck_list/sideboard_plan.cpp
)
@ -33,4 +35,7 @@ add_dependencies(libcockatrice_deck_list libcockatrice_protocol)
target_include_directories(libcockatrice_deck_list PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
target_link_libraries(libcockatrice_deck_list PUBLIC libcockatrice_protocol libcockatrice_utility ${QT_CORE_MODULE})
target_link_libraries(
libcockatrice_deck_list PUBLIC libcockatrice_interfaces libcockatrice_protocol libcockatrice_utility
${QT_CORE_MODULE}
)

View file

@ -25,7 +25,7 @@ static const QString CURRENT_SIDEBOARD_PLAN_KEY = "";
bool DeckList::Metadata::isEmpty() const
{
return name.isEmpty() && comments.isEmpty() && bannerCard.isEmpty() && tags.isEmpty();
return name.isEmpty() && comments.isEmpty() && bannerCard.isEmpty() && tags.isEmpty() && playmat.card.isEmpty();
}
DeckList::DeckList()
@ -74,6 +74,36 @@ bool DeckList::readElement(QXmlStreamReader *xml)
QString providerId = xml->attributes().value("providerId").toString();
QString cardName = xml->readElementText();
metadata.bannerCard = {cardName, providerId};
} else if (childName == "playmatCard") {
QString providerId = xml->attributes().value("providerId").toString();
bool ok;
QString marginLStr = xml->attributes().value("marginPctL").toString();
QString marginRStr = xml->attributes().value("marginPctR").toString();
QString vOffStr = xml->attributes().value("verticalOffset").toString();
QString zoomStr = xml->attributes().value("zoom").toString();
QString cardName = xml->readElementText();
PlaymatInfo playmat;
playmat.card = {cardName, providerId};
// Clamp to the same ranges as the settings dialog and the remote
// player-properties path so malformed deck files cannot produce
// degenerate art rectangles (e.g. a zoom of 0 dividing by zero).
playmat.params.marginPctL = qBound(0.0, marginLStr.toDouble(&ok), 0.95);
if (!ok) {
playmat.params.marginPctL = 0.07;
}
playmat.params.marginPctR = qBound(0.0, marginRStr.toDouble(&ok), 0.95);
if (!ok) {
playmat.params.marginPctR = 0.07;
}
playmat.params.verticalOffset = qBound(0.0, vOffStr.toDouble(&ok), 1.0);
if (!ok) {
playmat.params.verticalOffset = 0.33;
}
playmat.params.zoom = qBound(0.1, zoomStr.toDouble(&ok), 4.0);
if (!ok) {
playmat.params.zoom = 1.0;
}
metadata.playmat = playmat;
} else if (childName == "tags") {
metadata.tags.clear(); // Clear existing tags
while (xml->readNextStartElement()) {
@ -104,6 +134,16 @@ static void writeMetadata(QXmlStreamWriter *xml, const DeckList::Metadata &metad
xml->writeAttribute("providerId", metadata.bannerCard.providerId);
xml->writeCharacters(metadata.bannerCard.name);
xml->writeEndElement();
if (!metadata.playmat.card.isEmpty()) {
xml->writeStartElement("playmatCard");
xml->writeAttribute("providerId", metadata.playmat.card.providerId);
xml->writeAttribute("marginPctL", QString::number(metadata.playmat.params.marginPctL, 'f', 4));
xml->writeAttribute("marginPctR", QString::number(metadata.playmat.params.marginPctR, 'f', 4));
xml->writeAttribute("verticalOffset", QString::number(metadata.playmat.params.verticalOffset, 'f', 4));
xml->writeAttribute("zoom", QString::number(metadata.playmat.params.zoom, 'f', 4));
xml->writeCharacters(metadata.playmat.card.name);
xml->writeEndElement();
}
xml->writeTextElement("comments", metadata.comments);
// Write tags

View file

@ -18,7 +18,7 @@
#include <QMap>
#include <QVector>
#include <QtCore/QXmlStreamReader>
#include <libcockatrice/utility/card_ref.h>
#include <libcockatrice/utility/playmat_params.h>
class AbstractDecklistNode;
class DecklistCardNode;
@ -70,6 +70,7 @@ public:
CardRef bannerCard; ///< Optional representative card for the deck.
QStringList tags; ///< User-defined tags for deck classification.
QString lastLoadedTimestamp; ///< Timestamp string of last load.
PlaymatInfo playmat; ///< Optional playmat background for table+stack zones.
/**
* @brief Checks if all values (except for lastLoadedTimestamp) in the metadata is empty.
@ -115,6 +116,10 @@ public:
{
metadata.bannerCard = _bannerCard;
}
void setPlaymat(const PlaymatInfo &_playmat = {})
{
metadata.playmat = _playmat;
}
void setLastLoadedTimestamp(const QString &_lastLoadedTimestamp = QString())
{
metadata.lastLoadedTimestamp = _lastLoadedTimestamp;
@ -170,6 +175,10 @@ public:
{
return metadata.bannerCard;
}
PlaymatInfo getPlaymat() const
{
return metadata.playmat;
}
QString getLastLoadedTimestamp() const
{
return metadata.lastLoadedTimestamp;

View file

@ -0,0 +1,54 @@
#include "playmat_resolver.h"
#include <QRandomGenerator>
PlaymatInfo resolveEffectivePlaymat(const DeckList &deck,
const PlaymatInfo &force,
const QList<PlaymatInfo> &fallbackList,
PlaymatFallbackMode fallbackMode,
int rotationIndex)
{
if (!force.card.isEmpty()) {
return force;
}
const PlaymatInfo &deckPlaymat = deck.getPlaymat();
if (!deckPlaymat.card.isEmpty()) {
return deckPlaymat;
}
if (fallbackList.isEmpty()) {
return {};
}
switch (fallbackMode) {
case PlaymatFallbackModeFixed:
return fallbackList.first();
case PlaymatFallbackModeRoundRobin:
return fallbackList.at(rotationIndex % fallbackList.size());
case PlaymatFallbackModeRandom:
return fallbackList.at(QRandomGenerator::global()->bounded(fallbackList.size()));
}
return {};
}
PlaymatInfo resolvePlaymatForDeck(const DeckList &deck,
const QList<PlaymatInfo> &fallbackList,
PlaymatMode mode,
PlaymatFallbackMode fallbackBehavior,
int rotationIndex)
{
switch (mode) {
case PlaymatModeOverrideDeck: {
const DeckList emptyDeck;
return resolveEffectivePlaymat(emptyDeck, {}, fallbackList, fallbackBehavior, rotationIndex);
}
case PlaymatModeFallback:
return resolveEffectivePlaymat(deck, {}, fallbackList, fallbackBehavior, rotationIndex);
case PlaymatModeDeckOnly:
return deck.getPlaymat();
}
return {};
}

View file

@ -0,0 +1,47 @@
#ifndef COCKATRICE_PLAYMAT_RESOLVER_H
#define COCKATRICE_PLAYMAT_RESOLVER_H
#include "deck_list.h"
#include <libcockatrice/interfaces/interface_interface_settings_provider.h>
/**
* @brief Resolves the effective playmat for a deck per the resolution chain:
* force override > deck-configured playmat > fallback list > none.
*
* @param deck The deck to resolve a playmat for.
* @param force An optional user-level override; wins over everything. Pass an
* empty @ref PlaymatInfo::card to skip it.
* @param fallbackList User-level fallback playmats, consulted only when the
* deck has no configured playmat.
* @param fallbackMode How @p fallbackList is consulted (ignored when empty).
* @param rotationIndex In/out cursor for @c PlaymatFallbackModeRoundRobin;
* advanced once per call. Unused for the other modes.
* @return The effective playmat; an empty @ref PlaymatInfo::card when
* nothing in the chain resolves.
*/
PlaymatInfo resolveEffectivePlaymat(const DeckList &deck,
const PlaymatInfo &force,
const QList<PlaymatInfo> &fallbackList,
PlaymatFallbackMode fallbackMode,
int rotationIndex);
/**
* @brief Resolves the playmat to display for a deck according to the user's
* collection mode (@ref PlaymatMode), combining the deck with the
* given fallback list.
*
* @param deck The deck to resolve a playmat for.
* @param fallbackList User-level fallback playmats.
* @param mode How the collection interacts with the deck-configured playmat.
* @param fallbackBehavior How @p fallbackList is picked from.
* @param rotationIndex Cursor for @c PlaymatFallbackModeRoundRobin.
* @return The effective playmat; an empty @ref PlaymatInfo::card when nothing resolves.
*/
PlaymatInfo resolvePlaymatForDeck(const DeckList &deck,
const QList<PlaymatInfo> &fallbackList,
PlaymatMode mode,
PlaymatFallbackMode fallbackBehavior,
int rotationIndex);
#endif // COCKATRICE_PLAYMAT_RESOLVER_H

View file

@ -30,4 +30,4 @@ add_library(libcockatrice_interfaces STATIC ${MOC_SOURCES})
target_include_directories(libcockatrice_interfaces PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
target_link_libraries(libcockatrice_interfaces PUBLIC ${QT_CORE_MODULE})
target_link_libraries(libcockatrice_interfaces PUBLIC libcockatrice_utility ${QT_CORE_MODULE})

View file

@ -1,8 +1,40 @@
#ifndef COCKATRICE_INTERFACE_INTERFACE_SETTINGS_PROVIDER_H
#define COCKATRICE_INTERFACE_INTERFACE_SETTINGS_PROVIDER_H
#include <QList>
#include <QString>
#include <QStringList>
#include <libcockatrice/utility/playmat_params.h>
/**
* @brief Whether playmats are rendered in-game, and for whom.
*/
enum PlaymatVisibility
{
PlaymatVisibilityNone = 0, ///< Don't use playmats.
PlaymatVisibilityOwnOnly = 1, ///< Show the local player's playmat only.
PlaymatVisibilityAll = 2 ///< Show playmats for all players.
};
/**
* @brief How the user-level playmat collection interacts with the deck-configured playmat.
*/
enum PlaymatMode
{
PlaymatModeOverrideDeck = 0, ///< Always use the collection, ignoring any deck-configured playmat.
PlaymatModeFallback = 1, ///< Prefer the deck-configured playmat; fall back to the collection when absent.
PlaymatModeDeckOnly = 2 ///< Use only the deck-configured playmat, ignoring the collection.
};
/**
* @brief How the user-level fallback playmat list is consulted when a deck has no playmat configured.
*/
enum PlaymatFallbackMode
{
PlaymatFallbackModeFixed = 0, ///< Always use the first entry of the fallback list.
PlaymatFallbackModeRoundRobin = 1, ///< Cycle through the list, advancing one entry per resolution.
PlaymatFallbackModeRandom = 2 ///< Pick a random entry per resolution.
};
class IInterfaceSettingsProvider
{
@ -43,6 +75,21 @@ public:
[[nodiscard]] virtual bool getLifeCounterAnimationsEnabled() const = 0;
[[nodiscard]] virtual bool getBattlefieldFlashEnabled() const = 0;
[[nodiscard]] virtual QStringList getUserListExpandedSections() const = 0;
/** @brief Who gets playmats rendered: @ref PlaymatVisibility. */
[[nodiscard]] virtual int getPlaymatVisibility() const = 0;
/** @brief User-level playmat collection. Used either as a forced playmat
* (mode == @ref PlaymatModeOverrideDeck) or as a fallback when a deck has none
* (mode == @ref PlaymatModeFallback). */
[[nodiscard]] virtual QList<PlaymatInfo> getPlaymatFallbackList() const = 0;
/** @brief How the fallback list is applied: @ref PlaymatMode. */
[[nodiscard]] virtual int getPlaymatMode() const = 0;
/** @brief How the fallback list is picked from when mode is @ref PlaymatModeFallback:
* @ref PlaymatFallbackMode. */
[[nodiscard]] virtual int getPlaymatFallbackBehavior() const = 0;
};
#endif // COCKATRICE_INTERFACE_INTERFACE_SETTINGS_PROVIDER_H

View file

@ -19,6 +19,8 @@ public:
[[nodiscard]] virtual bool getTabReplaysOpen() const = 0;
[[nodiscard]] virtual bool getTabAdminOpen() const = 0;
[[nodiscard]] virtual bool getTabLogOpen() const = 0;
[[nodiscard]] virtual bool getTabReportOpen() const = 0;
[[nodiscard]] virtual bool getTabModerationOpen() const = 0;
};
#endif // COCKATRICE_INTERFACE_TABS_SETTINGS_PROVIDER_H

View file

@ -37,6 +37,7 @@
#include <libcockatrice/protocol/pb/command_set_card_attr.pb.h>
#include <libcockatrice/protocol/pb/command_set_card_counter.pb.h>
#include <libcockatrice/protocol/pb/command_set_counter.pb.h>
#include <libcockatrice/protocol/pb/command_set_playmat.pb.h>
#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>
@ -144,6 +145,13 @@ Response::ResponseCode Server_AbstractParticipant::cmdSetSideboardLock(const Com
return Response::RespFunctionNotAllowed;
}
Response::ResponseCode Server_AbstractParticipant::cmdSetPlaymat(const Command_SetPlaymat & /*cmd*/,
ResponseContainer & /*rc*/,
GameEventStorage & /*ges*/)
{
return Response::RespFunctionNotAllowed;
}
Response::ResponseCode Server_AbstractParticipant::cmdConcede(const Command_Concede & /*cmd*/,
ResponseContainer & /*rc*/,
GameEventStorage & /*ges*/)
@ -525,6 +533,9 @@ Server_AbstractParticipant::processGameCommand(const GameCommand &command, Respo
case GameCommand::REVERSE_TURN:
return cmdReverseTurn(command.GetExtension(Command_ReverseTurn::ext), rc, ges);
break;
case GameCommand::SET_PLAYMAT:
return cmdSetPlaymat(command.GetExtension(Command_SetPlaymat::ext), rc, ges);
break;
default:
return Response::RespInvalidCommand;
}

View file

@ -52,6 +52,7 @@ class Command_SetSideboardPlan;
class Command_DeckSelect;
class Command_SetSideboardLock;
class Command_ChangeZoneProperties;
class Command_SetPlaymat;
class Server_AbstractParticipant : public Server_ArrowTarget, public ServerInfo_User_Container
{
@ -124,6 +125,8 @@ public:
cmdSetSideboardPlan(const Command_SetSideboardPlan &cmd, ResponseContainer &rc, GameEventStorage &ges);
virtual Response::ResponseCode
cmdSetSideboardLock(const Command_SetSideboardLock &cmd, ResponseContainer &rc, GameEventStorage &ges);
virtual Response::ResponseCode
cmdSetPlaymat(const Command_SetPlaymat &cmd, ResponseContainer &rc, GameEventStorage &ges);
virtual Response::ResponseCode cmdGameSay(const Command_GameSay &cmd, ResponseContainer &rc, GameEventStorage &ges);
virtual Response::ResponseCode cmdShuffle(const Command_Shuffle &cmd, ResponseContainer &rc, GameEventStorage &ges);
virtual Response::ResponseCode

View file

@ -1653,5 +1653,13 @@ void Server_AbstractPlayer::getPlayerProperties(ServerInfo_PlayerProperties &res
result.set_ready_start(readyStart);
if (deck) {
result.set_deck_hash(deck->getDeckHash().toStdString());
const auto &playmat = deck->getPlaymat();
auto *playmatParams = result.mutable_playmat_params();
playmatParams->set_card_name(playmat.card.name.toStdString());
playmatParams->set_card_provider_id(playmat.card.providerId.toStdString());
playmatParams->set_margin_pct_l(playmat.params.marginPctL);
playmatParams->set_margin_pct_r(playmat.params.marginPctR);
playmatParams->set_vertical_offset(playmat.params.verticalOffset);
playmatParams->set_zoom(playmat.params.zoom);
}
}

View file

@ -27,6 +27,7 @@
#include <libcockatrice/protocol/pb/command_mulligan.pb.h>
#include <libcockatrice/protocol/pb/command_set_active_phase.pb.h>
#include <libcockatrice/protocol/pb/command_set_counter.pb.h>
#include <libcockatrice/protocol/pb/command_set_playmat.pb.h>
#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>
@ -250,6 +251,14 @@ Server_Player::cmdDeckSelect(const Command_DeckSelect &cmd, ResponseContainer &r
Event_PlayerPropertiesChanged event;
event.mutable_player_properties()->set_sideboard_locked(true);
event.mutable_player_properties()->set_deck_hash(deck->getDeckHash().toStdString());
const auto &playmat = deck->getPlaymat();
auto *playmatParams = event.mutable_player_properties()->mutable_playmat_params();
playmatParams->set_card_name(playmat.card.name.left(MAX_NAME_LENGTH).toStdString());
playmatParams->set_card_provider_id(playmat.card.providerId.left(MAX_NAME_LENGTH).toStdString());
playmatParams->set_margin_pct_l(playmat.params.marginPctL);
playmatParams->set_margin_pct_r(playmat.params.marginPctR);
playmatParams->set_vertical_offset(playmat.params.verticalOffset);
playmatParams->set_zoom(playmat.params.zoom);
ges.enqueueGameEvent(event, playerId);
Context_DeckSelect context;
@ -594,6 +603,46 @@ Server_Player::cmdReverseTurn(const Command_ReverseTurn &cmd, ResponseContainer
return Server_AbstractParticipant::cmdReverseTurn(cmd, rc, ges);
}
Response::ResponseCode
Server_Player::cmdSetPlaymat(const Command_SetPlaymat &cmd, ResponseContainer &rc, GameEventStorage &ges)
{
Q_UNUSED(rc);
if (!deck) {
return Response::RespContextError;
}
const auto &pp = cmd.playmat_params();
const auto rawName = QString::fromStdString(pp.card_name());
const auto rawProviderId = QString::fromStdString(pp.card_provider_id());
if (rawName.length() > MAX_NAME_LENGTH || rawProviderId.length() > MAX_NAME_LENGTH) {
return Response::RespInvalidData;
}
PlaymatInfo playmat;
playmat.card.name = rawName;
playmat.card.providerId = rawProviderId;
playmat.params.marginPctL = qBound(0.0, pp.margin_pct_l(), 0.95);
playmat.params.marginPctR = qBound(0.0, pp.margin_pct_r(), 0.95);
playmat.params.verticalOffset = qBound(0.0, pp.vertical_offset(), 1.0);
playmat.params.zoom = qBound(0.1, pp.zoom(), 4.0);
deck->setPlaymat(playmat);
Event_PlayerPropertiesChanged event;
auto *props = event.mutable_player_properties();
props->set_sideboard_locked(sideboardLocked);
props->set_deck_hash(deck->getDeckHash().toStdString());
auto *playmatParams = props->mutable_playmat_params();
playmatParams->set_card_name(playmat.card.name.toStdString());
playmatParams->set_card_provider_id(playmat.card.providerId.toStdString());
playmatParams->set_margin_pct_l(playmat.params.marginPctL);
playmatParams->set_margin_pct_r(playmat.params.marginPctR);
playmatParams->set_vertical_offset(playmat.params.verticalOffset);
playmatParams->set_zoom(playmat.params.zoom);
ges.enqueueGameEvent(event, playerId);
return Response::RespOk;
}
void Server_Player::getInfo(ServerInfo_Player *info,
Server_AbstractParticipant *recipient,
bool omniscient,

View file

@ -62,6 +62,8 @@ public:
cmdSetActivePhase(const Command_SetActivePhase &cmd, ResponseContainer &rc, GameEventStorage &ges) override;
Response::ResponseCode
cmdReverseTurn(const Command_ReverseTurn & /*cmd*/, ResponseContainer & /*rc*/, GameEventStorage &ges) override;
Response::ResponseCode
cmdSetPlaymat(const Command_SetPlaymat &cmd, ResponseContainer &rc, GameEventStorage &ges) override;
Response::ResponseCode cmdChangeZoneProperties(const Command_ChangeZoneProperties &cmd,
ResponseContainer &rc,
GameEventStorage &ges) override;

View file

@ -38,7 +38,8 @@ enum AuthenticationResult
UsernameInvalid,
RegistrationRequired,
UserIsInactive,
ClientIdRequired
ClientIdRequired,
PasswordChangeRequired
};
class Server : public QObject

View file

@ -69,6 +69,10 @@ void Server_AbstractUserInterface::sendResponseContainer(const ResponseContainer
}
}
void Server_AbstractUserInterface::onLogin(ResponseContainer &)
{
}
void Server_AbstractUserInterface::playerRemovedFromGame(Server_Game *game)
{
qDebug() << "Server_AbstractUserInterface::playerRemovedFromGame(): gameId =" << game->getGameId();

View file

@ -42,6 +42,7 @@ public:
void playerRemovedFromGame(Server_Game *game);
void playerAddedToGame(int gameId, int roomId, int playerId);
void joinPersistentGames(ResponseContainer &rc);
virtual void onLogin(ResponseContainer &rc);
QMap<int, QPair<int, int>> getGames() const
{

View file

@ -172,6 +172,9 @@ public:
{
return false;
}
virtual void setForcePasswordChange(const QString & /* user */, bool /* force */)
{
}
};
#endif

View file

@ -564,6 +564,8 @@ Response::ResponseCode Server_ProtocolHandler::cmdLogin(const Command_Login &cmd
return Response::RespClientIdRequired;
case UserIsInactive:
return Response::RespAccountNotActivated;
case PasswordChangeRequired:
return Response::RespPasswordChangeRequired;
default:
authState = res;
usingRealPassword = needsHash;
@ -614,6 +616,7 @@ Response::ResponseCode Server_ProtocolHandler::cmdLogin(const Command_Login &cmd
joinPersistentGames(rc);
databaseInterface->removeForgotPassword(userName);
onLogin(rc);
rc.setResponseExtension(re);
return Response::RespOk;
}

View file

@ -35,10 +35,20 @@ set(PROTO_FILES
command_ready_start.proto
command_replay_delete_match.proto
command_replay_download.proto
command_replay_download_by_game_id.proto
command_replay_get_code.proto
command_replay_list.proto
command_replay_modify_match.proto
command_replay_submit_code.proto
command_report.proto
command_report_add_comment.proto
command_report_assign.proto
command_report_details.proto
command_report_list.proto
command_report_my_list.proto
command_report_resolve.proto
command_report_stats.proto
command_report_user_info.proto
command_reveal_cards.proto
command_reverse_turn.proto
command_roll_die.proto
@ -46,6 +56,7 @@ set(PROTO_FILES
command_set_card_attr.proto
command_set_card_counter.proto
command_set_counter.proto
command_set_playmat.proto
command_set_sideboard_lock.proto
command_set_sideboard_plan.proto
command_shuffle.proto
@ -137,8 +148,19 @@ set(PROTO_FILES
response_password_salt.proto
response_register.proto
response_replay_download.proto
response_replay_download_by_game_id.proto
response_replay_get_code.proto
response_replay_list.proto
response_report_details.proto
response_report_list.proto
response_report_my_list.proto
response_report_stats.proto
response_report_user_info.proto
response_moderator_last_logins.proto
response_remove_user_avatar.proto
response_reset_user_password.proto
response_user_alts.proto
response_user_sessions.proto
response_viewlog_history.proto
response_warn_history.proto
response_warn_list.proto
@ -154,13 +176,17 @@ set(PROTO_FILES
serverinfo_deckstorage.proto
serverinfo_game.proto
serverinfo_gametype.proto
serverinfo_moderator_login.proto
serverinfo_player.proto
serverinfo_playerping.proto
serverinfo_playerproperties.proto
serverinfo_replay.proto
serverinfo_replay_match.proto
serverinfo_report.proto
serverinfo_room.proto
serverinfo_user.proto
serverinfo_user_alt.proto
serverinfo_user_session.proto
serverinfo_warning.proto
serverinfo_zone.proto
session_commands.proto

View file

@ -5,6 +5,7 @@ message AdminCommand {
SHUTDOWN_SERVER = 1001;
RELOAD_CONFIG = 1002;
ADJUST_MOD = 1003;
RESET_USER_PASSWORD = 1016;
}
extensions 100 to max;
}
@ -37,3 +38,10 @@ message Command_AdjustMod {
optional bool should_be_mod = 2;
optional bool should_be_judge = 3;
}
message Command_ResetUserPassword {
extend AdminCommand {
optional Command_ResetUserPassword ext = 1016;
}
optional string user_name = 1;
}

View file

@ -0,0 +1,9 @@
syntax = "proto2";
import "moderator_commands.proto";
message Command_ReplayDownloadByGameId {
extend ModeratorCommand {
optional Command_ReplayDownloadByGameId ext = 1203;
}
required sint32 game_id = 1;
}

View file

@ -0,0 +1,13 @@
syntax = "proto2";
import "session_commands.proto";
message Command_Report {
extend SessionCommand {
optional Command_Report ext = 1200;
}
optional string reported_user = 1;
optional int32 game_id = 2;
optional string category = 3;
optional string description = 4;
optional string chat_log = 5;
}

View file

@ -0,0 +1,10 @@
syntax = "proto2";
import "session_commands.proto";
message Command_ReportAddComment {
extend SessionCommand {
optional Command_ReportAddComment ext = 1205;
}
required int32 report_id = 1;
required string comment = 2;
}

View file

@ -0,0 +1,9 @@
syntax = "proto2";
import "moderator_commands.proto";
message Command_ReportAssign {
extend ModeratorCommand {
optional Command_ReportAssign ext = 1201;
}
required int32 report_id = 1;
}

View file

@ -0,0 +1,9 @@
syntax = "proto2";
import "session_commands.proto";
message Command_ReportDetails {
extend SessionCommand {
optional Command_ReportDetails ext = 1206;
}
required int32 report_id = 1;
}

View file

@ -0,0 +1,11 @@
syntax = "proto2";
import "moderator_commands.proto";
message Command_ReportList {
extend ModeratorCommand {
optional Command_ReportList ext = 1200;
}
optional bool unresolved_only = 1;
optional uint32 offset = 2 [default = 0];
optional uint32 limit = 3 [default = 100];
}

View file

@ -0,0 +1,8 @@
syntax = "proto2";
import "session_commands.proto";
message Command_ReportMyList {
extend SessionCommand {
optional Command_ReportMyList ext = 1204;
}
}

View file

@ -0,0 +1,11 @@
syntax = "proto2";
import "moderator_commands.proto";
message Command_ReportResolve {
extend ModeratorCommand {
optional Command_ReportResolve ext = 1202;
}
required int32 report_id = 1;
optional string resolution_note = 2;
optional bool dismissed = 3;
}

View file

@ -0,0 +1,8 @@
syntax = "proto2";
import "moderator_commands.proto";
message Command_ReportStats {
extend ModeratorCommand {
optional Command_ReportStats ext = 1205;
}
}

View file

@ -0,0 +1,9 @@
syntax = "proto2";
import "moderator_commands.proto";
message Command_ReportUserInfo {
extend ModeratorCommand {
optional Command_ReportUserInfo ext = 1204;
}
optional string user_name = 1;
}

View file

@ -0,0 +1,9 @@
syntax = "proto2";
import "game_commands.proto";
import "serverinfo_playerproperties.proto";
message Command_SetPlaymat {
extend GameCommand {
optional Command_SetPlaymat ext = 1035;
}
optional ServerInfo_PlayerProperties.PlaymatParams playmat_params = 1;
}

View file

@ -9,6 +9,8 @@ message Event_NotifyUser {
WARNING = 2;
IDLEWARNING = 3;
CUSTOM = 4;
REPORT_RESOLVED = 5;
REPORT_COMMENT = 6;
}
extend SessionEvent {

View file

@ -175,6 +175,11 @@ message GameCommand {
/// Server: Server_Player::cmdReverseTurn
/// Client: reflected via subsequent turn events
REVERSE_TURN = 1034;
/// Set the player's playmat independently of the deck.
/// Server: Server_Player::cmdSetPlaymat
/// Client: reflected via player properties changed event
SET_PLAYMAT = 1035;
}
extensions 100 to max;

View file

@ -14,6 +14,17 @@ message ModeratorCommand {
ADD_CARD_ART_RULE = 1010;
REMOVE_CARD_ART_RULE = 1011;
LIST_CARD_ART_RULES = 1012;
GET_USER_SESSIONS = 1013;
GET_USER_ALTS = 1014;
GET_MODERATOR_LAST_LOGINS = 1015;
RESET_USER_PASSWORD = 1016;
REMOVE_USER_AVATAR = 1017;
REPORT_LIST = 1200;
REPORT_ASSIGN = 1201;
REPORT_RESOLVE = 1202;
REPLAY_DOWNLOAD_BY_GAME_ID = 1203;
REPORT_USER_INFO = 1204;
REPORT_STATS = 1205;
}
extensions 100 to max;
}
@ -135,3 +146,31 @@ message Command_ListCardArtRules {
optional Command_ListCardArtRules ext = 1012;
}
}
message Command_GetUserSessions {
extend ModeratorCommand {
optional Command_GetUserSessions ext = 1013;
}
optional string user_name = 1;
optional uint32 limit = 2 [default = 110];
}
message Command_GetUserAlts {
extend ModeratorCommand {
optional Command_GetUserAlts ext = 1014;
}
optional string user_name = 1;
}
message Command_GetModeratorLastLogins {
extend ModeratorCommand {
optional Command_GetModeratorLastLogins ext = 1015;
}
}
message Command_RemoveUserAvatar {
extend ModeratorCommand {
optional Command_RemoveUserAvatar ext = 1017;
}
optional string user_name = 1;
}

View file

@ -53,6 +53,7 @@ message Response {
RespClientUpdateRequired = 35; // Client is missing features that the server is requiring
RespServerFull = 36; // Server user limit reached
RespEmailBlackListed = 37; // Server has blocked the email address provided for registration for some reason
RespPasswordChangeRequired = 38; // Server requires the user to change their password before proceeding
}
// Type of response, used to route handling on the client

View file

@ -0,0 +1,10 @@
syntax = "proto2";
import "response.proto";
import "serverinfo_moderator_login.proto";
message Response_ModeratorLastLogins {
extend Response {
optional Response_ModeratorLastLogins ext = 1217;
}
repeated ServerInfo_ModeratorLogin logins = 1;
}

View file

@ -0,0 +1,9 @@
syntax = "proto2";
import "response.proto";
message Response_RemoveUserAvatar {
extend Response {
optional Response_RemoveUserAvatar ext = 1219;
}
optional string user_name = 1;
}

View file

@ -0,0 +1,10 @@
syntax = "proto2";
import "response.proto";
message Response_ReplayDownloadByGameId {
extend Response {
optional Response_ReplayDownloadByGameId ext = 1203;
}
optional bytes replay_data = 1;
optional sint32 replay_id = 2;
}

View file

@ -0,0 +1,10 @@
syntax = "proto2";
import "response.proto";
import "serverinfo_report.proto";
message Response_ReportDetails {
extend Response {
optional Response_ReportDetails ext = 1214;
}
optional ServerInfo_Report report = 1;
}

View file

@ -0,0 +1,11 @@
syntax = "proto2";
import "response.proto";
import "serverinfo_report.proto";
message Response_ReportList {
extend Response {
optional Response_ReportList ext = 1210;
}
repeated ServerInfo_Report reports = 1;
optional uint32 total_count = 2;
}

View file

@ -0,0 +1,10 @@
syntax = "proto2";
import "response.proto";
import "serverinfo_report.proto";
message Response_ReportMyList {
extend Response {
optional Response_ReportMyList ext = 1213;
}
repeated ServerInfo_Report reports = 1;
}

View file

@ -0,0 +1,36 @@
syntax = "proto2";
import "response.proto";
message Response_ReportStats {
extend Response {
optional Response_ReportStats ext = 1212;
}
optional int32 total_reports = 1;
optional int32 total_pending = 2;
optional int32 total_assigned = 3;
optional int32 total_resolved = 4;
optional int32 reports_last_24h = 5;
optional int32 reports_last_7d = 6;
optional int32 reports_last_30d = 7;
optional double avg_resolution_hours = 8;
optional int32 reports_this_week = 9;
optional int32 reports_last_week = 10;
repeated ReportCategoryCount category_counts = 11;
repeated ReportTopUser top_reported_users = 12;
repeated ReportTopUser top_reporters = 13;
}
message ReportCategoryCount {
optional string category = 1;
optional int32 count = 2;
}
message ReportTopUser {
optional string user_name = 1;
optional int32 count = 2;
}

View file

@ -0,0 +1,21 @@
syntax = "proto2";
import "response.proto";
import "serverinfo_report.proto";
message Response_ReportUserInfo {
extend Response {
optional Response_ReportUserInfo ext = 1211;
}
optional string user_name = 1;
optional int32 total_reports = 2;
optional int32 total_bans = 3;
optional int32 total_warns = 4;
optional int64 registration_time = 5;
optional bool is_admin = 6;
optional bool is_active = 7;
optional string admin_notes = 8;
repeated ServerInfo_Report recent_reports = 9;
// Last known login of the user, epoch seconds; 0 = unknown.
optional int64 last_login = 10;
}

View file

@ -0,0 +1,12 @@
syntax = "proto2";
import "response.proto";
message Response_ResetUserPassword {
extend Response {
optional Response_ResetUserPassword ext = 1218;
}
optional string user_name = 1;
// The generated temporary password, shown to the moderator who
// requested the reset. The affected user must change it on first login.
optional string temporary_password = 2;
}

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