From 63a970045a9b487e96af73d8954b23b00495c67f Mon Sep 17 00:00:00 2001 From: tooomm Date: Sun, 23 Aug 2026 11:42:47 +0200 Subject: [PATCH 1/7] [CI] Minimal GitHub Actions permissions (#7165) * add permission block * add permissions block * add permission block * add permissions block * switch order --- .github/workflows/codeql.yml | 2 +- .github/workflows/desktop-lint.yml | 3 +++ .github/workflows/documentation-build.yml | 3 +++ .github/workflows/translations-pull.yml | 4 ++++ .github/workflows/translations-push.yml | 4 ++++ 5 files changed, 15 insertions(+), 1 deletion(-) 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/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: From c42fb6691d556890db3a326ce0df0f4c00fa2f13 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sun, 23 Aug 2026 11:44:59 +0200 Subject: [PATCH 2/7] [Client] Fix user list banner art rendering under display scaling (#7160) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cached card art pixmaps carry the screen device pixel ratio, so both banner painters did their crop math on scaled pixels and blitted the result at raw over logical size, clipping art into its top left quadrant on any display above 100 percent Normalize a local copy to DPR 1 before crop math in UserListPainter and the popup header, clamp srcX and srcY bounds against stored zoom below 1, keep shared cache entries untouched Took 15 minutes Co-authored-by: Lukas Brübach --- .../widgets/server/user/user_info_popup.cpp | 33 ++++++++++++------- .../widgets/server/user/user_list_painter.cpp | 15 +++++++-- 2 files changed, 33 insertions(+), 15 deletions(-) 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 +} From f425dcc93e938fabbb494a183db066841b16ba7a Mon Sep 17 00:00:00 2001 From: tooomm Date: Sun, 23 Aug 2026 12:56:00 +0200 Subject: [PATCH 3/7] Update & add arch/platform labels in release template (#7149) * Update & add arch/platform labels * Update release_template.md --- .ci/release_template.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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! From 976546fbe079b628205b01657338e114ac295af3 Mon Sep 17 00:00:00 2001 From: tooomm Date: Sun, 23 Aug 2026 15:51:33 +0200 Subject: [PATCH 4/7] Docker: Build ARM image natively (#7046) * native builds + merge * naming and ordering * use ninja and cmake build * add ccache and cache mounts * formatting * Update servatrice.cpp * Revert "Update servatrice.cpp" This reverts commit 3acc684135c6db9baa17d00deaceecf8f7079721. * remove ccache again cache mounts are not part of GHA caches from docker action * comments and cleanup Use buildx provided in runner, see https://github.com/actions/runner-images/blob/main/images/ubuntu-slim/ubuntu-slim-Readme.md * more explicit * comments, first pass * ${{ runner.temp }} * $(printf "$GHCR_IMAGE@sha256:%s " *) * follow docker docs for latest and extract short semver from our tags * not so pretty, but allows the easy inspect at the end * add Servatrice name * add links to runner images * comments, second pass * cleanup --- .github/workflows/docker-release.yml | 178 +++++++++++++++++++++------ Dockerfile | 52 ++++---- 2 files changed, 172 insertions(+), 58 deletions(-) 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/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/* From bb0a96984d900736e93c241d851c1c1fdd54965f Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sun, 23 Aug 2026 17:26:15 +0200 Subject: [PATCH 5/7] [Game] Derive playmat sampling window from shared clamped helpers (#7159) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Game] Derive playmat sampling window from shared clamped helpers The crop formula computed the sampled window inline with an unclamped zoom floor, so stored vertical offsets below half of travel were dead and extreme zooms could sample outside the art Remap verticalOffset to place the window top edge within its travel, floor zoom at visible width over min card side with a 4.0 ceiling, clamp pan along the margin sum constant segment, and expose playmatClampedZoom, playmatWindowSide and aspectFitRect so the game renderer and any editor share one geometry model Zoom 1 rendering is bit identical to before Took 7 seconds * Comments. Took 29 minutes --------- Co-authored-by: Lukas Brübach --- .../player/player_graphics_item.cpp | 4 +- .../playmat/playmat_preview_widget.cpp | 4 +- .../interface/widgets/playmat/playmat_utils.h | 117 ++++++++++++++---- 3 files changed, 98 insertions(+), 27 deletions(-) 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 From 25a9e37ff860ecb9d44ab20a10f8a9830ef591f8 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sun, 23 Aug 2026 17:28:37 +0200 Subject: [PATCH 6/7] [Client] Restore stored banner printing when the art dialog opens (#7157) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two gaps made opening and confirming the banner dialog silently switch the banner to the default art of the first local printing. The caller dropped the card provider id when constructing the initial params, and the constructor left the printing combo wherever onCardNameChanged put it, which is always the first printing Pass the provider id through, restore it in the combo when it resolves locally, and keep it verbatim when it does not Took 2 minutes Co-authored-by: Lukas Brübach --- .../server/user/user_card_settings_dialog.cpp | 13 +++++++++++++ .../interface/widgets/server/user/user_info_box.cpp | 1 + 2 files changed, 14 insertions(+) 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(); From e2eb36f19fb26c45f437e86831383897b1dd78b5 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sun, 23 Aug 2026 18:37:15 +0200 Subject: [PATCH 7/7] [Server] Add match result strategy hook (#7131) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Server] Add match result strategy hook Took 7 minutes Took 18 minutes * Rebase. Took 2 minutes Took 13 seconds --------- Co-authored-by: Lukas Brübach --- .../network/server/remote/CMakeLists.txt | 1 + .../server/remote/game/server_game.cpp | 16 +++++++- .../network/server/remote/game/server_game.h | 3 ++ .../game/server_match_result_strategy.h | 38 +++++++++++++++++++ 4 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 libcockatrice_network/libcockatrice/network/server/remote/game/server_match_result_strategy.h 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