diff --git a/.ci/release_template.md b/.ci/release_template.md
index ac78a193a..23b475150 100644
--- a/.ci/release_template.md
+++ b/.ci/release_template.md
@@ -12,9 +12,9 @@ Available pre-compiled binaries for installation:
• Windows 10+
macOS
- • macOS 15+ Sequoia Apple M
- • macOS 14+ Sonoma Apple M
- • macOS 13+ Ventura Intel
+ • macOS 15+ Sequoia
+ • macOS 14+ Sonoma
+ • macOS 13+ Ventura (x86)
Linux
• Ubuntu 26.04 LTS Resolute Racoon
@@ -24,10 +24,10 @@ Available pre-compiled binaries for installation:
• Fedora 44
• Fedora 43
-We are also packaged in Arch Linux's official extra repository, courtesy of @FFY00.
-General Linux support is available via a flatpak package at Flathub!
+ General Linux support is available via a flatpak package hosted at Flathub (x86 & ARM)!
+ Thanks to courtesy of @FFY00, the app is also available in Arch Linux's official extra repository.
-We provide a Docker image for "Servatrice" in GHCR. You can docker pull it or use our Docker Compose files!
+ We maintain a Docker image for "Servatrice" in GHCR (x86 & ARM). You can docker pull it or use our Docker Compose files!
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
index fee0b34cb..58ca87573 100644
--- a/.github/workflows/codeql.yml
+++ b/.github/workflows/codeql.yml
@@ -9,8 +9,8 @@
name: CodeQL
permissions:
- security-events: write # needed to post results
contents: read
+ security-events: write # needed to post results
on:
push:
diff --git a/.github/workflows/desktop-lint.yml b/.github/workflows/desktop-lint.yml
index 5f31ea59c..93ba79464 100644
--- a/.github/workflows/desktop-lint.yml
+++ b/.github/workflows/desktop-lint.yml
@@ -1,5 +1,8 @@
name: Code Style (C++)
+permissions:
+ contents: read
+
on:
# Push trigger not needed for linting, we do not allow direct pushes to master
pull_request:
diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml
index 5384c9e64..df4fe233c 100644
--- a/.github/workflows/docker-release.yml
+++ b/.github/workflows/docker-release.yml
@@ -1,8 +1,8 @@
-name: Build Docker Image
+name: Build Docker
permissions:
- contents: read
- packages: write
+ contents: read # needed to checkout repo
+ packages: write # needed for interacting with GHCR
on:
push:
@@ -13,7 +13,10 @@ on:
- master
paths:
- '.github/workflows/docker-release.yml'
+ - '.dockerignore'
- 'Dockerfile'
+ - 'docker-compose.yml'
+ - 'docker-compose.yml.windows'
release:
types:
- released # publishing of stable releases
@@ -23,36 +26,38 @@ 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:
- docker:
- name: amd64 & arm64
- if: ${{ github.repository_owner == 'Cockatrice' }}
- runs-on: ubuntu-latest
-
+ # 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 }}
+
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
@@ -61,18 +66,117 @@ jobs:
id: login
uses: docker/login-action@v4
with:
- password: ${{ github.token }}
registry: ghcr.io
username: ${{ github.actor }}
+ password: ${{ github.token }}
- - name: "Build and push Docker image"
+ # Don't push for non-release triggers
+ - name: "Build image"
+ if: steps.login.outcome != 'success'
uses: docker/build-push-action@v7
with:
- annotations: ${{ steps.metadata.outputs.annotations }}
- cache-from: type=gha,scope=servatrice
- cache-to: type=gha,mode=max,scope=servatrice
+ cache-from: type=gha,scope=${{ env.CACHE_SCOPE }}
+ cache-to: type=gha,mode=max,scope=${{ env.CACHE_SCOPE }}
context: .
- labels: ${{ steps.metadata.outputs.labels }}
- platforms: linux/amd64,linux/arm64
- push: ${{ steps.login.outcome == 'success' }}
- tags: ${{ steps.metadata.outputs.tags }}
+ 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"
diff --git a/.github/workflows/documentation-build.yml b/.github/workflows/documentation-build.yml
index 4c06f9ab3..419cbfbfb 100644
--- a/.github/workflows/documentation-build.yml
+++ b/.github/workflows/documentation-build.yml
@@ -1,5 +1,8 @@
name: Generate Docs
+permissions:
+ contents: read # write permission to the destination repo come from 'deploy_key'
+
on:
pull_request:
paths:
diff --git a/.github/workflows/translations-pull.yml b/.github/workflows/translations-pull.yml
index a3db5f86d..71b0b4c22 100644
--- a/.github/workflows/translations-pull.yml
+++ b/.github/workflows/translations-pull.yml
@@ -1,5 +1,9 @@
name: Update Translations
+permissions:
+ contents: read
+ pull-requests: write
+
on:
pull_request:
paths:
diff --git a/.github/workflows/translations-push.yml b/.github/workflows/translations-push.yml
index c4d3f61fb..41a7aef40 100644
--- a/.github/workflows/translations-push.yml
+++ b/.github/workflows/translations-push.yml
@@ -1,5 +1,9 @@
name: Update Translation Source
+permissions:
+ contents: read
+ pull-requests: write
+
on:
pull_request:
paths:
diff --git a/Dockerfile b/Dockerfile
index 7c5c773c9..382309d47 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -3,35 +3,45 @@ FROM ubuntu:26.04 AS build
ARG DEBIAN_FRONTEND=noninteractive
-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
+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
WORKDIR /src
COPY . .
-RUN mkdir build && cd build && \
- cmake .. -DWITH_SERVER=1 -DWITH_CLIENT=0 -DWITH_ORACLE=0 && \
- make -j$(nproc) && \
- make install
+RUN cmake \
+ -S . \
+ -B build \
+ -G Ninja \
+ -DWITH_CLIENT=0 \
+ -DWITH_ORACLE=0 \
+ -DWITH_SERVER=1 \
+ && cmake --build build \
+ && cmake --install build
# -------- Runtime Stage (clean) --------
FROM ubuntu:26.04
-RUN apt-get update && apt-get install -y --no-install-recommends \
- libprotobuf32t64 \
- libqt6sql6-mysql \
- libqt6websockets6 \
+ARG DEBIAN_FRONTEND=noninteractive
+
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends \
+ libprotobuf32t64 \
+ libqt6sql6-mysql \
+ libqt6websockets6 \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
diff --git a/cockatrice/src/game_graphics/player/player_graphics_item.cpp b/cockatrice/src/game_graphics/player/player_graphics_item.cpp
index 026e00588..8bf2703e1 100644
--- a/cockatrice/src/game_graphics/player/player_graphics_item.cpp
+++ b/cockatrice/src/game_graphics/player/player_graphics_item.cpp
@@ -187,8 +187,8 @@ void PlayerGraphicsItem::paint(QPainter *painter, const QStyleOptionGraphicsItem
QRectF combinedArea(combinedLeft, combinedTop, combinedRight - combinedLeft, combinedBottom - combinedTop);
- const QRectF srcRect = computeArtSourceRect(playmatPixmap.size(), playmatParams);
- const QRectF dstRect = coverFitRect(combinedArea, srcRect.size());
+ const QRectF srcRect = PlaymatUtils::computeArtSourceRect(playmatPixmap.size(), playmatParams);
+ const QRectF dstRect = PlaymatUtils::coverFitRect(combinedArea, srcRect.size());
painter->save();
painter->setClipRect(combinedArea);
diff --git a/cockatrice/src/interface/widgets/playmat/playmat_preview_widget.cpp b/cockatrice/src/interface/widgets/playmat/playmat_preview_widget.cpp
index dc3afc2cd..52f21f714 100644
--- a/cockatrice/src/interface/widgets/playmat/playmat_preview_widget.cpp
+++ b/cockatrice/src/interface/widgets/playmat/playmat_preview_widget.cpp
@@ -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 = computeArtSourceRect(sourcePixmap.size(), params);
- const QRectF dstRect = coverFitRect(playArea, srcRect.size());
+ const QRectF srcRect = PlaymatUtils::computeArtSourceRect(sourcePixmap.size(), params);
+ const QRectF dstRect = PlaymatUtils::coverFitRect(playArea, srcRect.size());
painter.setClipRect(playArea.toRect());
painter.drawPixmap(dstRect, sourcePixmap, srcRect);
diff --git a/cockatrice/src/interface/widgets/playmat/playmat_utils.h b/cockatrice/src/interface/widgets/playmat/playmat_utils.h
index 9ab8190b3..0691a9637 100644
--- a/cockatrice/src/interface/widgets/playmat/playmat_utils.h
+++ b/cockatrice/src/interface/widgets/playmat/playmat_utils.h
@@ -6,42 +6,96 @@
#include
#include
+namespace PlaymatUtils
+{
+
+/** @brief Upper bound for zooming into the playmat art. */
+constexpr qreal MAX_ZOOM = 4.0;
+
/**
- * @brief Computes the source region of the full-resolution card image to use as a playmat.
+ * @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 ¶ms)
+{
+ 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.
*
- * 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.
+ * 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 ¶ms)
+{
+ const qreal minDim = qMin(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 ¶ms)
+{
+ 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.
*
* @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 ¶ms)
{
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
+ // 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 vCenter = params.verticalOffset * srcH;
- qreal srcY = vCenter - visibleH / 2.0;
- srcY = qBound(0.0, srcY, srcH - visibleH);
+ // 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);
- // 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;
+ // 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));
- return QRectF(zoomedX, zoomedY, zoomedW, zoomedH);
+ return QRectF(x, y, side, side);
}
/**
@@ -49,7 +103,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)
@@ -66,4 +120,21 @@ 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
diff --git a/cockatrice/src/interface/widgets/server/user/user_card_settings_dialog.cpp b/cockatrice/src/interface/widgets/server/user/user_card_settings_dialog.cpp
index ca32edaf1..1d76b2c67 100644
--- a/cockatrice/src/interface/widgets/server/user/user_card_settings_dialog.cpp
+++ b/cockatrice/src/interface/widgets/server/user/user_card_settings_dialog.cpp
@@ -112,6 +112,19 @@ 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);
diff --git a/cockatrice/src/interface/widgets/server/user/user_info_box.cpp b/cockatrice/src/interface/widgets/server/user/user_info_box.cpp
index 416cd42e3..875bdfb05 100644
--- a/cockatrice/src/interface/widgets/server/user/user_info_box.cpp
+++ b/cockatrice/src/interface/widgets/server/user/user_info_box.cpp
@@ -319,6 +319,7 @@ 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();
diff --git a/cockatrice/src/interface/widgets/server/user/user_info_popup.cpp b/cockatrice/src/interface/widgets/server/user/user_info_popup.cpp
index 014d3d4c3..f6f34a6a5 100644
--- a/cockatrice/src/interface/widgets/server/user/user_info_popup.cpp
+++ b/cockatrice/src/interface/widgets/server/user/user_info_popup.cpp
@@ -189,22 +189,31 @@ 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) / cardArt.width(), double(h) / cardArt.height());
+ const double base = qMax(double(dW) / art.width(), double(h) / art.height());
const double scale = base * params.zoom;
- const int sW = qRound(cardArt.width() * scale);
- const int sH = qRound(cardArt.height() * scale);
+ const int sW = qRound(art.width() * scale);
+ const int sH = qRound(art.height() * scale);
- 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));
+ 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));
- QImage img = scaled.copy(srcX, srcY, dW, h).toImage().convertToFormat(QImage::Format_ARGB32_Premultiplied);
+ QImage img =
+ scaled.copy(safeSrcX, safeSrcY, dW, h).toImage().convertToFormat(QImage::Format_ARGB32_Premultiplied);
{
QPainter mask(&img);
mask.setCompositionMode(QPainter::CompositionMode_DestinationIn);
@@ -361,7 +370,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);
@@ -402,7 +411,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);
@@ -673,7 +682,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();
@@ -702,7 +711,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();
@@ -763,4 +772,4 @@ void UserInfoPopup::leaveEvent(QEvent *e)
{
QFrame::leaveEvent(e);
emit mouseLeftPopup();
-}
\ No newline at end of file
+}
diff --git a/cockatrice/src/interface/widgets/server/user/user_list_painter.cpp b/cockatrice/src/interface/widgets/server/user/user_list_painter.cpp
index 5a4723065..82f2887c8 100644
--- a/cockatrice/src/interface/widgets/server/user/user_list_painter.cpp
+++ b/cockatrice/src/interface/widgets/server/user/user_list_painter.cpp
@@ -155,6 +155,12 @@ 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);
@@ -172,11 +178,14 @@ void UserListPainter::drawCardArt(QPainter *painter,
const int srcX = (scaledW - drawW) / 2;
const int srcY = qRound((scaledH - cardH) * params.verticalOffset);
- // Clamp srcY so we never copy outside the pixmap bounds
+ // 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));
const int safeSrcY = qBound(0, srcY, qMax(0, scaledH - cardH));
QImage img =
- scaled.copy(srcX, safeSrcY, drawW, cardH).toImage().convertToFormat(QImage::Format_ARGB32_Premultiplied);
+ scaled.copy(safeSrcX, safeSrcY, drawW, cardH).toImage().convertToFormat(QImage::Format_ARGB32_Premultiplied);
{
QPainter mask(&img);
@@ -403,4 +412,4 @@ void UserListPainter::paint(QPainter *painter,
drawBadges(painter, option, rect, cardRight, badges, online, style);
painter->restore();
-}
\ No newline at end of file
+}
diff --git a/libcockatrice_network/libcockatrice/network/server/remote/CMakeLists.txt b/libcockatrice_network/libcockatrice/network/server/remote/CMakeLists.txt
index 8389fcf10..60760b5bd 100644
--- a/libcockatrice_network/libcockatrice/network/server/remote/CMakeLists.txt
+++ b/libcockatrice_network/libcockatrice/network/server/remote/CMakeLists.txt
@@ -14,6 +14,7 @@ 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
diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp
index 425fefcb3..43209e994 100644
--- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp
+++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp
@@ -63,7 +63,9 @@ 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),
- lifecycleStrategy(new Server_DefaultLifecycleStrategy), gameMutex()
+ deckValidationStrategy(new Server_DefaultDeckValidationStrategy),
+ lifecycleStrategy(new Server_DefaultLifecycleStrategy), matchResultStrategy(new Server_NullMatchResultStrategy),
+ gameMutex()
{
currentReplay = new GameReplay;
currentReplay->set_replay_id(room->getServer()->getDatabaseInterface()->getNextReplayId());
@@ -391,10 +393,12 @@ 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) {
@@ -410,6 +414,16 @@ 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;
diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.h b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.h
index 8ed0769a6..1b9f651bd 100644
--- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.h
+++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.h
@@ -24,6 +24,7 @@
#include "game_config.h"
#include "server_deck_validation_strategy.h"
#include "server_game_lifecycle_strategy.h"
+#include "server_match_result_strategy.h"
#include
#include
@@ -86,6 +87,8 @@ private:
QScopedPointer lifecycleStrategy;
+ QScopedPointer matchResultStrategy;
+
void createGameStateChangedEvent(Event_GameStateChanged *event,
Server_AbstractParticipant *recipient,
bool omniscient,
diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_match_result_strategy.h b/libcockatrice_network/libcockatrice/network/server/remote/game/server_match_result_strategy.h
new file mode 100644
index 000000000..51c696db1
--- /dev/null
+++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_match_result_strategy.h
@@ -0,0 +1,38 @@
+#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