Compare commits

...

7 commits

Author SHA1 Message Date
BruebachL
e2eb36f19f
[Server] Add match result strategy hook (#7131)
Some checks are pending
CodeQL / Analyze (cpp) (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
Build Desktop / Configure (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Docker / Servatrice (arm) (push) Waiting to run
Build Docker / Servatrice (x86) (push) Waiting to run
Build Docker / Publish multi-platform Servatrice image (push) Blocked by required conditions
* [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 <Bruebach.Lukas@bdosecurity.de>
2026-08-23 18:37:15 +02:00
BruebachL
25a9e37ff8
[Client] Restore stored banner printing when the art dialog opens (#7157)
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 <Bruebach.Lukas@bdosecurity.de>
2026-08-23 17:28:37 +02:00
BruebachL
bb0a96984d
[Game] Derive playmat sampling window from shared clamped helpers (#7159)
* [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 <Bruebach.Lukas@bdosecurity.de>
2026-08-23 17:26:15 +02:00
tooomm
976546fbe0
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
2026-08-23 15:51:33 +02:00
tooomm
f425dcc93e
Update & add arch/platform labels in release template (#7149)
* Update & add arch/platform labels

* Update release_template.md
2026-08-23 12:56:00 +02:00
BruebachL
c42fb6691d
[Client] Fix user list banner art rendering under display scaling (#7160)
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 <Bruebach.Lukas@bdosecurity.de>
2026-08-23 11:44:59 +02:00
tooomm
63a970045a
[CI] Minimal GitHub Actions permissions (#7165)
* add permission block

* add permissions block

* add permission block

* add permissions block

* switch order
2026-08-23 11:42:47 +02:00
19 changed files with 395 additions and 108 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> <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>
• <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>
<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>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>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 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>
<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>
</pre>

View file

@ -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:

View file

@ -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:

View file

@ -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"

View file

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

View file

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

View file

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

View file

@ -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/*

View file

@ -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);

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 = 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);

View file

@ -6,42 +6,96 @@
#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 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 &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.
*
* 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 &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.
*
* @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();
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

View file

@ -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);

View file

@ -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();

View file

@ -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();
}
}

View file

@ -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();
}
}

View file

@ -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

View file

@ -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;

View file

@ -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 <QDateTime>
#include <QMap>
@ -86,6 +87,8 @@ private:
QScopedPointer<Server_GameLifecycleStrategy> lifecycleStrategy;
QScopedPointer<Server_MatchResultStrategy> matchResultStrategy;
void createGameStateChangedEvent(Event_GameStateChanged *event,
Server_AbstractParticipant *recipient,
bool omniscient,

View file

@ -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