Compare commits

..

No commits in common. "e2eb36f19fb26c45f437e86831383897b1dd78b5" and "da924c2bd6155cb8486fdf59f3dc9ef5d7670207" have entirely different histories.

19 changed files with 108 additions and 395 deletions

View file

@ -12,9 +12,9 @@ Available pre-compiled binaries for installation:
• <kbd>Windows 10+</kbd>
<b>macOS</b>
• <kbd>macOS 15+</kbd> <sub><i>Sequoia</i></sub>
• <kbd>macOS 14+</kbd> <sub><i>Sonoma</i></sub>
• <kbd>macOS 13+</kbd> <sub><i>Ventura (x86)</i></sub>
• <kbd>macOS 15+</kbd> <sub><i>Sequoia</i></sub> <sub>Apple M</sub>
• <kbd>macOS 14+</kbd> <sub><i>Sonoma</i></sub> <sub>Apple M</sub>
• <kbd>macOS 13+</kbd> <sub><i>Ventura</i></sub> <sub>Intel</sub>
<b>Linux</b>
• <kbd>Ubuntu 26.04 LTS</kbd> <sub><i>Resolute Racoon</i></sub>
@ -24,10 +24,10 @@ Available pre-compiled binaries for installation:
• <kbd>Fedora 44</kbd>
• <kbd>Fedora 43</kbd>
<sub>General Linux support is available via a <kbd>flatpak</kbd> package hosted at <a href="https://flathub.org/apps/io.github.Cockatrice.cockatrice">Flathub</a> (x86 & ARM)!</sub>
<sub>Thanks to courtesy of @FFY00, the app is also available in <kbd>Arch Linux</kbd>'s <a href="https://archlinux.org/packages/extra/x86_64/cockatrice">official extra repository</a>.</sub>
<sub>We are also packaged in <kbd>Arch Linux</kbd>'s <a href="https://archlinux.org/packages/extra/x86_64/cockatrice">official extra repository</a>, courtesy of @FFY00.</sub>
<sub>General Linux support is available via a <kbd>flatpak</kbd> package at <a href="https://flathub.org/apps/io.github.Cockatrice.cockatrice">Flathub</a>!</sub>
<sub>We maintain a <kbd>Docker</kbd> image for "Servatrice" in <a href="https://github.com/Cockatrice/Cockatrice/pkgs/container/servatrice">GHCR</a> (x86 & ARM). You can docker pull it or use our Docker Compose files!</sub>
<sub>We provide a <kbd>Docker</kbd> image for "Servatrice" in <a href="https://github.com/Cockatrice/Cockatrice/pkgs/container/servatrice">GHCR</a>. You can docker pull it or use our Docker Compose files!</sub>
</pre>

View file

@ -9,8 +9,8 @@
name: CodeQL
permissions:
contents: read
security-events: write # needed to post results
contents: read
on:
push:

View file

@ -1,8 +1,5 @@
name: Code Style (C++)
permissions:
contents: read
on:
# Push trigger not needed for linting, we do not allow direct pushes to master
pull_request:

View file

@ -1,8 +1,8 @@
name: Build Docker
name: Build Docker Image
permissions:
contents: read # needed to checkout repo
packages: write # needed for interacting with GHCR
contents: read
packages: write
on:
push:
@ -13,10 +13,7 @@ on:
- master
paths:
- '.github/workflows/docker-release.yml'
- '.dockerignore'
- 'Dockerfile'
- 'docker-compose.yml'
- 'docker-compose.yml.windows'
release:
types:
- released # publishing of stable releases
@ -26,38 +23,36 @@ concurrency:
group: "${{ github.workflow }} @ ${{ github.ref_name }}"
cancel-in-progress: ${{ github.event_name != 'release' }}
env:
GHCR_IMAGE: ghcr.io/cockatrice/servatrice
OCI_DESCRIPTION: Server for Cockatrice, a cross-platform virtual tabletop for multiplayer card games
OCI_TITLE: Servatrice
OCI_URL: https://cockatrice.github.io/
jobs:
# Create one platform-specific image and publish its OCI image manifest per matrix job
build:
name: "Servatrice (${{ matrix.label }})"
if: github.repository_owner == 'Cockatrice'
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- label: x86
platform: linux/amd64
runner: ubuntu-latest # https://github.com/actions/runner-images
- label: arm
platform: linux/arm64
runner: ubuntu-24.04-arm # https://github.com/actions/runner-images/blob/main/images/ubuntu/Ubuntu2404-Arm64-Readme.md, replace with "ubuntu-latest-arm" once available
env:
CACHE_SCOPE: servatrice-${{ matrix.label }}
docker:
name: amd64 & arm64
if: ${{ github.repository_owner == 'Cockatrice' }}
runs-on: ubuntu-latest
steps:
- name: "Checkout"
uses: actions/checkout@v7
- name: "Docker metadata"
id: metadata
uses: docker/metadata-action@v6
env:
DOCKER_METADATA_ANNOTATIONS_LEVELS: index # needed for GHCR
with:
annotations: |
org.opencontainers.image.title=Servatrice
org.opencontainers.image.url=https://cockatrice.github.io/
org.opencontainers.image.description=Server for Cockatrice, a cross-platform virtual tabletop for multiplayer card games
images: |
ghcr.io/cockatrice/servatrice
labels: |
org.opencontainers.image.title=Servatrice
org.opencontainers.image.url=https://cockatrice.github.io/
org.opencontainers.image.description=Server for Cockatrice, a cross-platform virtual tabletop for multiplayer card games
- name: "Set up QEMU"
uses: docker/setup-qemu-action@v4
- name: "Set up Docker buildx"
uses: docker/setup-buildx-action@v4
@ -66,117 +61,18 @@ jobs:
id: login
uses: docker/login-action@v4
with:
password: ${{ github.token }}
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ github.token }}
# Don't push for non-release triggers
- name: "Build image"
if: steps.login.outcome != 'success'
- name: "Build and push Docker image"
uses: docker/build-push-action@v7
with:
cache-from: type=gha,scope=${{ env.CACHE_SCOPE }}
cache-to: type=gha,mode=max,scope=${{ env.CACHE_SCOPE }}
annotations: ${{ steps.metadata.outputs.annotations }}
cache-from: type=gha,scope=servatrice
cache-to: type=gha,mode=max,scope=servatrice
context: .
platforms: ${{ matrix.platform }}
push: false
# Add OCI labels and push single-platform image by digest (without tags)
- name: "Build image and push by digest"
if: steps.login.outcome == 'success'
id: build
uses: docker/build-push-action@v7
with:
cache-from: type=gha,scope=${{ env.CACHE_SCOPE }}
cache-to: type=gha,mode=max,scope=${{ env.CACHE_SCOPE }}
context: .
labels: |
org.opencontainers.image.description=${{ env.OCI_DESCRIPTION }}
org.opencontainers.image.title=${{ env.OCI_TITLE }}
org.opencontainers.image.url=${{ env.OCI_URL }}
outputs: type=image,name=${{ env.GHCR_IMAGE }},name-canonical=true,push=true,push-by-digest=true
platforms: ${{ matrix.platform }}
provenance: mode=max # Do not pass secrets as build arguments with this option
sbom: true
- name: "Export digest"
if: steps.login.outcome == 'success'
env:
DIGEST: ${{ steps.build.outputs.digest }}
run: |
mkdir -p "$RUNNER_TEMP/digests"
touch "$RUNNER_TEMP/digests/${DIGEST#sha256:}"
- name: "Upload digest"
if: steps.login.outcome == 'success'
uses: actions/upload-artifact@v7
with:
archive: false
if-no-files-found: error
name: digest-${{ matrix.label }}
path: ${{ runner.temp }}/digests/*
retention-days: 1
# Create an OCI image index from the platform-specific image manifests
index:
name: "Publish multi-platform Servatrice image"
if: github.repository_owner == 'Cockatrice' && github.event_name == 'release' && github.event.release.prerelease == false
needs: build
runs-on: ubuntu-slim # https://github.com/actions/runner-images/blob/main/images/ubuntu-slim/ubuntu-slim-Readme.md
steps:
- name: "Download digests"
uses: actions/download-artifact@v7
with:
path: ${{ runner.temp }}/digests
pattern: digest-*
merge-multiple: true
- name: "Login to GitHub Container Registry (GHCR)"
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ github.token }}
- name: "Docker metadata"
id: metadata
uses: docker/metadata-action@v6
with:
images: ${{ env.GHCR_IMAGE }}
flavor: |
latest=auto
tags: |
type=ref,event=tag # if semver, also: type=semver,pattern={{version}} / {{major}}.{{minor}}
# Add OCI annotations to image index and publish tags
- name: "Create image index"
env:
DOCKER_TAGS: ${{ steps.metadata.outputs.tags }}
working-directory: ${{ runner.temp }}/digests
run: |
TAG_ARGS=()
while IFS= read -r tag; do
TAG_ARGS+=(--tag "$tag")
done <<< "$DOCKER_TAGS"
DIGEST_ARGS=()
for digest in *; do
DIGEST_ARGS+=("$GHCR_IMAGE@sha256:$digest")
done
docker buildx imagetools create \
--prefer-index=true \
--annotation "index:org.opencontainers.image.description=$OCI_DESCRIPTION" \
--annotation "index:org.opencontainers.image.title=$OCI_TITLE" \
--annotation "index:org.opencontainers.image.url=$OCI_URL" \
"${TAG_ARGS[@]}" \
"${DIGEST_ARGS[@]}"
- name: "Inspect images"
env:
GITHUB_TAG: ${{ github.ref_name }}
run: |
docker buildx imagetools inspect "$GHCR_IMAGE:latest"
docker buildx imagetools inspect "$GHCR_IMAGE:$GITHUB_TAG"
labels: ${{ steps.metadata.outputs.labels }}
platforms: linux/amd64,linux/arm64
push: ${{ steps.login.outcome == 'success' }}
tags: ${{ steps.metadata.outputs.tags }}

View file

@ -1,8 +1,5 @@
name: Generate Docs
permissions:
contents: read # write permission to the destination repo come from 'deploy_key'
on:
pull_request:
paths:

View file

@ -1,9 +1,5 @@
name: Update Translations
permissions:
contents: read
pull-requests: write
on:
pull_request:
paths:

View file

@ -1,9 +1,5 @@
name: Update Translation Source
permissions:
contents: read
pull-requests: write
on:
pull_request:
paths:

View file

@ -3,45 +3,35 @@ FROM ubuntu:26.04 AS build
ARG DEBIAN_FRONTEND=noninteractive
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
build-essential \
cmake \
ninja-build \
file \
g++ \
git \
libmariadb-dev-compat \
libprotobuf-dev \
libqt6sql6-mysql \
qt6-websockets-dev \
protobuf-compiler \
qt6-tools-dev \
qt6-tools-dev-tools
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
cmake \
file \
g++ \
git \
libmariadb-dev-compat \
libprotobuf-dev \
libqt6sql6-mysql \
qt6-websockets-dev \
protobuf-compiler \
qt6-tools-dev \
qt6-tools-dev-tools
WORKDIR /src
COPY . .
RUN cmake \
-S . \
-B build \
-G Ninja \
-DWITH_CLIENT=0 \
-DWITH_ORACLE=0 \
-DWITH_SERVER=1 \
&& cmake --build build \
&& cmake --install build
RUN mkdir build && cd build && \
cmake .. -DWITH_SERVER=1 -DWITH_CLIENT=0 -DWITH_ORACLE=0 && \
make -j$(nproc) && \
make install
# -------- Runtime Stage (clean) --------
FROM ubuntu:26.04
ARG DEBIAN_FRONTEND=noninteractive
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
libprotobuf32t64 \
libqt6sql6-mysql \
libqt6websockets6 \
RUN apt-get update && apt-get install -y --no-install-recommends \
libprotobuf32t64 \
libqt6sql6-mysql \
libqt6websockets6 \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*

View file

@ -187,8 +187,8 @@ void PlayerGraphicsItem::paint(QPainter *painter, const QStyleOptionGraphicsItem
QRectF combinedArea(combinedLeft, combinedTop, combinedRight - combinedLeft, combinedBottom - combinedTop);
const QRectF srcRect = PlaymatUtils::computeArtSourceRect(playmatPixmap.size(), playmatParams);
const QRectF dstRect = PlaymatUtils::coverFitRect(combinedArea, srcRect.size());
const QRectF srcRect = computeArtSourceRect(playmatPixmap.size(), playmatParams);
const QRectF dstRect = coverFitRect(combinedArea, srcRect.size());
painter->save();
painter->setClipRect(combinedArea);

View file

@ -61,8 +61,8 @@ void PlaymatPreviewWidget::paintEvent(QPaintEvent *)
// 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 = PlaymatUtils::computeArtSourceRect(sourcePixmap.size(), params);
const QRectF dstRect = PlaymatUtils::coverFitRect(playArea, srcRect.size());
const QRectF srcRect = computeArtSourceRect(sourcePixmap.size(), params);
const QRectF dstRect = coverFitRect(playArea, srcRect.size());
painter.setClipRect(playArea.toRect());
painter.drawPixmap(dstRect, sourcePixmap, srcRect);

View file

@ -6,96 +6,42 @@
#include <QSizeF>
#include <libcockatrice/deck_list/deck_list.h>
namespace PlaymatUtils
{
/** @brief Upper bound for zooming into the playmat art. */
constexpr qreal MAX_ZOOM = 4.0;
/**
* @brief Width of the outer viewing window: full card width trimmed by the
* horizontal margins. Guarded against margins summing to >= 1.
*/
inline qreal playmatVisibleWidth(const QSize &fullCardSize, const PlaymatParams &params)
{
const qreal srcW = fullCardSize.width();
const qreal marginL = params.marginPctL * srcW;
const qreal marginR = params.marginPctR * srcW;
return qMax(0.0, srcW - marginL - marginR);
}
/**
* @brief Zoom clamped to the range where every step renders differently.
* @brief Computes the source region of the full-resolution card image to use as a playmat.
*
* The square sampling window is visibleWidth / zoom, zooming out past
* visibleWidth / min(card width, height) would sample beyond the card itself,
* which both looks broken and makes whole ranges of the parameter dead. The
* floor is therefore derived from the actual image instead of a static value,
* and is shared verbatim by the render path and the editor's gesture math so
* the two can never disagree.
*/
inline qreal playmatClampedZoom(const QSize &fullCardSize, const PlaymatParams &params)
{
const qreal minDim = qMin<qreal>(fullCardSize.width(), fullCardSize.height());
const qreal visibleW = playmatVisibleWidth(fullCardSize, params);
const qreal zoomOutFloor = (minDim > 0.0 && visibleW > 0.0) ? visibleW / minDim : 1.0;
// The floor deliberately bypasses MAX_ZOOM: when the art is much wider
// than tall, keeping the square window inside it requires more than 4x
// zoom-out, and honoring that larger floor keeps side within
// min(card width, height). Zooming IN is still capped at MAX_ZOOM.
return qMin(MAX_ZOOM, qMax(params.zoom, zoomOutFloor));
}
/**
* @brief Side of the square sampling window actually rendered for these
* parameters. Never exceeds either card dimension, so the source rect
* always lies within the image (vertical travel remains for panning
* whenever the art is taller than it is wide).
*/
inline qreal playmatWindowSide(const QSize &fullCardSize, const PlaymatParams &params)
{
const qreal visibleW = playmatVisibleWidth(fullCardSize, params);
if (visibleW <= 0.0) {
return 0.0;
}
return visibleW / playmatClampedZoom(fullCardSize, params);
}
/**
* @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 (shifting them pans the window), verticalOffset places the top
* edge of the sampling window within its available travel, and zoom scales
* into the trimmed span. The result always lies within the card image bounds.
* 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.
* @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();
// Square sampling window, keeps art unskewed, never exceeds the card on
// either axis thanks to the zoom floor in playmatWindowSide().
const qreal side = playmatWindowSide(fullCardSize, params);
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
// verticalOffset places the TOP edge of the sampling window itself within
// its travel, so the full [0, 1] parameter range is live at every zoom and
// the window can always reach the very top (0.0) and bottom (1.0) of the
// art.
const qreal offset = qBound(0.0, params.verticalOffset, 1.0);
const qreal y = offset * qMax(0.0, srcH - side);
const qreal vCenter = params.verticalOffset * srcH;
qreal srcY = vCenter - visibleH / 2.0;
srcY = qBound(0.0, srcY, srcH - visibleH);
// Horizontally the sampling window sits centered inside the trimmed span
// (margins pan it), zooming out can make it wider than that span, so it
// is then kept within the image, an edge stop, never an invalid rect.
const qreal outerW = playmatVisibleWidth(fullCardSize, params);
const qreal x = qBound(0.0, params.marginPctL * srcW + (outerW - side) / 2.0, qMax(0.0, srcW - side));
// 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(x, y, side, side);
return QRectF(zoomedX, zoomedY, zoomedW, zoomedH);
}
/**
@ -103,7 +49,7 @@ inline QRectF computeArtSourceRect(const QSize &fullCardSize, const PlaymatParam
* 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.
* @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)
@ -120,21 +66,4 @@ inline QRectF coverFitRect(const QRectF &dstArea, const QSizeF &srcSize)
return QRectF(dstArea.left(), dstArea.top() + (dstArea.height() - dstH) / 2.0, dstArea.width(), dstH);
}
/**
* @brief Fits a rectangle of the given aspect ratio into dstArea, centered,
* touching the constraining dimension ("aspect fit" of the FRAME
* itself, not of a source image).
*/
inline QRectF aspectFitRect(const QRectF &dstArea, qreal aspect)
{
if (aspect <= 0.0) {
return dstArea;
}
qreal w = qMin(dstArea.width(), dstArea.height() * aspect);
qreal h = w / aspect;
return QRectF(dstArea.left() + (dstArea.width() - w) / 2.0, dstArea.top() + (dstArea.height() - h) / 2.0, w, h);
}
} // namespace PlaymatUtils
#endif // COCKATRICE_PLAYMAT_UTILS_H

View file

@ -112,19 +112,6 @@ UserCardArtSettingsDialog::UserCardArtSettingsDialog(const CardArtParams &initia
if (!initial.cardName.isEmpty()) {
searchBar->setText(initial.cardName);
onCardNameChanged(initial.cardName);
// onCardNameChanged leaves the printing combo on the first printing in
// the database, which would silently change the stored banner card on
// accept. Restore the stored printing when it resolves locally.
const int storedPrintingIndex = providerComboBox->findData(initial.cardProviderId);
if (storedPrintingIndex != -1) {
providerComboBox->setCurrentIndex(storedPrintingIndex);
} else {
// Stored printing not in the local database: keep it rather than
// silently substituting the first printing.
currentParams.cardProviderId = initial.cardProviderId;
reloadPreview();
}
}
marginLSpin->setValue(initial.marginPctL);
marginRSpin->setValue(initial.marginPctR);

View file

@ -319,7 +319,6 @@ void UserInfoBox::actBannerCard()
if (hasUserInfo && currentUserInfo.has_card_art_params()) {
const auto &cap = currentUserInfo.card_art_params();
initial.cardName = QString::fromStdString(cap.card_name());
initial.cardProviderId = QString::fromStdString(cap.card_provider_id());
initial.marginPctL = cap.margin_pct_l();
initial.marginPctR = cap.margin_pct_r();
initial.verticalOffset = cap.vertical_offset();

View file

@ -189,31 +189,22 @@ void UserInfoHeaderWidget::paintEvent(QPaintEvent *)
// ── Card art background ───────────────────────────────────────────────────
if (!cardArt.isNull()) {
// Same DPR normalization as UserListPainter::drawCardArt: the cache
// carries screen scaled pixmaps on HiDPI displays, the math below is
// in raw pixels.
QPixmap art = cardArt;
art.setDevicePixelRatio(1.0);
const int w = rect.width();
const int h = rect.height();
const int mL = qRound(w * params.marginPctL);
const int mR = qRound(w * params.marginPctR);
const int dW = w - mL - mR;
const double base = qMax(double(dW) / art.width(), double(h) / art.height());
const double base = qMax(double(dW) / cardArt.width(), double(h) / cardArt.height());
const double scale = base * params.zoom;
const int sW = qRound(art.width() * scale);
const int sH = qRound(art.height() * scale);
const int sW = qRound(cardArt.width() * scale);
const int sH = qRound(cardArt.height() * scale);
const QPixmap scaled = art.scaled(sW, sH, Qt::IgnoreAspectRatio, Qt::SmoothTransformation);
// Clamp against stored zoom < 1, which can push srcX negative and silently
// underfill the strip with transparent padding
const int safeSrcX = qBound(0, (sW - dW) / 2, qMax(0, sW - dW));
const int safeSrcY = qBound(0, qRound((sH - h) * params.verticalOffset), qMax(0, sH - h));
const QPixmap scaled = cardArt.scaled(sW, sH, Qt::IgnoreAspectRatio, Qt::SmoothTransformation);
const int srcX = (sW - dW) / 2;
const int srcY = qBound(0, qRound((sH - h) * params.verticalOffset), qMax(0, sH - h));
QImage img =
scaled.copy(safeSrcX, safeSrcY, dW, h).toImage().convertToFormat(QImage::Format_ARGB32_Premultiplied);
QImage img = scaled.copy(srcX, srcY, dW, h).toImage().convertToFormat(QImage::Format_ARGB32_Premultiplied);
{
QPainter mask(&img);
mask.setCompositionMode(QPainter::CompositionMode_DestinationIn);
@ -370,7 +361,7 @@ void UserInfoPopup::buildUi()
header = new UserInfoHeaderWidget(this);
root->addWidget(header);
// Action area, rebuilt per user
// Action area — rebuilt per user
actionArea = new QWidget(this);
root->addWidget(actionArea);
@ -411,7 +402,7 @@ void UserInfoPopup::buildUi()
root->addWidget(gamesView);
// Close button, positioned absolutely in the top right corner
// Close button — positioned absolutely in the top-right corner
closeBtn = new QPushButton(QStringLiteral("✕"), this);
closeBtn->setFixedSize(22, 22);
closeBtn->setFlat(true);
@ -682,7 +673,7 @@ void UserInfoPopup::showForUser(const QString &userName,
gamesStatus->setText(tr("Loading games…"));
gamesStatus->show();
// Close button, top right corner, above everything
// Close button — top-right corner, above everything
closeBtn->move(PopupWidth - closeBtn->width() - 6, 6);
closeBtn->raise();
@ -711,7 +702,7 @@ void UserInfoPopup::fetchGames()
void UserInfoPopup::onGamesReceived(const Response &r, const QString &forUser)
{
if (forUser != currentUser) {
return; // stale response, different user showing now
return; // stale response — different user showing now
}
gamesModel->clear();
@ -772,4 +763,4 @@ void UserInfoPopup::leaveEvent(QEvent *e)
{
QFrame::leaveEvent(e);
emit mouseLeftPopup();
}
}

View file

@ -155,12 +155,6 @@ void UserListPainter::drawCardArt(QPainter *painter,
return;
}
// CardPictureLoader::getPixmap tags its output with the screen's
// devicePixelRatio on HiDPI displays. Every calculation below is in raw
// pixels, so normalize to 1.0 or the crop renders at 1/dpr scale anchored
// to the top left corner of the row.
art.setDevicePixelRatio(1.0);
const int cardH = rect.height() - 4;
const int totalW = cardRight - rect.left();
const int marginL = qRound(totalW * params.marginPctL);
@ -178,14 +172,11 @@ void UserListPainter::drawCardArt(QPainter *painter,
const int srcX = (scaledW - drawW) / 2;
const int srcY = qRound((scaledH - cardH) * params.verticalOffset);
// Clamp so we never copy outside the pixmap bounds. srcX can go negative
// for stored zoom values below 1, which would silently underfill the
// strip with transparent padding.
const int safeSrcX = qBound(0, srcX, qMax(0, scaledW - drawW));
// Clamp srcY so we never copy outside the pixmap bounds
const int safeSrcY = qBound(0, srcY, qMax(0, scaledH - cardH));
QImage img =
scaled.copy(safeSrcX, safeSrcY, drawW, cardH).toImage().convertToFormat(QImage::Format_ARGB32_Premultiplied);
scaled.copy(srcX, safeSrcY, drawW, cardH).toImage().convertToFormat(QImage::Format_ARGB32_Premultiplied);
{
QPainter mask(&img);
@ -412,4 +403,4 @@ void UserListPainter::paint(QPainter *painter,
drawBadges(painter, option, rect, cardRight, badges, online, style);
painter->restore();
}
}

View file

@ -14,7 +14,6 @@ set(HEADERS
game/server_deck_validation_strategy.h
game/server_game.h
game/server_game_lifecycle_strategy.h
game/server_match_result_strategy.h
game/server_player.h
game/server_spectator.h
server.h

View file

@ -63,9 +63,7 @@ Server_Game::Server_Game(const GameConfig &config, Server_Room *_room)
startingLifeTotal(config.startingLifeTotal), shareDecklistsOnLoad(config.shareDecklistsOnLoad),
inactivityCounter(0), startTimeOfThisGame(0), secondsElapsed(0), firstGameStarted(false),
turnOrderReversed(false), startTime(QDateTime::currentDateTime()), pingClock(nullptr),
deckValidationStrategy(new Server_DefaultDeckValidationStrategy),
lifecycleStrategy(new Server_DefaultLifecycleStrategy), matchResultStrategy(new Server_NullMatchResultStrategy),
gameMutex()
lifecycleStrategy(new Server_DefaultLifecycleStrategy), gameMutex()
{
currentReplay = new GameReplay;
currentReplay->set_replay_id(room->getServer()->getDatabaseInterface()->getNextReplayId());
@ -393,12 +391,10 @@ void Server_Game::stopGameIfFinished()
QMutexLocker locker(&gameMutex);
int playing = 0;
Server_AbstractPlayer *lastPlayer = nullptr;
auto players = getPlayers();
for (auto *player : players.values()) {
if (!player->getConceded()) {
++playing;
lastPlayer = player;
}
}
if (playing > 1) {
@ -414,16 +410,6 @@ void Server_Game::stopGameIfFinished()
sendGameStateToPlayers();
bool matchDecided = matchResultStrategy->onGameFinished(this, playing, lastPlayer);
if (matchDecided) {
locker.unlock();
sendGameEventContainer(prepareGameEvent(Event_GameClosed(), -1));
gameClosed = true;
deleteLater();
return;
}
locker.unlock();
ServerInfo_Game gameInfo;

View file

@ -24,7 +24,6 @@
#include "game_config.h"
#include "server_deck_validation_strategy.h"
#include "server_game_lifecycle_strategy.h"
#include "server_match_result_strategy.h"
#include <QDateTime>
#include <QMap>
@ -87,8 +86,6 @@ private:
QScopedPointer<Server_GameLifecycleStrategy> lifecycleStrategy;
QScopedPointer<Server_MatchResultStrategy> matchResultStrategy;
void createGameStateChangedEvent(Event_GameStateChanged *event,
Server_AbstractParticipant *recipient,
bool omniscient,

View file

@ -1,38 +0,0 @@
#ifndef SERVER_MATCH_RESULT_STRATEGY_H
#define SERVER_MATCH_RESULT_STRATEGY_H
class Server_AbstractPlayer;
class Server_Game;
/**
* @brief Strategy hook invoked when a game has finished to decide the match result.
*
* Subclasses can report the match outcome (e.g. to a tournament backend) and decide
* whether the game should be closed permanently; the default implementation never
* closes the game, preserving the normal return-to-lobby behavior.
*/
class Server_MatchResultStrategy
{
public:
virtual ~Server_MatchResultStrategy() = default;
/**
* @brief Called when a game has finished.
* @return Whether the game has been decided and should be closed.
*/
virtual bool onGameFinished(Server_Game *game, int playing, Server_AbstractPlayer *lastPlayer) = 0;
};
/**
* @brief Default match result strategy that never closes the game.
*/
class Server_NullMatchResultStrategy : public Server_MatchResultStrategy
{
public:
bool onGameFinished(Server_Game *, int, Server_AbstractPlayer *) override
{
return false;
}
};
#endif