Compare commits

...

8 commits

Author SHA1 Message Date
Bryce!
3a656c9c4a
Preserve installed card databases when Oracle refresh fails (#7739)
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 / vcpkg baseline (push) Waiting to run
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 sanitize (push) Blocked by required conditions
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 cover (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 27 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / Prune caches (push) Waiting to run
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
* Preserve installed card databases when Oracle refresh fails

* Fix the Oracle refresh verdict and cover the parser write path

A background refresh that already installed cards.xml exited 1 when the
token save afterwards failed, so the client kept its old in-memory
database and reported the update as failed. Return the same verdict
main() already computes: a reject after the database was written is a
successful update.

Cover the writer Oracle actually uses: add a short-write death test for
CockatriceXml4Parser::saveToFile, which reverted to QFile would truncate
an installed database without any test noticing.

Route a failed background token download through reject() instead of a
modal box on a hidden wizard, which left the client waiting with no
timeout and blocked later updates. Test the first non-space byte in
place rather than copying the payload, and report an import refusal with
the importer's own message.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-11-04 17:33:49 +01:00
Bryce!
e558c0dffa
[Banner] Keep horizontal user banner crops inside the artwork (#7742) 2026-11-04 17:08:54 +01:00
Bryce!
d944273335
[Home] Keep actions bounded and reachable at every window height (#7747) 2026-11-04 17:06:58 +01:00
BruebachL
5ea16c805f
[Server/Client/Protocol] Shuffle or reorder player seats before a game starts (#7398)
* [Protocol] Add shuffle players option and player order command/event

Adds a 'Shuffle players at start' option to game creation plus a local
player id on the game state so clients can reconcile seat changes, and a
SET_PLAYER_ORDER command (1206) with a PLAYER_ORDER_CHANGED event (2023)
so the game host can set or randomize the seating order.

Took 6 minutes

* [Server] Shuffle and reorder player seats before a game starts

Auto-shuffles the seats at game start when enabled (now for games with
two or more players instead of three or more) and handles the new
SET_PLAYER_ORDER command so the game host or a moderator can reorder or
randomize the seats before the game starts. Re-seating re-keys the
player ids while keeping the per-user game/seat routing and persistent
player records in sync, then broadcasts the new order to everyone.

* [Client] Add shuffle players option and turn order dialog

Adds the 'Shuffle players at start' checkbox to game creation and a
host-only 'Set turn order...' action that opens a dialog to reorder or
randomize the seats before the game starts. Seating changes are shown in
the game's message log.

* [Client] Move the turn order dialog strings into retranslateUi

Follows the DlgInviteToGame convention so the dialog can be retranslated
instead of freezing its strings at construction time.

* [Server] Keep player ids stable across seat shuffles

* [Client] Lay out table and turn order dialog in seat order

* [Server] Send game-joined player list in seat order

* [Client] Reword the shuffle option to match its behaviour

* [Client] Address the seat-order review comments

- Give the game setup checkboxes a row of their own so they no longer
  collide in the create-game dialog
- Rename DlgTurnOrder::randomize() to isRandomizeRequested()
- Document what the ids in GameState::seatOrder mean
- Keep "Set turn order..." in sync with the game: hidden for non-hosts
  and once the game has started, re-shown when the game ends or hosting
  moves to the local player (new GameState::hostChanged signal)

* [Client] Do not preview a randomized turn order

Randomizing is a request to the server, not a local shuffle. Shuffling the
list locally meant the dialog showed an order the server then discarded, and
let the host re-open it until a favourable order appeared. The dialog now
switches to a mode that says the server will randomize and toggles back to the
manual list on request.

* [Client] Let moderators set the turn order

The server accepts SET_PLAYER_ORDER from the host or a moderator, but the
client hid the action from everyone except the host, making the moderator
branch unreachable. Visibility and the handler now share canSetTurnOrder().

* [Server] Rely on the player order event instead of a full state resend

cmdSetPlayerOrder re-sent the whole game state after reseating, so every
client reloaded its deck view mid-lobby for no reason. The new seating reaches
clients through the Event_PlayerOrderChanged that reseatPlayers broadcasts.

* [Client] Show random turn order in the game selector

The restrictions column listed "open decklists" but not the new seating
option, so a joining player had no way to see that seats will be randomized
each game. Game info already shows the disabled checkbox.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-11-04 15:44:50 +01:00
Bryce!
ca2301bc96
Fix deck export and mana group ordering (#7729) 2026-11-04 15:43:38 +01:00
tooomm
2056043104
[CI] Update macOS runner (keep same compile targets) (#7331)
* update/add eol dates

* use new macOS images

* [CI] Retire the old macOS ccache keys from the live list

prune-caches deletes any ccache-* entry whose key is not in
LIVE_CCACHE_KEYS, so the three keys this PR renames would be dropped on
the next master run and every macOS leg would start cold.

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-11-04 15:35:45 +01:00
Bryce!
67330ead5a
Embed bundled resources without compiling large C++ arrays [skip ci] (#7730) 2026-11-04 15:27:44 +01:00
BruebachL
7ca697348b
[CI] Give ccache room to hold a configuration (#7667)
* [CI] Re-assert the ccache timestamp sloppiness after configure

CMakeLists.txt sets sloppiness=pch_defines,time_macros at configure
time, and ccache --set-config replaces the whole list rather than
extending it. Setting the four entries before cmake would therefore
leave the build with only two, so the entries CI needs have to be
written after the configure step and before the build.

The mtime and ctime entries cover files created during the build
itself: moc/uic/rcc output and protobuf headers are generated after
ccache starts, so without them every TU that includes one is read as
a race and never cached. Checked-out sources are older than the
compile that follows them and do not trip the check.

* [Build] Keep risky ccache sloppiness out of the global config

USE_CCACHE defaults to ON, so CMakeLists.txt runs on a developer
machine, and ccache --set-config writes the user's global ccache.conf.
The include_file_mtime/include_file_ctime entries turn off the
modified-during-compile check for every project on the machine, not
only this one, so they belong in CI (compile.sh) and not here. The two
entries PCH caching needs stay.

* [CI] Size each compiler cache against the cache quota

The 600M bound sat below what a leg writes: ccache spent each build
deleting the entries the next run asked for, with 2624-3720 cleanup
passes and about 8% of calls served in the runs inspected on
2026-11-04.

Raising it needs room first. The repository's 10 GB quota currently
holds 1.1 GB of CodeQL trap, 1.1 GB of vcpkg, 0.7 GB of Qt and 2.1 GB
of docker buildkit layers. Two of those are now cheap to reclaim:

- docker-release wrote its layer cache with mode=max, but the build
  stage sits behind COPY . ., so its compile layer is invalidated by
  every source change and was never read back. mode=min keeps only the
  final image stage.
- the five nightly-only legs kept their own compiler caches, 600M
  each, that no pull request ever reads. They now keep none, and the
  prune job retires their leftover keys immediately instead of waiting
  for GitHub's whole-entry LRU eviction to pick them.

That funds per-leg bounds: 700M for one configuration, 850M for the
Debian 13 release that also runs the suite, 900M for Fedora 44's
Debug and Release, 800M for the instrumented leg, 400M for the
server-only leg, and 700M for each macOS leg. 6.45G of ccache plus the
3.2G of fixed entries leaves about 350M under the quota.

The prune step also covers the buildkit entries by age now, so the
mode=max generation of layer blobs is reclaimed instead of living
forever: a layer a build needs is downloaded and its date refreshed,
and dropping one that an index still lists only costs docker-release a
rebuilt layer, which gates nothing.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-11-04 10:06:56 +01:00
73 changed files with 1984 additions and 195 deletions

View file

@ -185,9 +185,6 @@ if [[ $MAKE_COVERAGE ]]; then
fi
if [[ $USE_CCACHE ]]; then
flags+=("-DUSE_CCACHE=1")
# PCH-aware caching is required or ccache refuses to cache any TU that
# consumes a precompiled header, silently recompiling everything on every run.
ccache --set-config sloppiness=pch_defines,time_macros
if [[ $CCACHE_SIZE ]]; then
# note, this setting persists after running the script
ccache --max-size "$CCACHE_SIZE"
@ -378,6 +375,21 @@ echo "Running cmake with flags: ${flags[*]}"
cmake .. "${flags[@]}"
echo "::endgroup::"
if [[ $USE_CCACHE ]]; then
# PCH-aware caching is required or ccache refuses to cache any TU that consumes a
# precompiled header, silently recompiling everything on every run. The mtime and ctime
# entries cover files created during the build itself: the moc/uic/rcc output and the
# protobuf headers are generated after ccache starts, so without them every TU including
# one is read as a race and never cached. Checked-out sources are older than the compile
# that follows them and do not trip the check.
#
# This has to run after configure: CMakeLists.txt sets sloppiness=pch_defines,time_macros
# at configure time, and --set-config replaces the whole list rather than extending it, so
# setting it earlier would leave the build below with two entries. Re-asserting all four
# here is what actually reaches the compiler; the order is the point.
ccache --set-config sloppiness=pch_defines,time_macros,include_file_mtime,include_file_ctime
fi
echo "::group::Build project"
cpuinfo
echo "Running cmake --build with flags: ${buildflags[*]}"

View file

@ -157,11 +157,13 @@ jobs:
package: DEB
server_only: yes
test: skip
ccache_size: 400M # server only, no client to compile
- distro: Debian
version: 13
package: DEB
ccache_size: 850M # the Release build plus the suite it runs
- distro: Fedora
version: 44
@ -172,6 +174,7 @@ jobs:
# only the Debug configuration passes -Werror, so these two are the legs where a
# new warning in either compiler still fails the build.
debug_test: yes
ccache_size: 900M # Debug for the suite and Release for the package
- distro: Ubuntu
version: 26.04
@ -193,6 +196,7 @@ jobs:
package: skip
sanitize: yes
ccache_size: 800M # instrumented objects are larger
name: ${{ matrix.distro }} ${{ matrix.version }}${{ matrix.variant && format(' {0}', matrix.variant) || '' }}
needs: configure
@ -205,7 +209,27 @@ jobs:
env:
CACHE: ${{ github.workspace }}/.cache/${{ matrix.distro }}${{ matrix.version }}${{ matrix.variant }} # directory for caching docker image and ccache
CCACHE_EVICTION_AGE: 7d
CCACHE_SIZE: 600M # space of all repo is 10Gi: https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#usage-limits-and-eviction-policy
# The bound is sized against the repository's 10 GB cache quota
# (https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#usage-limits-and-eviction-policy),
# not against what one leg would like. The quota currently holds about 1.1 GB of
# CodeQL trap, 1.1 GB of vcpkg, 0.7 GB of Qt and 2.1 GB of docker buildkit layers
# (mode=min in docker-release.yml, added here, brings the last one down to roughly
# 0.3 GB - the one input here that is an expectation rather than a measurement, since
# mode=min still writes one layer per pushed binary) plus the compiler caches of the nine legs a pull request runs. The five
# nightly-only legs keep no compiler cache (see PERSIST_CCACHE below), which is what
# makes these per-leg bounds affordable. Accounting for all of it: 6.45G of ccache
# bounds + 3.2G of the fixed entries = 9.65G against the 10G quota.
#
# A configuration needs roughly 500-850 MB on Linux, so the old uniform 600M bound sat
# below what a leg writes: ccache spent each build deleting the entries the next run
# asked for (measured: 2624-3720 cleanup passes per build, 8% of calls served). Each
# leg states the size it needs instead: one configuration for most, two for Fedora 44
# (Debug for the suite, Release for the package), the server alone for Servatrice.
# Raising these further means caching fewer legs, since GitHub evicts a whole entry
# when the total is exceeded.
#
# Every key listed in LIVE_CCACHE_KEYS in prune-caches below must match this matrix.
CCACHE_SIZE: ${{ matrix.ccache_size || '700M' }}
CMAKE_GENERATOR: 'Ninja'
NAME: ${{ matrix.distro }}${{ matrix.version }}
IMAGE_SUFFIX: ${{ matrix.variant }}
@ -218,6 +242,11 @@ jobs:
# also package, so testing and packaging cost one compile instead of two.
DEBUG_TEST: ${{ matrix.sanitize == 'yes' || matrix.coverage == 'yes' || matrix.debug_test == 'yes' }}
RUN_TESTS: ${{ matrix.test != 'skip' && !(matrix.sanitize == 'yes' || matrix.coverage == 'yes' || matrix.debug_test == 'yes') }}
# Whether this leg restores and saves its compiler cache. Every leg a pull request
# runs keeps one; the nightly-only legs do not, so that the quota is spent on the
# builds that review a change. A tag build runs both sets, and the nightly legs are
# cold there - accepted, because a tag is rare and only the pull-request legs gate.
PERSIST_CCACHE: 'true'
# Shared with build-linux-nightly so the two leg sets run the same steps.
steps: &linux-steps
@ -226,6 +255,7 @@ jobs:
- name: "Restore compiler cache (ccache)"
id: restore_ccache
if: env.PERSIST_CCACHE == 'true'
uses: actions/cache/restore@v6
with:
key: ccache-${{ matrix.distro }}${{ matrix.version }}${{ matrix.variant }}
@ -371,7 +401,7 @@ jobs:
# Delete used cache to emulate a ccache update. See https://github.com/actions/cache/issues/342
- name: "Delete remote compiler cache (ccache)"
if: github.ref == 'refs/heads/master' && steps.restore_ccache.outputs.cache-hit
if: github.ref == 'refs/heads/master' && env.PERSIST_CCACHE == 'true' && steps.restore_ccache.outputs.cache-hit
continue-on-error: true
env:
CACHE_PRIMARY_KEY: ${{ steps.restore_ccache.outputs.cache-primary-key }}
@ -382,7 +412,7 @@ jobs:
fi
- name: "Save updated compiler cache (ccache)"
if: github.ref == 'refs/heads/master'
if: github.ref == 'refs/heads/master' && env.PERSIST_CCACHE == 'true'
uses: actions/cache/save@v6
with:
key: ${{ steps.restore_ccache.outputs.cache-primary-key }}
@ -479,7 +509,12 @@ jobs:
env:
CACHE: ${{ github.workspace }}/.cache/${{ matrix.distro }}${{ matrix.version }}${{ matrix.variant }} # directory for caching docker image and ccache
CCACHE_EVICTION_AGE: 7d
CCACHE_SIZE: 600M # space of all repo is 10Gi: https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#usage-limits-and-eviction-policy
# These legs do not keep a compiler cache: they run once a day (and on tags) and gate
# nothing, so their share of the 10 GB quota goes to the pull-request legs instead.
# A cold nightly build costs minutes on a runner nobody waits for; a cold pull request
# build costs a review. CCACHE_SIZE is still passed so ccache trims its scratch dir.
PERSIST_CCACHE: 'false'
CCACHE_SIZE: 600M
CMAKE_GENERATOR: 'Ninja'
NAME: ${{ matrix.distro }}${{ matrix.version }}
IMAGE_SUFFIX: ${{ matrix.variant }}
@ -491,8 +526,8 @@ jobs:
matrix:
include:
- os: macOS
target: 14 # EOL 2026-??
runner: macos-26 # https://github.com/actions/runner-images/blob/main/images/macos/macos-26-arm64-Readme.md
target: 14 # EOL 2026-09-14
runner: xcode-27 # https://github.com/actions/runner-images/blob/main/images/macos/xcode-27-arm64-Readme.md
ccache_eviction_age: 7d
cmake_generator: Ninja
@ -507,11 +542,10 @@ jobs:
vcpkg_deployment_target: 14
vcpkg_scope: 'vcpkg/macos14-arm64-xcode26.6/'
vcpkg_upload: 1 # only one leg per vcpkg_scope hands its archives over
xcode: "26.6"
- os: macOS
target: 15
runner: macos-26 # https://github.com/actions/runner-images/blob/main/images/macos/macos-26-arm64-Readme.md
target: 15 # EOL 2027-??
runner: xcode-27 # https://github.com/actions/runner-images/blob/main/images/macos/xcode-27-arm64-Readme.md
ccache_eviction_age: 7d
cmake_generator: Ninja
@ -525,11 +559,10 @@ jobs:
use_ccache: 1
vcpkg_deployment_target: 14
vcpkg_scope: 'vcpkg/macos14-arm64-xcode26.6/'
xcode: "26.6"
- os: macOS
target: 26
runner: macos-26 # https://github.com/actions/runner-images/blob/main/images/macos/macos-26-arm64-Readme.md
target: 27
runner: xcode-27 # https://github.com/actions/runner-images/blob/main/images/macos/xcode-27-arm64-Readme.md
ccache_eviction_age: 7d
cmake_generator: Ninja
@ -540,7 +573,6 @@ jobs:
use_ccache: 1
vcpkg_deployment_target: 14
vcpkg_scope: 'vcpkg/macos14-arm64-xcode26.6/'
xcode: "26.6"
- os: Windows
target: 10
@ -571,7 +603,13 @@ jobs:
timeout-minutes: 100
env:
CCACHE_DIR: ${{ github.workspace }}/.cache/
CCACHE_SIZE: 600M # space of all repo is 10Gi: https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#usage-limits-and-eviction-policy
# macOS configurations compile about twice the TUs of a Linux one, so each of these
# three legs gets a bound above what it has been measured to write. The four macOS
# legs used to be bounded at 600M from the same quota as the Linux ones; the three
# pull-request legs now get 700M each while the nightly Intel leg keeps none, and
# that is what fits (see CCACHE_SIZE in build-linux for the full arithmetic).
CCACHE_SIZE: 700M
PERSIST_CCACHE: 'true'
# Shared with build-vcpkg-nightly so the two leg sets run the same steps.
steps: &vcpkg-steps
@ -591,11 +629,11 @@ jobs:
run: brew install ccache
- name: "[macOS] Restore compiler cache (ccache)"
if: matrix.os == 'macOS' && matrix.use_ccache == 1
if: matrix.os == 'macOS' && matrix.use_ccache == 1 && env.PERSIST_CCACHE == 'true'
id: restore_ccache
uses: actions/cache/restore@v6
with:
key: ccache-${{ matrix.runner }}_${{ matrix.override_target }}-Xcode${{ matrix.xcode }}
key: ccache-${{ matrix.os }}${{ matrix.override_target }}-${{ matrix.runner }}
path: ${{ env.CCACHE_DIR }}
- name: "[macOS] Install aqtinstall"
@ -710,7 +748,6 @@ jobs:
# configure.outputs.tag is set for ANY tag, so beta (Development) tags get the DSN
# and the symbol upload below just like full releases.
COCKATRICE_SENTRY_DSN: ${{ needs.configure.outputs.tag != null && secrets.SENTRY_DSN || '' }}
DEVELOPER_DIR: '/Applications/Xcode_${{ matrix.xcode }}.app/Contents/Developer'
GITHUB_TOKEN: ${{ github.token }} # needed for vcpkg dependency graph updates, see VCPKG_FEATURE_FLAGS
MACOS_CERTIFICATE: ${{ secrets.PROD_MACOS_CERTIFICATE }}
MACOS_CERTIFICATE_NAME: ${{ secrets.PROD_MACOS_CERTIFICATE_NAME }}
@ -922,7 +959,7 @@ jobs:
# Delete used cache to emulate a ccache update. See https://github.com/actions/cache/issues/342
- name: "[macOS] Delete remote compiler cache (ccache)"
if: matrix.os == 'macOS' && matrix.use_ccache == 1 && github.ref == 'refs/heads/master' && steps.restore_ccache.outputs.cache-hit
if: matrix.os == 'macOS' && matrix.use_ccache == 1 && env.PERSIST_CCACHE == 'true' && github.ref == 'refs/heads/master' && steps.restore_ccache.outputs.cache-hit
continue-on-error: true
env:
CACHE_PRIMARY_KEY: ${{ steps.restore_ccache.outputs.cache-primary-key }}
@ -933,7 +970,7 @@ jobs:
fi
- name: "[macOS] Cache updated compiler cache (ccache)"
if: matrix.os == 'macOS' && matrix.use_ccache == 1 && github.ref == 'refs/heads/master'
if: matrix.os == 'macOS' && matrix.use_ccache == 1 && env.PERSIST_CCACHE == 'true' && github.ref == 'refs/heads/master'
uses: actions/cache/save@v6
with:
key: ${{ steps.restore_ccache.outputs.cache-primary-key }}
@ -1071,7 +1108,6 @@ jobs:
use_ccache: 1
vcpkg_scope: 'vcpkg/macos13-x64-xcode26.3/'
vcpkg_upload: 1 # only one leg per vcpkg_scope hands its archives over
xcode: "26.3"
name: ${{ matrix.os }} ${{ matrix.target }}${{ matrix.soc == 'Intel' && ' Intel' || '' }}${{ matrix.type == 'Debug' && ' Debug' || '' }}
needs: configure
@ -1082,7 +1118,10 @@ jobs:
timeout-minutes: 100
env:
CCACHE_DIR: ${{ github.workspace }}/.cache/
CCACHE_SIZE: 600M # space of all repo is 10Gi: https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#usage-limits-and-eviction-policy
# Like build-linux-nightly: this leg keeps no compiler cache, so the quota stays with
# the pull-request legs (see PERSIST_CCACHE there).
PERSIST_CCACHE: 'false'
CCACHE_SIZE: 600M
steps: *vcpkg-steps
prune-caches:
@ -1093,12 +1132,23 @@ jobs:
continue-on-error: true # freeing space must never fail a build
steps:
# The repository holds 10Gi of cache against entries that want more, and GitHub
# evicts the least recently used entry whenever that is exceeded. The vcpkg
# entries are the reason that never happens: the restore above touches every
# entry under the prefix on every run, so a dead ABI generation keeps its access
# date fresh forever and age based eviction can never reach it. Deleting by age
# alone cannot fix that, only knowing which scope an entry belongs to can.
# The repository holds 10 GB of cache against entries that want more, and GitHub evicts
# the least recently used entry whenever that is exceeded. The vcpkg entries are the
# reason that never happens: the restore above touches every entry under the prefix on
# every run, so a dead ABI generation keeps its access date fresh forever and age based
# eviction can never reach it. Deleting by age alone cannot fix that, only knowing which
# scope an entry belongs to can. The same is true of a compiler cache of a leg that no
# longer keeps one: its restore would keep touching it, so the live key list below is
# what retires it.
#
# docker-release's image builds keep their layer cache in the same quota: one blob per
# layer, keyed buildkit-blob-1-<digest>, plus one index-<scope>-1-<hash> entry per image
# scope that lists them. They are written with mode=min, so only the final image stage
# is kept and the set stays small. A layer a build actually needs is downloaded, which
# refreshes its access date, so an old date means the builds have stopped using it and
# age is the right test. Dropping a blob an index still lists costs the next image build
# one rebuilt layer; docker-release gates nothing, so that is cheaper than keeping the
# entries forever.
- name: "Delete unusable cache entries"
shell: bash
env:
@ -1107,25 +1157,39 @@ jobs:
# matrices above, or the entries of a scope that is still in use get deleted
# on the next run.
LIVE_VCPKG_SCOPES: 'vcpkg/macos13-x64-xcode26.3/ vcpkg/macos14-arm64-xcode26.6/ vcpkg/windows10-x64-vs2026/'
# Must list the ccache key of every leg that keeps a compiler cache
# (PERSIST_CCACHE in build-linux and build-vcpkg), or a live entry is deleted.
# The nightly legs keep none, so their leftover entries are reclaimed here
# instead of waiting out the age rule.
LIVE_CCACHE_KEYS: 'ccache-Arch ccache-Servatrice_Debian12 ccache-Debian13 ccache-Fedora44 ccache-Ubuntu26.04 ccache-Ubuntu26.04sanitize ccache-macOS14-xcode-27 ccache-macOS15-xcode-27 ccache-macOS-xcode-27'
run: |
gh api --paginate "repos/$GITHUB_REPOSITORY/actions/caches?per_page=100" \
--jq '.actions_caches[] | [.id, .ref, .key, .last_accessed_at] | @tsv' >"$RUNNER_TEMP/caches.tsv"
cutoff="$(( $(date -u +%s) - 7 * 24 * 3600 ))"
live=" ${LIVE_VCPKG_SCOPES} "
live_ccache=" ${LIVE_CCACHE_KEYS} "
deleted=0
while IFS=$'\t' read -r id ref key accessed; do
[[ -n $id ]] || continue
# Only ever touch the key shapes this workflow creates, so that an entry of
# some other workflow can never be deleted by accident
# Only ever touch the key shapes our workflows create (buildkit-* and index-*
# come from docker-release.yml), so that an entry of some other workflow can
# never be deleted by accident
case "$key" in
vcpkg/* | ccache-* | Qt*) ;;
vcpkg/* | ccache-* | Qt* | buildkit-* | index-*) ;;
*) continue ;;
esac
reason=""
if [[ $ref != refs/heads/master ]]; then
reason="scoped to $ref"
elif [[ $key == ccache-* ]]; then
# An exact token compare: ccache-Ubuntu26.04 is a prefix of
# ccache-Ubuntu26.04sanitize, and both keys are live
if [[ $live_ccache != *" $key "* ]]; then
reason="no pull-request leg keeps it"
fi
elif [[ $key == vcpkg/* ]]; then
# "<scope>/<abi hash>", cut the hash off the end to get the scope
scope="vcpkg/${key#vcpkg/}"

View file

@ -84,12 +84,18 @@ jobs:
password: ${{ github.token }}
# Don't push for non-publishing triggers
#
# The layer cache shares the repository's 10 GB Actions cache quota with the compiler
# caches (see CCACHE_SIZE in desktop-build.yml). mode=min keeps only the final image
# stage: the build stage sits behind `COPY . .`, so its compile layer is invalidated by
# every source change and was never read back, yet it was the bulk of the ~2 GB this
# cache held. The final runtime stage is the part that still pays off.
- name: "Build image"
if: steps.login.outcome != 'success'
uses: docker/build-push-action@v7
with:
cache-from: type=gha,scope=${{ env.CACHE_SCOPE }}
cache-to: ${{ case(github.ref == 'refs/heads/master', format('type=gha,mode=max,scope={0}', env.CACHE_SCOPE), '') }}
cache-to: ${{ case(github.ref == 'refs/heads/master', format('type=gha,mode=min,scope={0}', env.CACHE_SCOPE), '') }}
context: .
platforms: ${{ matrix.platform }}
push: false
@ -101,7 +107,7 @@ jobs:
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 }}
cache-to: type=gha,mode=min,scope=${{ env.CACHE_SCOPE }}
context: .
labels: |
org.opencontainers.image.description=${{ env.OCI_DESCRIPTION }}

View file

@ -65,6 +65,10 @@ if(USE_CCACHE AND NOT WIN32)
# PCH-aware caching, matching .ci/compile.sh: without this ccache refuses
# to cache any TU that consumes a precompiled header, so every PCH-backed
# target recompiles from scratch on each build.
# The include_file_mtime/include_file_ctime entries that CI also sets are
# deliberately not set here: --set-config writes the user's global
# ccache.conf, and those two would turn off the modified-during-compile
# check for every project on the machine, not just this one.
execute_process(COMMAND ${CCACHE_PROGRAM} --set-config sloppiness=pch_defines,time_macros)
message(STATUS "Found CCache ${CCACHE_PROGRAM}")
endif()

View file

@ -1,4 +1,4 @@
<events>
<!-- Open a new deck editor tab from the home page -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabHome/homeWidget/homeButtonsBox/homeCreateNewDeckButton" command="activate" arguments="" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabHome/homeWidget/homeActionsScrollArea/qt_scrollarea_viewport/homeButtonsBox/homeCreateNewDeckButton" command="activate" arguments="" />
</events>

View file

@ -1,6 +1,6 @@
<events>
<!-- Open a new visual deck editor tab from the home page -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabHome/homeWidget/homeButtonsBox/homeCreateNewDeckButton" command="activate" arguments="" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabHome/homeWidget/homeActionsScrollArea/qt_scrollarea_viewport/homeButtonsBox/homeCreateNewDeckButton" command="activate" arguments="" />
<!-- Type "Lightning" into the search box (keycodes for L-i-g-h-t-n-i-n-g) -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabDeckEditorVisual/databaseDisplayDock/databaseDisplayDockContents/databaseDisplayWidget/searchEdit" command="key" arguments="76" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabDeckEditorVisual/databaseDisplayDock/databaseDisplayDockContents/databaseDisplayWidget/searchEdit" command="key" arguments="105" />

View file

@ -36,7 +36,7 @@
<event widget="MainWindow/settingsDialog/settingsOkButton" command="activate" arguments="" />
<!-- Tier 3: Open deck editor from home and type in search -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabHome/homeWidget/homeButtonsBox/homeCreateNewDeckButton" command="activate" arguments="" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabHome/homeWidget/homeActionsScrollArea/qt_scrollarea_viewport/homeButtonsBox/homeCreateNewDeckButton" command="activate" arguments="" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabDeckEditorVisual/databaseDisplayDock/databaseDisplayDockContents/databaseDisplayWidget/searchEdit" command="key" arguments="76" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabDeckEditorVisual/databaseDisplayDock/databaseDisplayDockContents/databaseDisplayWidget/searchEdit" command="key" arguments="105" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabDeckEditorVisual/databaseDisplayDock/databaseDisplayDockContents/databaseDisplayWidget/searchEdit" command="key" arguments="103" />

View file

@ -56,6 +56,7 @@ set(cockatrice_SOURCES
src/interface/widgets/dialogs/dlg_settings.cpp
src/interface/widgets/dialogs/dlg_startup_card_check.cpp
src/interface/widgets/dialogs/dlg_tip_of_the_day.cpp
src/interface/widgets/dialogs/dlg_turn_order.cpp
src/interface/widgets/dialogs/dlg_update.cpp
src/interface/widgets/dialogs/dlg_view_log.cpp
src/interface/widgets/dialogs/override_printing_warning.cpp
@ -495,7 +496,15 @@ if(APPLE)
set(cockatrice_SOURCES ${cockatrice_SOURCES} ${CMAKE_CURRENT_SOURCE_DIR}/resources/appicon.icns)
endif(APPLE)
# The fonts and card artwork produce a large C++ byte array with qt_add_resources.
# Embed the data in the compiled resource object instead, keeping compiler memory
# independent of that payload. Qt's two-pass resource build is unsupported on iOS.
if(IOS)
qt_add_resources(cockatrice_RESOURCES_RCC ${cockatrice_RESOURCES})
else()
qt_add_big_resources(cockatrice_RESOURCES_RCC ${cockatrice_RESOURCES})
endif()
set_source_files_properties(${cockatrice_RESOURCES} PROPERTIES SKIP_AUTORCC ON)
# Declare path variables
set(ICONDIR
@ -521,6 +530,7 @@ qt_add_executable(
WIN32
MACOSX_BUNDLE
${cockatrice_SOURCES}
${cockatrice_RESOURCES}
${cockatrice_RESOURCES_RCC}
${cockatrice_MOC_SRCS}
MANUAL_FINALIZATION

View file

@ -36,6 +36,7 @@
#include <functional>
#include <libcockatrice/game/abstract_game.h>
#include <libcockatrice/game/board/arrow_data.h>
#include <libcockatrice/game/game_state.h>
#include <libcockatrice/game/player/player_actions.h>
#include <libcockatrice/game/player/player_event_handler.h>
#include <libcockatrice/game/player/player_info.h>
@ -441,6 +442,37 @@ QList<PlayerLogic *> GameScene::collectActivePlayers(int &firstPlayerIndex) cons
}
}
}
// Lay out the table in seat order rather than id order. Players missing
// from the tracked order keep id order at the end.
QList<int> seatOrder;
for (auto *pgItem : playerViews.values()) {
if (PlayerLogic *p = pgItem->getLogic()) {
seatOrder = p->getGame()->getGameState()->getSeatOrder();
break;
}
}
if (!seatOrder.isEmpty()) {
std::sort(activePlayers.begin(), activePlayers.end(), [&](PlayerLogic *a, PlayerLogic *b) {
int indexA = seatOrder.indexOf(a->getPlayerInfo()->getId());
int indexB = seatOrder.indexOf(b->getPlayerInfo()->getId());
if (indexA == -1) {
indexA = seatOrder.size() + a->getPlayerInfo()->getId();
}
if (indexB == -1) {
indexB = seatOrder.size() + b->getPlayerInfo()->getId();
}
return indexA < indexB;
});
// Recompute the local-player index after sorting.
firstPlayerIndex = 0;
for (int i = 0; i < activePlayers.size(); ++i) {
if (activePlayers[i]->getPlayerInfo()->getLocal()) {
firstPlayerIndex = i;
break;
}
}
}
return activePlayers;
}

View file

@ -605,6 +605,20 @@ void MessageLogWidget::logReverseTurn(PlayerLogic *player, bool reversed)
.arg(reversed ? tr("reversed") : tr("normal")));
}
void MessageLogWidget::logTurnOrderChanged(QStringList playerNames, bool randomized)
{
QStringList sanitizedNames;
for (const QString &name : playerNames) {
sanitizedNames.append(sanitizeHtml(name));
}
const QString order = sanitizedNames.join(" -> ");
if (randomized) {
appendHtmlServerMessage(tr("Randomized turn order: %1.").arg(order));
} else {
appendHtmlServerMessage(tr("New turn order set: %1.").arg(order));
}
}
void MessageLogWidget::logRollDie(PlayerLogic *player, int sides, const QList<uint> &rolls)
{
if (rolls.length() == 1) {

View file

@ -94,6 +94,7 @@ public slots:
int amount,
bool isLentToAnotherPlayer);
void logReverseTurn(PlayerLogic *player, bool reversed);
void logTurnOrderChanged(QStringList playerNames, bool randomized);
void logRollDie(PlayerLogic *player, int sides, const QList<uint> &rolls);
void logSay(PlayerLogic *player, QString message);
void logSetActivePhase(int phase);

View file

@ -39,6 +39,7 @@
#include <QTextTableFormat>
#include <QVector>
#include <QtConcurrentRun>
#include <algorithm>
#include <functional>
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/card/import/card_name_normalizer.h>
@ -372,8 +373,12 @@ bool DeckLoader::saveToStream_Plain(QTextStream &out,
saveToStream_DeckHeader(out, deckList);
}
// loop zones
for (auto zoneNode : deckList.getZoneNodes()) {
// Plain deck readers expect the main deck before the sideboard. Reorder a
// copy so native saves and the relative order of other zones stay intact.
auto zones = deckList.getZoneNodes();
std::stable_partition(zones.begin(), zones.end(),
[](const auto *zone) { return zone->getName() == DECK_ZONE_MAIN; });
for (auto zoneNode : zones) {
saveToStream_DeckZone(out, zoneNode, addComments, addSetNameAndNumber);
// end of zone

View file

@ -2,6 +2,8 @@
#include "libcockatrice/card/card_info.h"
#include <QRect>
#include <QSize>
#include <QTransform>
#include <libcockatrice/card/printing/exact_card.h>
#include <qnamespace.h>
@ -18,4 +20,23 @@ QPixmap rotateSidewaysLayoutArt(const QPixmap &art, const ExactCard &card)
transform.rotate(90);
return art.transformed(transform, Qt::SmoothTransformation);
}
QPixmap cropCardArt(const QPixmap &art, const ExactCard &card)
{
if (art.isNull()) {
return {};
}
const QPixmap source = rotateSidewaysLayoutArt(art, card);
const bool landscape = card.getInfo().getUiAttributes().landscapeOrientation;
const QSize size = source.size();
const int marginX = size.width() * 0.07;
// Landscape name boxes extend below 5% of the upright image. Remove the
// whole box here, rather than relying on the banner's narrow viewport to
// hide it: the same saved offset is also used by the taller profile popup.
const int topMargin = size.height() * (landscape ? 0.15 : 0.11);
const int bottomMargin = size.height() * 0.45;
const QRect crop(marginX, topMargin, size.width() - 2 * marginX, size.height() - topMargin - bottomMargin);
return source.copy(crop.intersected(source.rect()));
}
} // namespace CardArtUtils

View file

@ -20,6 +20,13 @@ namespace CardArtUtils
* @return @p art rotated 90° clockwise for sideways-layout cards, else @p art.
*/
QPixmap rotateSidewaysLayoutArt(const QPixmap &art, const ExactCard &card);
/**
* Return the upright artwork used by banner previews, user rows and profile
* popups. The crop excludes conventional name and rules boxes before callers apply
* their saved pan and zoom, so a taller popup cannot expose the card frame.
*/
QPixmap cropCardArt(const QPixmap &art, const ExactCard &card);
} // namespace CardArtUtils
#endif // CARD_ART_UTILS_H

View file

@ -69,6 +69,7 @@ DeckCardZoneDisplayWidget::DeckCardZoneDisplayWidget(QWidget *parent,
updateZoneCardCount();
connect(deckListModel, &QAbstractItemModel::rowsInserted, this, &DeckCardZoneDisplayWidget::onCategoryAddition);
connect(deckListModel, &QAbstractItemModel::layoutChanged, this, &DeckCardZoneDisplayWidget::displayCards);
if (selectionModel) {
connect(selectionModel, &QItemSelectionModel::selectionChanged, this,
&DeckCardZoneDisplayWidget::onSelectionChanged);
@ -174,6 +175,9 @@ void DeckCardZoneDisplayWidget::displayCards()
for (const QPersistentModelIndex &persistent : rows) {
constructAppropriateWidget(persistent);
// Reuse existing widgets when a model sort moves their persistent rows.
// This preserves collapsed groups and card selection during live edits.
cardGroupLayout->insertWidget(persistent.row(), indexToWidgetMap.value(persistent));
}
}

View file

@ -116,14 +116,17 @@ void DlgCreateGame::sharedCtor()
shareDecklistsOnLoadCheckBox = new QCheckBox(tr("Open decklists in lobby"));
shufflePlayersCheckBox = new QCheckBox(tr("Randomize turn order (seats) on each game start"));
createGameAsJudgeCheckBox = new QCheckBox(tr("Create game as judge"));
auto *gameSetupOptionsLayout = new QGridLayout;
gameSetupOptionsLayout->addWidget(startingLifeTotalLabel, 0, 0);
gameSetupOptionsLayout->addWidget(startingLifeTotalEdit, 0, 1);
gameSetupOptionsLayout->addWidget(shareDecklistsOnLoadCheckBox, 1, 0);
gameSetupOptionsLayout->addWidget(shareDecklistsOnLoadCheckBox, 1, 0, 1, 2);
gameSetupOptionsLayout->addWidget(shufflePlayersCheckBox, 2, 0, 1, 2);
if (room && room->getUserInfo()->user_level() & ServerInfo_User::IsJudge) {
gameSetupOptionsLayout->addWidget(createGameAsJudgeCheckBox, 2, 0);
gameSetupOptionsLayout->addWidget(createGameAsJudgeCheckBox, 3, 0, 1, 2);
} else {
createGameAsJudgeCheckBox->setChecked(false);
createGameAsJudgeCheckBox->setHidden(true);
@ -184,6 +187,7 @@ DlgCreateGame::DlgCreateGame(TabRoom *_room, const QMap<int, QString> &_gameType
createGameAsSpectatorCheckBox->setChecked(SettingsCache::instance().game().getCreateGameAsSpectator());
startingLifeTotalEdit->setValue(SettingsCache::instance().game().getDefaultStartingLifeTotal());
shareDecklistsOnLoadCheckBox->setChecked(SettingsCache::instance().game().getShareDecklistsOnLoad());
shufflePlayersCheckBox->setChecked(SettingsCache::instance().game().getShufflePlayers());
if (!rememberGameSettings->isChecked()) {
actReset();
@ -217,6 +221,7 @@ DlgCreateGame::DlgCreateGame(const ServerInfo_Game &gameInfo, const QMap<int, QS
createGameAsSpectatorCheckBox->setEnabled(false);
startingLifeTotalEdit->setEnabled(false);
shareDecklistsOnLoadCheckBox->setEnabled(false);
shufflePlayersCheckBox->setEnabled(false);
descriptionEdit->setText(QString::fromStdString(gameInfo.description()));
maxPlayersEdit->setValue(gameInfo.max_players());
@ -227,6 +232,7 @@ DlgCreateGame::DlgCreateGame(const ServerInfo_Game &gameInfo, const QMap<int, QS
spectatorsCanTalkCheckBox->setChecked(gameInfo.spectators_can_chat());
spectatorsSeeEverythingCheckBox->setChecked(gameInfo.spectators_omniscient());
shareDecklistsOnLoadCheckBox->setChecked(gameInfo.share_decklists_on_load());
shufflePlayersCheckBox->setChecked(gameInfo.shuffle_players());
QSet<int> types;
for (int i = 0; i < gameInfo.game_types_size(); ++i) {
@ -264,6 +270,7 @@ void DlgCreateGame::actReset()
startingLifeTotalEdit->setValue(20);
shareDecklistsOnLoadCheckBox->setChecked(false);
shufflePlayersCheckBox->setChecked(false);
createGameAsJudgeCheckBox->setChecked(false);
QMapIterator<int, QRadioButton *> gameTypeCheckBoxIterator(gameTypeCheckBoxes);
@ -294,6 +301,7 @@ void DlgCreateGame::actOK()
cmd.set_join_as_spectator(createGameAsSpectatorCheckBox->isChecked());
cmd.set_starting_life_total(startingLifeTotalEdit->value());
cmd.set_share_decklists_on_load(shareDecklistsOnLoadCheckBox->isChecked());
cmd.set_shuffle_players(shufflePlayersCheckBox->isChecked());
auto _gameTypes = QString();
QMapIterator<int, QRadioButton *> gameTypeCheckBoxIterator(gameTypeCheckBoxes);
@ -318,6 +326,7 @@ void DlgCreateGame::actOK()
SettingsCache::instance().game().setCreateGameAsSpectator(createGameAsSpectatorCheckBox->isChecked());
SettingsCache::instance().game().setDefaultStartingLifeTotal(startingLifeTotalEdit->value());
SettingsCache::instance().game().setShareDecklistsOnLoad(shareDecklistsOnLoadCheckBox->isChecked());
SettingsCache::instance().game().setShufflePlayers(shufflePlayersCheckBox->isChecked());
SettingsCache::instance().game().setGameTypes(_gameTypes);
}
PendingCommand *pend = room->prepareRoomCommand(cmd);

View file

@ -51,6 +51,7 @@ private:
QCheckBox *spectatorsAllowedCheckBox, *spectatorsNeedPasswordCheckBox, *spectatorsCanTalkCheckBox,
*spectatorsSeeEverythingCheckBox, *createGameAsJudgeCheckBox, *createGameAsSpectatorCheckBox;
QCheckBox *shareDecklistsOnLoadCheckBox;
QCheckBox *shufflePlayersCheckBox;
QDialogButtonBox *buttonBox;
QPushButton *clearButton;
QCheckBox *rememberGameSettings;

View file

@ -0,0 +1,136 @@
#include "dlg_turn_order.h"
#include <QDialogButtonBox>
#include <QHBoxLayout>
#include <QLabel>
#include <QListWidget>
#include <QListWidgetItem>
#include <QPushButton>
#include <QVBoxLayout>
DlgTurnOrder::DlgTurnOrder(const QStringList &_playerNames, QWidget *parent)
: QDialog(parent), randomizeRequested(false)
{
listWidget = new QListWidget(this);
listWidget->addItems(_playerNames);
listWidget->setCurrentRow(0);
connect(listWidget, &QListWidget::currentRowChanged, this, &DlgTurnOrder::selectionChanged);
moveUpButton = new QPushButton(this);
connect(moveUpButton, &QPushButton::clicked, this, &DlgTurnOrder::actMoveUp);
moveDownButton = new QPushButton(this);
connect(moveDownButton, &QPushButton::clicked, this, &DlgTurnOrder::actMoveDown);
randomizeButton = new QPushButton(this);
connect(randomizeButton, &QPushButton::clicked, this, &DlgTurnOrder::actRandomize);
// Shown in place of the list while the server is asked to randomize: the
// order is chosen server side, so there is nothing to preview here.
hintLabel = new QLabel(this);
hintLabel->setWordWrap(true);
hintLabel->setAlignment(Qt::AlignCenter);
auto *buttonColumn = new QVBoxLayout;
buttonColumn->addWidget(moveUpButton);
buttonColumn->addWidget(moveDownButton);
buttonColumn->addWidget(randomizeButton);
buttonColumn->addStretch();
auto *listLayout = new QHBoxLayout;
listLayout->addWidget(listWidget);
listLayout->addWidget(hintLabel, 1);
listLayout->addLayout(buttonColumn);
auto *buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel);
connect(buttonBox, &QDialogButtonBox::accepted, this, &DlgTurnOrder::actOk);
connect(buttonBox, &QDialogButtonBox::rejected, this, &DlgTurnOrder::reject);
auto *mainLayout = new QVBoxLayout;
mainLayout->addLayout(listLayout);
mainLayout->addWidget(buttonBox);
setLayout(mainLayout);
updateButtons();
updateMode();
setMinimumWidth(300);
}
void DlgTurnOrder::retranslateUi()
{
setWindowTitle(tr("Set turn order"));
moveUpButton->setText(tr("Move up"));
moveDownButton->setText(tr("Move down"));
randomizeButton->setText(randomizeRequested ? tr("Set order manually") : tr("Randomize"));
hintLabel->setText(tr("The server will randomize the turn order."));
}
QStringList DlgTurnOrder::order() const
{
QStringList playerNames;
for (int i = 0; i < listWidget->count(); ++i) {
playerNames.append(listWidget->item(i)->text());
}
return playerNames;
}
void DlgTurnOrder::moveSelected(int delta)
{
const int row = listWidget->currentRow();
const int newRow = row + delta;
if (row < 0 || newRow < 0 || newRow >= listWidget->count()) {
return;
}
QListWidgetItem *item = listWidget->takeItem(row);
listWidget->insertItem(newRow, item);
listWidget->setCurrentRow(newRow);
updateButtons();
}
void DlgTurnOrder::updateMode()
{
listWidget->setVisible(!randomizeRequested);
hintLabel->setVisible(randomizeRequested);
moveUpButton->setVisible(!randomizeRequested);
moveDownButton->setVisible(!randomizeRequested);
retranslateUi();
}
void DlgTurnOrder::updateButtons()
{
const int row = listWidget->currentRow();
const int count = listWidget->count();
const bool manualOrder = !randomizeRequested;
moveUpButton->setEnabled(manualOrder && row > 0);
moveDownButton->setEnabled(manualOrder && row >= 0 && row < count - 1);
}
void DlgTurnOrder::actMoveUp()
{
moveSelected(-1);
}
void DlgTurnOrder::actMoveDown()
{
moveSelected(1);
}
void DlgTurnOrder::actRandomize()
{
// Randomizing is a request to the server, not a local shuffle: showing a
// preview would let the host re-open the dialog until the order looks
// favourable for them.
randomizeRequested = !randomizeRequested;
updateMode();
updateButtons();
}
void DlgTurnOrder::actOk()
{
accept();
}
void DlgTurnOrder::selectionChanged()
{
updateButtons();
}

View file

@ -0,0 +1,51 @@
/**
* @file dlg_turn_order.h
* @ingroup GameDialogs
*/
//! \todo Document this file.
#ifndef DLG_TURN_ORDER_H
#define DLG_TURN_ORDER_H
#include <QDialog>
#include <QStringList>
class QLabel;
class QListWidget;
class QPushButton;
class DlgTurnOrder : public QDialog
{
Q_OBJECT
private:
QListWidget *listWidget;
QLabel *hintLabel;
QPushButton *moveUpButton;
QPushButton *moveDownButton;
QPushButton *randomizeButton;
bool randomizeRequested;
void moveSelected(int delta);
void updateMode();
void updateButtons();
private slots:
void actMoveUp();
void actMoveDown();
void actRandomize();
void actOk();
void selectionChanged();
public:
explicit DlgTurnOrder(const QStringList &_playerNames, QWidget *parent = nullptr);
void retranslateUi();
[[nodiscard]] bool isRandomizeRequested() const
{
return randomizeRequested;
}
[[nodiscard]] QStringList order() const;
};
#endif

View file

@ -21,8 +21,8 @@ class QWidget;
HomeStyledButton::HomeStyledButton(const QString &text, QPair<QColor, QColor> _gradientColors, QWidget *parent)
: QPushButton(text, parent), gradientColors(_gradientColors)
{
setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
setMinimumHeight(25);
// Keep the font/style-derived height even when the home page has ample space.
setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
setStyleSheet(generateButtonStylesheet(gradientColors));
applyInterfaceDefaultFont();
connect(themeManager, &ThemeManager::fontsChanged, this, &HomeStyledButton::applyInterfaceDefaultFont);

View file

@ -27,10 +27,11 @@
#include <QFlags>
#include <QFont>
#include <QFontMetrics>
#include <QGridLayout>
#include <QFrame>
#include <QGroupBox>
#include <QImage>
#include <QLabel>
#include <QLayout>
#include <QLinearGradient>
#include <QList>
#include <QMap>
@ -44,9 +45,9 @@
#include <QRect>
#include <QRectF>
#include <QRgb>
#include <QScrollArea>
#include <QSharedPointer>
#include <QSize>
#include <QSizePolicy>
#include <QString>
#include <QStringLiteral>
#include <QTimer>
@ -60,6 +61,7 @@
#include <libcockatrice/network/client/remote/remote_client.h>
#include <libcockatrice/settings/appearance_settings.h>
#include <libcockatrice/settings/paths_settings.h>
#include <libcockatrice/utility/qt_utils.h>
#include <optional>
#include <qlogging.h>
#include <qnamespace.h>
@ -69,20 +71,25 @@ HomeWidget::HomeWidget(QWidget *parent, TabSupervisor *_tabSupervisor)
: QWidget(parent), tabSupervisor(_tabSupervisor), background(themePixmap(QStringLiteral("backgrounds/home")))
{
setObjectName("homeWidget");
layout = new QGridLayout(this);
backgroundSourceCard = new CardInfoPictureArtCropWidget(this);
gradientColors = determineButtonColor();
layout->addWidget(createButtons(), 1, 1, Qt::AlignVCenter | Qt::AlignHCenter);
// The launcher keeps its natural size on a large display and stays centred in the
// viewport. On a small window it scrolls, instead of its combined minimum height
// becoming the minimum height of the entire application.
auto *scrollArea = QtUtils::makeScrollArea(this, Qt::ScrollBarAsNeeded, Qt::ScrollBarAsNeeded);
scrollArea->setObjectName("homeActionsScrollArea");
scrollArea->setFrameShape(QFrame::NoFrame);
scrollArea->setWidgetResizable(false);
scrollArea->setAlignment(Qt::AlignCenter);
// The home background is painted by this widget, so let it show through the viewport.
scrollArea->viewport()->setAutoFillBackground(false);
scrollArea->setWidget(createButtons());
layout->setRowStretch(0, 1);
layout->setRowStretch(2, 1);
layout->setColumnStretch(0, 1);
layout->setColumnStretch(2, 1);
setLayout(layout);
auto *layout = new QVBoxLayout(this);
layout->addWidget(scrollArea);
cardChangeTimer = new QTimer(this);
connect(cardChangeTimer, &QTimer::timeout, this, &HomeWidget::updateRandomCard);
@ -289,18 +296,10 @@ QGroupBox *HomeWidget::createButtons()
{
QGroupBox *box = new QGroupBox(this);
box->setObjectName("homeButtonsBox");
box->setStyleSheet(R"(
QGroupBox {
color: white; /* Title text color */
background: transparent;
}
QGroupBox::title {
color: white;
subcontrol-origin: margin;
subcontrol-position: top center; /* or top left / right */
}
)");
// The box only stacks the logo and the actions: no frame, and no background of its
// own, so the home background art stays visible. It never shows a title. The rule is
// load-bearing: QScrollArea::setWidget() turns autoFillBackground on for its widget.
box->setStyleSheet("QGroupBox { background: transparent; border: none; }");
// Family comes from the InterfaceDefault theme role; the 20px size stays caller-side.
auto applyBoxFont = [box]() {
QFont boxFont = themeManager->appFont(AppFont::InterfaceDefault);
@ -309,9 +308,12 @@ QGroupBox *HomeWidget::createButtons()
};
applyBoxFont();
connect(themeManager, &ThemeManager::fontsChanged, this, applyBoxFont);
box->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
QVBoxLayout *boxLayout = new QVBoxLayout;
boxLayout->setAlignment(Qt::AlignHCenter);
// The scroll area is not resizable, so the box has to size itself: SetFixedSize keeps
// it at its content size and re-fixes it whenever the labels or fonts change, which is
// what makes the scroll area's scroll bars follow the content.
boxLayout->setSizeConstraint(QLayout::SetFixedSize);
logoLabel = new QLabel;
logoLabel->setAlignment(Qt::AlignCenter);

View file

@ -16,7 +16,6 @@
#include <libcockatrice/network/client/abstract/abstract_client.h>
#include <qtmetamacros.h>
class QGridLayout;
class QLabel;
class CardInfoPictureArtCropWidget;
class ExactCard;
@ -46,7 +45,6 @@ public slots:
void updateConnectButton(const ClientStatus status);
private:
QGridLayout *layout;
QTimer *cardChangeTimer;
TabSupervisor *tabSupervisor;
QPixmap background;

View file

@ -185,6 +185,9 @@ QVariant GamesModel::data(const QModelIndex &index, int role) const
if (gameentry.share_decklists_on_load()) {
result.append(tr("open decklists"));
}
if (gameentry.shuffle_players()) {
result.append(tr("random turn order"));
}
return result.join(", ");
}
case Qt::DecorationRole: {

View file

@ -12,7 +12,6 @@
#include <QChar>
#include <QMetaObject>
#include <QPointer>
#include <QRect>
#include <QSize>
#include <QStringList>
#include <libcockatrice/card/database/card_database_manager.h>
@ -66,27 +65,6 @@ void UserCardArtProvider::requestCardArt(const QString &userName, const QString
processQueue();
}
QPixmap UserCardArtProvider::cropCardArt(const QPixmap &fullRes, const ExactCard &card)
{
QPixmap source = fullRes;
// Sideways-layout cards (plane, siege/battle, split) store their landscape
// artwork rotated 90° inside a portrait frame. Rotate it upright first so
// the crop below lands on the horizontal art, mirroring the way
// CardInfoPictureWidget displays these cards.
const bool landscape = card.getInfo().getUiAttributes().landscapeOrientation;
source = CardArtUtils::rotateSidewaysLayoutArt(source, card);
const QSize sz = source.size();
const int marginX = sz.width() * 0.07;
const int topMargin = landscape ? sz.height() * 0.05 : sz.height() * 0.11;
const int bottomMargin = landscape ? sz.height() * 0.42 : sz.height() * 0.45;
const QRect artRect(marginX, topMargin, sz.width() - 2 * marginX, sz.height() - topMargin - bottomMargin);
return source.copy(artRect.intersected(source.rect()));
}
void UserCardArtProvider::insertIntoCache(const QString &key, const QPixmap &pixmap)
{
if (pixmap.isNull()) {
@ -134,7 +112,7 @@ void UserCardArtProvider::processQueue()
// Synchronous hit (already loaded/on disk)
if (!fullRes.isNull()) {
insertIntoCache(key, cropCardArt(fullRes, card));
insertIntoCache(key, CardArtUtils::cropCardArt(fullRes, card));
pending.remove(key);
emit cardArtUpdated(userName);
@ -158,7 +136,7 @@ void UserCardArtProvider::processQueue()
CardPictureLoader::getPixmap(fullRes, card, QSize(745, 1040));
if (!fullRes.isNull()) {
self->insertIntoCache(key, self->cropCardArt(fullRes, card));
self->insertIntoCache(key, CardArtUtils::cropCardArt(fullRes, card));
}
self->pending.remove(key);

View file

@ -10,8 +10,6 @@
#include <QString>
#include <qtmetamacros.h>
class ExactCard;
class UserCardArtProvider : public QObject
{
Q_OBJECT
@ -21,7 +19,6 @@ public:
void requestCardArt(const QString &userName, const QString &cardName, const QString &providerId);
const QMap<QString, QPixmap> &cache() const;
static QPixmap cropCardArt(const QPixmap &fullRes, const ExactCard &card);
signals:
void cardArtUpdated(const QString &userName);

View file

@ -2,6 +2,7 @@
#include "../../../card_picture_loader/card_picture_loader.h"
#include "../../cards/art_crop_attribution.h"
#include "../../cards/card_art_utils.h"
#include "../../utility/completer_utils.h"
#include "card_database_display_model.h"
#include "card_database_model.h"
@ -10,7 +11,6 @@
#include "libcockatrice/card/printing/exact_card.h"
#include "libcockatrice/card/printing/printing_info.h"
#include "libcockatrice/card/set/card_set.h"
#include "user_card_art_provider.h"
#include "user_list_painter.h"
#include <QBrush>
@ -580,7 +580,7 @@ void UserCardArtSettingsDialog::reloadPreview()
return;
}
currentPixmap = UserCardArtProvider::cropCardArt(fullRes, card);
currentPixmap = CardArtUtils::cropCardArt(fullRes, card);
preview->setPixmap(currentPixmap);
preview->setParams(currentParams);

View file

@ -20,6 +20,7 @@
#include "../interface/widgets/cards/card_info_frame_widget.h"
#include "../interface/widgets/dialogs/dlg_create_game.h"
#include "../interface/widgets/dialogs/dlg_invite_to_game.h"
#include "../interface/widgets/dialogs/dlg_turn_order.h"
#include "../interface/widgets/server/game_link.h"
#include "../interface/widgets/server/user/user_list_manager.h"
#include "../interface/widgets/utility/completer_utils.h"
@ -195,6 +196,7 @@ void TabGame::connectToGameState()
{
connect(game->getGameState(), &GameState::gameStarted, this, &TabGame::startGame);
connect(game->getGameState(), &GameState::gameStopped, this, &TabGame::stopGame);
connect(game->getGameState(), &GameState::hostChanged, this, &TabGame::updateTurnOrderAction);
connect(game->getGameState(), &GameState::activePhaseChanged, this, &TabGame::setActivePhase);
connect(game->getGameState(), &GameState::activePlayerChanged, this, &TabGame::setActivePlayer);
}
@ -263,6 +265,8 @@ void TabGame::connectMessageLogToGameEventHandler()
connect(game->getGameEventHandler(), &GameEventHandler::logTurnReversed, messageLog,
&MessageLogWidget::logReverseTurn);
connect(game->getGameEventHandler(), &GameEventHandler::logTurnOrderChanged, messageLog,
&MessageLogWidget::logTurnOrderChanged);
connect(game->getGameEventHandler(), &GameEventHandler::logConcede, messageLog, &MessageLogWidget::logConcede);
connect(game->getGameEventHandler(), &GameEventHandler::logUnconcede, messageLog, &MessageLogWidget::logUnconcede);
@ -373,6 +377,9 @@ void TabGame::retranslateUi()
if (aReverseTurn) {
aReverseTurn->setText(tr("Reverse turn order"));
}
if (aTurnOrder) {
aTurnOrder->setText(tr("&Set turn order..."));
}
if (aRemoveLocalArrows) {
aRemoveLocalArrows->setText(tr("&Remove all local arrows"));
}
@ -629,6 +636,73 @@ void TabGame::actConcede()
}
}
void TabGame::actTurnOrder()
{
if (!canSetTurnOrder()) {
return;
}
QStringList playerNames;
const QList<int> seatOrder = game->getGameState()->getSeatOrder();
const auto &players = game->getPlayerManager()->getPlayers();
if (!seatOrder.isEmpty()) {
for (int playerId : seatOrder) {
if (PlayerLogic *player = players.value(playerId, nullptr)) {
playerNames.append(player->getPlayerInfo()->getName());
}
}
// Include any players missing from the tracked order rather than dropping them.
for (auto it = players.constBegin(); it != players.constEnd(); ++it) {
const QString name = it.value()->getPlayerInfo()->getName();
if (!playerNames.contains(name)) {
playerNames.append(name);
}
}
} else {
for (PlayerLogic *player : players) {
playerNames.append(player->getPlayerInfo()->getName());
}
}
DlgTurnOrder dlg(playerNames, this);
if (dlg.exec() != QDialog::Accepted) {
return;
}
game->getGameEventHandler()->handleSetPlayerOrder(dlg.order(), dlg.isRandomizeRequested());
}
/**
* Whether the local user may change the seating order: only while the game has
* not started, and only as the host or a moderator - the server allows both
* (Server_AbstractParticipant::cmdSetPlayerOrder).
*/
bool TabGame::canSetTurnOrder() const
{
if (game->getGameMetaInfo()->started()) {
return false;
}
if (game->isHost()) {
return true;
}
const ServerInfo_User *userInfo = tabSupervisor->getUserInfo();
return userInfo != nullptr && (userInfo->user_level() & ServerInfo_User::IsModerator);
}
/**
* Shows the turn order action only when it can actually change anything: the
* player must be allowed to set the order and the game must not have started.
* Called whenever either condition may have changed (game start/stop, host
* change).
*/
void TabGame::updateTurnOrderAction()
{
if (aTurnOrder == nullptr) {
return;
}
aTurnOrder->setVisible(canSetTurnOrder());
}
/**
* Confirms the leave game and sends the leave game command, if applicable.
*
@ -840,6 +914,15 @@ void TabGame::processPlayerLeave(PlayerLogic *leavingPlayer)
}
}
// A shuffled local player has their seat changed, so the deck view container
// keyed by their previous id must be cleaned up alongside the leaving player.
int leavingPlayerId = leavingPlayer->getPlayerInfo()->getId();
if (deckViewContainers.contains(leavingPlayerId)) {
TabbedDeckViewContainer *deckView = deckViewContainers.take(leavingPlayerId);
deckViewContainerLayout->removeWidget(deckView);
deckView->deleteLater();
}
scene->removePlayer(leavingPlayer);
}
@ -948,6 +1031,7 @@ void TabGame::startGame(bool _resuming)
aConcede->setText(tr("&Concede"));
aConcede->setEnabled(true);
}
updateTurnOrderAction();
}
void TabGame::stopGame()
@ -970,6 +1054,7 @@ void TabGame::stopGame()
aConcede->setText(tr("&Concede"));
aConcede->setEnabled(false);
}
updateTurnOrderAction();
}
void TabGame::closeGame()
@ -1094,6 +1179,9 @@ void TabGame::createMenuItems()
connect(aNextTurn, &QAction::triggered, game->getGameEventHandler(), &GameEventHandler::handleNextTurn);
aReverseTurn = new QAction(this);
connect(aReverseTurn, &QAction::triggered, game->getGameEventHandler(), &GameEventHandler::handleReverseTurn);
aTurnOrder = new QAction(this);
connect(aTurnOrder, &QAction::triggered, this, &TabGame::actTurnOrder);
updateTurnOrderAction();
aRemoveLocalArrows = new QAction(this);
connect(aRemoveLocalArrows, &QAction::triggered, this, &TabGame::actRemoveLocalArrows);
aRotateViewCW = new QAction(this);
@ -1144,6 +1232,7 @@ void TabGame::createMenuItems()
gameMenu->addAction(aNextTurn);
aNextTurn->setObjectName("aNextTurn");
gameMenu->addAction(aReverseTurn);
gameMenu->addAction(aTurnOrder);
gameMenu->addSeparator();
gameMenu->addAction(aRemoveLocalArrows);
gameMenu->addAction(aRotateViewCW);
@ -1172,6 +1261,7 @@ void TabGame::createReplayMenuItems()
aNextPhaseAction = nullptr;
aNextTurn = nullptr;
aReverseTurn = nullptr;
aTurnOrder = nullptr;
aRemoveLocalArrows = nullptr;
aRotateViewCW = nullptr;
aRotateViewCCW = nullptr;
@ -1302,6 +1392,7 @@ void TabGame::createPlayAreaWidget(bool bReplay)
scene = new GameScene(phasesToolbar, this);
connect(game->getPlayerManager(), &PlayerManager::playerConceded, scene, &GameScene::rearrange);
connect(game->getPlayerManager(), &PlayerManager::playerCountChanged, scene, &GameScene::rearrange);
connect(game->getGameState(), &GameState::seatOrderChanged, scene, &GameScene::rearrange);
connect(scene, &GameScene::arrowDeletionRequested, game->getGameEventHandler(),
&GameEventHandler::handleArrowDeletion);
connect(game->getGameEventHandler(), &GameEventHandler::arrowDeleted, scene, &GameScene::deleteArrow);

View file

@ -94,7 +94,8 @@ private:
QMenu *gameMenu, *viewMenu;
TearOffMenu *phasesMenu;
QAction *aGameInfo, *aConcede, *aCopyGameLink, *aLeaveGame, *aNextPhase, *aNextPhaseAction, *aNextTurn,
*aReverseTurn, *aRemoveLocalArrows, *aRotateViewCW, *aRotateViewCCW, *aResetLayout, *aResetReplayLayout;
*aReverseTurn, *aTurnOrder, *aRemoveLocalArrows, *aRotateViewCW, *aRotateViewCCW, *aResetLayout,
*aResetReplayLayout;
QAction *aFocusChat;
QAction *aInviteToGame = nullptr;
QList<QAction *> phaseActions;
@ -129,6 +130,7 @@ private:
PlayerLogic *setActivePlayer(int id);
void setActivePhase(int phase);
bool canSetTurnOrder() const;
void createMenuItems();
void createReplayMenuItems();
void createViewMenuItems();
@ -162,6 +164,8 @@ private slots:
void actInviteToGame();
void actConcede();
void actCopyGameLink();
void actTurnOrder();
void updateTurnOrderAction();
void actRemoveLocalArrows();
void actRotateViewCW();
void actRotateViewCCW();

View file

@ -18,6 +18,7 @@
#include <QIODevice>
#include <QList>
#include <QMessageLogger>
#include <QSaveFile>
#include <QSharedPointer>
#include <QStringView>
#include <QXmlStreamAttributes>
@ -499,7 +500,7 @@ bool CockatriceXml3Parser::saveToFile(FormatRulesNameMap _formats,
{
Q_UNUSED(_formats);
QFile file(fileName);
QSaveFile file(fileName);
if (!file.open(QIODevice::WriteOnly)) {
return false;
}
@ -539,5 +540,5 @@ bool CockatriceXml3Parser::saveToFile(FormatRulesNameMap _formats,
xml.writeEndElement(); // cockatrice_carddatabase
xml.writeEndDocument();
return true;
return !xml.hasError() && file.commit();
}

View file

@ -18,6 +18,7 @@
#include <QList>
#include <QMap>
#include <QMessageLogger>
#include <QSaveFile>
#include <QSharedPointer>
#include <QStringList>
#include <QStringView>
@ -682,7 +683,7 @@ bool CockatriceXml4Parser::saveToFile(FormatRulesNameMap _formats,
const QString &sourceUrl,
const QString &sourceVersion)
{
QFile file(fileName);
QSaveFile file(fileName);
if (!file.open(QIODevice::WriteOnly)) {
return false;
}
@ -730,5 +731,5 @@ bool CockatriceXml4Parser::saveToFile(FormatRulesNameMap _formats,
xml.writeEndElement(); // cockatrice_carddatabase
xml.writeEndDocument();
return true;
return !xml.hasError() && file.commit();
}

View file

@ -340,7 +340,17 @@ bool DeckList::saveToStream_Plain(QTextStream &stream, bool prefixSideboardCards
}
};
forEachCard(writeToStream);
// Export the main deck first without changing the saved tree's zone order.
forEachCard([&](const auto node, const auto card) {
if (node->getName() == DECK_ZONE_MAIN) {
writeToStream(node, card);
}
});
forEachCard([&](const auto node, const auto card) {
if (node->getName() != DECK_ZONE_MAIN) {
writeToStream(node, card);
}
});
return true;
}

View file

@ -21,6 +21,7 @@
#include <QMap>
#include <QMapIterator>
#include <QMessageLogger>
#include <QSet>
#include <QSharedPointer>
#include <google/protobuf/descriptor.h>
#include <google/protobuf/message.h>
@ -38,6 +39,7 @@
#include <libcockatrice/protocol/pb/command_reverse_turn.pb.h>
#include <libcockatrice/protocol/pb/command_set_active_phase.pb.h>
#include <libcockatrice/protocol/pb/command_set_card_attr.pb.h>
#include <libcockatrice/protocol/pb/command_set_player_order.pb.h>
#include <libcockatrice/protocol/pb/command_set_playmat.pb.h>
#include <libcockatrice/protocol/pb/command_set_sideboard_lock.pb.h>
#include <libcockatrice/protocol/pb/command_set_sideboard_plan.pb.h>
@ -50,6 +52,7 @@
#include <libcockatrice/protocol/pb/event_join.pb.h>
#include <libcockatrice/protocol/pb/event_kicked.pb.h>
#include <libcockatrice/protocol/pb/event_leave.pb.h>
#include <libcockatrice/protocol/pb/event_player_order_changed.pb.h>
#include <libcockatrice/protocol/pb/event_player_properties_changed.pb.h>
#include <libcockatrice/protocol/pb/event_reverse_turn.pb.h>
#include <libcockatrice/protocol/pb/event_set_active_phase.pb.h>
@ -189,6 +192,9 @@ void GameEventHandler::processGameEventContainer(const GameEventContainer &cont,
case GameEvent::REVERSE_TURN:
eventReverseTurn(event.GetExtension(Event_ReverseTurn::ext), playerId, context);
break;
case GameEvent::PLAYER_ORDER_CHANGED:
eventPlayerOrderChanged(event.GetExtension(Event_PlayerOrderChanged::ext), playerId, context);
break;
default: {
PlayerLogic *player = game->getPlayerManager()->getPlayers().value(playerId, 0);
@ -215,6 +221,16 @@ void GameEventHandler::handleReverseTurn()
sendGameCommand(Command_ReverseTurn());
}
void GameEventHandler::handleSetPlayerOrder(const QList<QString> &playerNames, bool randomize)
{
Command_SetPlayerOrder command;
command.set_randomize(randomize);
for (const QString &name : playerNames) {
command.add_player_names(name.toStdString());
}
sendGameCommand(command);
}
void GameEventHandler::handleActiveLocalPlayerConceded()
{
sendGameCommand(Command_Concede());
@ -377,6 +393,27 @@ void GameEventHandler::eventGameStateChanged(const Event_GameStateChanged &event
{
const int playerListSize = event.player_list_size();
if (event.has_local_player_id()) {
game->getPlayerManager()->setLocalPlayerId(event.local_player_id());
}
QSet<int> authoritativePlayerIds;
for (int i = 0; i < playerListSize; ++i) {
const ServerInfo_PlayerProperties &prop = event.player_list(i).properties();
if (!prop.spectator()) {
authoritativePlayerIds.insert(prop.player_id());
}
}
// Players whose seats were shuffled away from the previous game are no longer
// part of the authoritative list and must be removed.
for (int stalePlayerId : game->getPlayerManager()->getPlayers().keys()) {
if (!authoritativePlayerIds.contains(stalePlayerId)) {
game->getPlayerManager()->removePlayer(stalePlayerId);
emit playerLeft(stalePlayerId);
}
}
QVector<QPair<int, QPair<QString, QString>>> opponentDecksToDisplay;
for (int i = 0; i < playerListSize; ++i) {
@ -421,6 +458,19 @@ void GameEventHandler::eventGameStateChanged(const Event_GameStateChanged &event
emit remotePlayersDecksSelected(opponentDecksToDisplay);
// The server emits player_list in seat order, so record it to lay out the
// table by seating rather than by player id.
QList<int> seatOrder;
for (int i = 0; i < playerListSize; ++i) {
const ServerInfo_PlayerProperties &prop = event.player_list(i).properties();
if (!prop.spectator()) {
seatOrder.append(prop.player_id());
}
}
if (!seatOrder.isEmpty()) {
game->getGameState()->setSeatOrder(seatOrder);
}
game->getGameState()->setGameTime(event.seconds_elapsed());
if (event.game_started() && !game->getGameMetaInfo()->started()) {
@ -625,6 +675,33 @@ void GameEventHandler::eventReverseTurn(const Event_ReverseTurn &event,
emit logTurnReversed(player, event.reversed());
}
void GameEventHandler::eventPlayerOrderChanged(const Event_PlayerOrderChanged &event,
int /*eventPlayerId*/,
const GameEventContext & /*context*/)
{
QStringList playerNames;
for (const auto &name : event.player_names()) {
playerNames.append(QString::fromStdString(name));
}
// Track the seating by stable player id so the table layout and the turn
// order dialog follow the shuffled order instead of id order.
QList<int> seatOrder;
const auto &players = game->getPlayerManager()->getPlayers();
for (const QString &name : playerNames) {
for (auto it = players.constBegin(); it != players.constEnd(); ++it) {
if (it.value() && it.value()->getPlayerInfo()->getName() == name) {
seatOrder.append(it.key());
break;
}
}
}
if (!seatOrder.isEmpty()) {
game->getGameState()->setSeatOrder(seatOrder);
}
emit logTurnOrderChanged(playerNames, event.randomized());
}
void GameEventHandler::eventGameHostChanged(const Event_GameHostChanged & /*event*/,
int eventPlayerId,
const GameEventContext & /*context*/)

View file

@ -51,6 +51,7 @@ class Event_GameSay;
class Event_Kicked;
class Event_ReverseTurn;
class Event_Ping;
class Event_PlayerOrderChanged;
class ServerInfo_PlayerProperties;
namespace google
{
@ -113,6 +114,16 @@ public:
/** @brief Request reversing the current turn order. */
void handleReverseTurn();
/**
* @brief Request a new seating / turn order.
*
* Only the game host (or a moderator) may do this before the game starts.
*
* @param playerNames Ordered list of player names. Ignored when randomize is true.
* @param randomize Ask the server to shuffle the seats instead.
*/
void handleSetPlayerOrder(const QList<QString> &playerNames, bool randomize);
/** @brief Concede the game for the currently active local player. */
void handleActiveLocalPlayerConceded();
@ -316,6 +327,10 @@ public:
/** @brief Handle a turn reversal event. */
void eventReverseTurn(const Event_ReverseTurn &event, int eventPlayerId, const GameEventContext &context);
/** @brief Handle a change of the seating / turn order. */
void
eventPlayerOrderChanged(const Event_PlayerOrderChanged &event, int eventPlayerId, const GameEventContext &context);
/** @brief Handle ping / latency updates. */
void eventPing(const Event_Ping &event, int eventPlayerId, const GameEventContext &context);
@ -443,6 +458,7 @@ signals:
void logLeave(PlayerLogic *player, QString reason);
void logKicked();
void logTurnReversed(PlayerLogic *player, bool reversed);
void logTurnOrderChanged(QStringList playerNames, bool randomized);
void logGameClosed();
void logActivePlayer(PlayerLogic *activePlayer);
void logActivePhaseChanged(int activePhase);

View file

@ -33,7 +33,11 @@ public:
void setHostId(int _hostId)
{
if (hostId == _hostId) {
return;
}
hostId = _hostId;
emit hostChanged(hostId);
}
QList<AbstractClient *> getClients() const
@ -112,6 +116,19 @@ public:
return hostId;
}
void setSeatOrder(const QList<int> &order)
{
if (seatOrder != order) {
seatOrder = order;
emit seatOrderChanged(seatOrder);
}
}
QList<int> getSeatOrder() const
{
return seatOrder;
}
int getSecondsElapsed() const
{
return secondsElapsed;
@ -123,6 +140,8 @@ signals:
void gameStopped();
void activePhaseChanged(int activePhase);
void activePlayerChanged(int playerId);
void hostChanged(int hostId);
void seatOrderChanged(const QList<int> &seatOrder);
public slots:
void incrementGameTime();
@ -142,6 +161,9 @@ private:
int currentPhase;
int activePlayer;
bool gameClosed;
//! Player ids in seating (turn) order, first to play first. Empty until the
//! server sends an order, in which case players are laid out in id order.
QList<int> seatOrder;
};
#endif // COCKATRICE_GAME_STATE_H

View file

@ -57,6 +57,11 @@ public:
return localPlayerId;
}
void setLocalPlayerId(int _localPlayerId)
{
localPlayerId = _localPlayerId;
}
[[nodiscard]] const QMap<int, PlayerLogic *> &getPlayers() const
{
return players;

View file

@ -20,6 +20,7 @@ public:
[[nodiscard]] virtual bool getCreateGameAsSpectator() const = 0;
[[nodiscard]] virtual int getDefaultStartingLifeTotal() const = 0;
[[nodiscard]] virtual bool getShareDecklistsOnLoad() const = 0;
[[nodiscard]] virtual bool getShufflePlayers() const = 0;
[[nodiscard]] virtual bool getRememberGameSettings() const = 0;
[[nodiscard]] virtual bool getLocalGameRememberSettings() const = 0;
[[nodiscard]] virtual int getLocalGameMaxPlayers() const = 0;

View file

@ -746,7 +746,8 @@ QModelIndex DeckListModel::nodeToIndex(AbstractDecklistNode *node) const
void DeckListModel::sortShadowTree(InnerDecklistNode *node, Qt::SortOrder order)
{
// The mapping is not needed: fresh shadow nodes have no persistent indices yet.
(void)DeckListModelCustomZones::sortWithCustomZonesLast(root, node, order);
(void)DeckListModelCustomZones::sortWithCustomZonesLast(
root, node, order, activeGroupCriteria == DeckListModelGroupCriteria::MANA_COST && lastKnownColumn != 0);
for (int i = node->size() - 1; i >= 0; --i) {
if (auto *subNode = dynamic_cast<InnerDecklistNode *>(node->at(i))) {
@ -759,7 +760,8 @@ void DeckListModel::sortHelper(InnerDecklistNode *node, Qt::SortOrder order)
{
// Sort children (custom zones always sorted after groups within a board) and
// use the movement mapping to update the list of persistent indices.
const auto mapping = DeckListModelCustomZones::sortWithCustomZonesLast(root, node, order);
const auto mapping = DeckListModelCustomZones::sortWithCustomZonesLast(
root, node, order, activeGroupCriteria == DeckListModelGroupCriteria::MANA_COST && lastKnownColumn != 0);
QModelIndexList from, to;
int columns = columnCount();

View file

@ -8,6 +8,8 @@
#include <QHash>
#include <QSharedPointer>
#include <QVector>
#include <algorithm>
#include <cmath>
#include <utility>
namespace DeckListModelCustomZones
@ -111,7 +113,7 @@ QList<QPair<int, int>> plainSort(InnerDecklistNode *node, Qt::SortOrder order)
*
* @return The (preSortRow, finalRow) mapping covering both the sort and the shift.
*/
QList<QPair<int, int>> boardSort(InnerDecklistNode *node, Qt::SortOrder order)
QList<QPair<int, int>> boardSort(InnerDecklistNode *node, Qt::SortOrder order, bool numericGroups)
{
const QVector<QPair<int, int>> sortResult = node->sort(order);
@ -132,6 +134,27 @@ QList<QPair<int, int>> boardSort(InnerDecklistNode *node, Qt::SortOrder order)
}
}
if (numericGroups) {
// Only criteria groups are numeric. Mixing numeric and lexical fallback
// comparisons with custom zone names would not define a strict ordering.
std::sort(groups.begin(), groups.end(), [order](const auto *left, const auto *right) {
const QString leftName = left->getName();
const QString rightName = right->getName();
bool leftIsNumber, rightIsNumber;
const double leftValue = leftName.toDouble(&leftIsNumber);
const double rightValue = rightName.toDouble(&rightIsNumber);
leftIsNumber = leftIsNumber && std::isfinite(leftValue);
rightIsNumber = rightIsNumber && std::isfinite(rightValue);
if (leftIsNumber != rightIsNumber) {
return leftIsNumber;
}
if (leftIsNumber && leftValue != rightValue) {
return order == Qt::AscendingOrder ? leftValue < rightValue : leftValue > rightValue;
}
return order == Qt::AscendingOrder ? leftName < rightName : leftName > rightName;
});
}
QVector<AbstractDecklistNode *> ordered = groups + customZones;
for (int i = 0; i < ordered.size(); ++i) {
node->replace(i, ordered[i]);
@ -147,10 +170,11 @@ QList<QPair<int, int>> boardSort(InnerDecklistNode *node, Qt::SortOrder order)
} // namespace
QList<QPair<int, int>> sortWithCustomZonesLast(InnerDecklistNode *root, InnerDecklistNode *node, Qt::SortOrder order)
QList<QPair<int, int>>
sortWithCustomZonesLast(InnerDecklistNode *root, InnerDecklistNode *node, Qt::SortOrder order, bool numericGroups)
{
const bool isBoardZone = (node != root) && (node->getParent() == root);
return isBoardZone ? boardSort(node, order) : plainSort(node, order);
return isBoardZone ? boardSort(node, order, numericGroups) : plainSort(node, order);
}
} // namespace DeckListModelCustomZones

View file

@ -91,10 +91,15 @@ void mirrorCustomZones(const InnerDecklistNode *deckBoardZone, InnerDecklistNode
* @param root Root of the shadow tree (used to classify board zones).
* @param node The shadow node whose children are reordered.
* @param order Sort order to apply.
* @param numericGroups Compare criteria-group names as mana values. Custom zone
* names and card names retain their normal comparison; nonnumeric groups follow
* numeric groups in either sort direction.
* @return A list of (preSortRow, finalRow) pairs describing how each node moved.
*/
[[nodiscard]] QList<QPair<int, int>>
sortWithCustomZonesLast(InnerDecklistNode *root, InnerDecklistNode *node, Qt::SortOrder order);
[[nodiscard]] QList<QPair<int, int>> sortWithCustomZonesLast(InnerDecklistNode *root,
InnerDecklistNode *node,
Qt::SortOrder order,
bool numericGroups = false);
} // namespace DeckListModelCustomZones

View file

@ -21,6 +21,7 @@ struct GameConfig
bool spectatorsSeeEverything = true;
int startingLifeTotal = 20;
bool shareDecklistsOnLoad = true;
bool shufflePlayers = false;
};
#endif

View file

@ -43,6 +43,7 @@
#include <libcockatrice/protocol/pb/command_set_card_attr.pb.h>
#include <libcockatrice/protocol/pb/command_set_card_counter.pb.h>
#include <libcockatrice/protocol/pb/command_set_counter.pb.h>
#include <libcockatrice/protocol/pb/command_set_player_order.pb.h>
#include <libcockatrice/protocol/pb/command_set_playmat.pb.h>
#include <libcockatrice/protocol/pb/command_set_sideboard_lock.pb.h>
#include <libcockatrice/protocol/pb/command_set_sideboard_plan.pb.h>
@ -434,6 +435,33 @@ Response::ResponseCode Server_AbstractParticipant::cmdReverseTurn(const Command_
return Response::RespOk;
}
Response::ResponseCode Server_AbstractParticipant::cmdSetPlayerOrder(const Command_SetPlayerOrder &cmd,
ResponseContainer & /*rc*/,
GameEventStorage & /*ges*/)
{
if ((game->getHostId() != playerId) && !(userInfo->user_level() & ServerInfo_User::IsModerator)) {
return Response::RespFunctionNotAllowed;
}
if (game->getGameStarted()) {
return Response::RespContextError;
}
if (cmd.randomize()) {
game->shufflePlayerSeats();
} else {
QList<QString> playerNames;
for (int i = 0; i < cmd.player_names_size(); ++i) {
playerNames.append(QString::fromStdString(cmd.player_names(i)));
}
game->reorderPlayerSeats(playerNames);
}
// Clients learn the new seating from the Event_PlayerOrderChanged that
// reseatPlayers broadcasts; no full game state resend is needed for it.
return Response::RespOk;
}
Response::ResponseCode
Server_AbstractParticipant::processGameCommand(const GameCommand &command, ResponseContainer &rc, GameEventStorage &ges)
{
@ -543,6 +571,9 @@ Server_AbstractParticipant::processGameCommand(const GameCommand &command, Respo
case GameCommand::SET_PLAYMAT:
return cmdSetPlaymat(command.GetExtension(Command_SetPlaymat::ext), rc, ges);
break;
case GameCommand::SET_PLAYER_ORDER:
return cmdSetPlayerOrder(command.GetExtension(Command_SetPlayerOrder::ext), rc, ges);
break;
default:
return Response::RespInvalidCommand;
}

View file

@ -53,6 +53,7 @@ class Command_DeckSelect;
class Command_SetSideboardLock;
class Command_ChangeZoneProperties;
class Command_SetPlaymat;
class Command_SetPlayerOrder;
class Server_AbstractParticipant : public Server_ArrowTarget, public ServerInfo_User_Container
{
@ -89,6 +90,10 @@ public:
{
return playerId;
}
void setPlayerId(int _playerId)
{
playerId = _playerId;
}
bool isSpectator() const
{
return roleInfo.isSpectator;
@ -173,6 +178,8 @@ public:
virtual Response::ResponseCode
cmdReverseTurn(const Command_ReverseTurn & /*cmd*/, ResponseContainer & /*rc*/, GameEventStorage &ges);
virtual Response::ResponseCode
cmdSetPlayerOrder(const Command_SetPlayerOrder &cmd, ResponseContainer &rc, GameEventStorage &ges);
virtual Response::ResponseCode
cmdChangeZoneProperties(const Command_ChangeZoneProperties &cmd, ResponseContainer &rc, GameEventStorage &ges);
Response::ResponseCode processGameCommand(const GameCommand &command, ResponseContainer &rc, GameEventStorage &ges);

View file

@ -73,11 +73,13 @@
#include <libcockatrice/protocol/pb/event_join.pb.h>
#include <libcockatrice/protocol/pb/event_kicked.pb.h>
#include <libcockatrice/protocol/pb/event_leave.pb.h>
#include <libcockatrice/protocol/pb/event_player_order_changed.pb.h>
#include <libcockatrice/protocol/pb/event_player_properties_changed.pb.h>
#include <libcockatrice/protocol/pb/event_replay_added.pb.h>
#include <libcockatrice/protocol/pb/event_set_active_phase.pb.h>
#include <libcockatrice/protocol/pb/event_set_active_player.pb.h>
#include <libcockatrice/protocol/pb/game_replay.pb.h>
#include <libcockatrice/rng/rng_abstract.h>
#include <libcockatrice/utility/zone_names.h>
#include <qlogging.h>
#include <qnamespace.h>
@ -91,8 +93,8 @@ Server_Game::Server_Game(const GameConfig &config, Server_Room *_room)
spectatorsAllowed(config.spectatorsAllowed), spectatorsNeedPassword(config.spectatorsNeedPassword),
spectatorsCanTalk(config.spectatorsCanTalk), spectatorsSeeEverything(config.spectatorsSeeEverything),
startingLifeTotal(config.startingLifeTotal), shareDecklistsOnLoad(config.shareDecklistsOnLoad),
inactivityCounter(0), startTimeOfThisGame(0), secondsElapsed(0), firstGameStarted(false),
turnOrderReversed(false), startTime(QDateTime::currentDateTime()), pingClock(nullptr),
shufflePlayers(config.shufflePlayers), inactivityCounter(0), startTimeOfThisGame(0), secondsElapsed(0),
firstGameStarted(false), turnOrderReversed(false), startTime(QDateTime::currentDateTime()), pingClock(nullptr),
deckValidationStrategy(new Server_DefaultDeckValidationStrategy),
lifecycleStrategy(new Server_DefaultLifecycleStrategy), matchResultStrategy(new Server_NullMatchResultStrategy),
gameMutex()
@ -292,6 +294,32 @@ int Server_Game::getSpectatorCount() const
return result;
}
QList<Server_AbstractParticipant *> Server_Game::getParticipantsInSeatOrder() const
{
// Players in seat order first (fall back to id order for seats predating
// seatOrder), then spectators. Player ids are stable; only the ordering
// changes on shuffle/reorder.
QList<Server_AbstractParticipant *> ordered;
ordered.reserve(participants.size());
for (int id : seatOrder) {
auto *participant = participants.value(id, nullptr);
if (participant && !participant->isSpectator() && !ordered.contains(participant)) {
ordered.append(participant);
}
}
for (auto *participant : participants.values()) {
if (!participant->isSpectator() && !ordered.contains(participant)) {
ordered.append(participant);
}
}
for (auto *participant : participants.values()) {
if (participant->isSpectator()) {
ordered.append(participant);
}
}
return ordered;
}
void Server_Game::createGameStateChangedEvent(Event_GameStateChanged *event,
Server_AbstractParticipant *recipient,
bool omniscient,
@ -306,16 +334,24 @@ void Server_Game::createGameStateChangedEvent(Event_GameStateChanged *event,
event->set_game_started(false);
}
for (Server_AbstractParticipant *participant : participants.values()) {
// Emit the player list in seat order so every client (including old ones
// that ignore Event_PlayerOrderChanged) sees the current seating. Player
// ids are stable; only this ordering changes on shuffle/reorder.
QList<Server_AbstractParticipant *> ordered = getParticipantsInSeatOrder();
for (Server_AbstractParticipant *participant : ordered) {
participant->getInfo(event->add_player_list(), recipient, omniscient, withUserInfo);
}
if (recipient != nullptr) {
event->set_local_player_id(recipient->getPlayerId());
}
}
void Server_Game::sendGameStateToPlayers()
{
// game state information for replay and omniscient spectators
Event_GameStateChanged omniscientEvent;
createGameStateChangedEvent(&omniscientEvent, nullptr, true, false);
createGameStateChangedEvent(&omniscientEvent, nullptr, true, true);
GameEventContainer *replayCont = prepareGameEvent(omniscientEvent, -1);
replayCont->set_seconds_elapsed(secondsElapsed - startTimeOfThisGame);
@ -327,7 +363,7 @@ void Server_Game::sendGameStateToPlayers()
// the data we used for the replay. All spectators are equal, so we don't need to make a createGameStateChangedEvent
// call for each one.
Event_GameStateChanged spectatorNormalEvent;
createGameStateChangedEvent(&spectatorNormalEvent, nullptr, false, false);
createGameStateChangedEvent(&spectatorNormalEvent, nullptr, false, true);
// send game state info to clients according to their role in the game
for (auto *participant : participants.values()) {
@ -340,7 +376,7 @@ void Server_Game::sendGameStateToPlayers()
}
} else {
Event_GameStateChanged event;
createGameStateChangedEvent(&event, participant, participant->isJudge(), false);
createGameStateChangedEvent(&event, participant, participant->isJudge(), true);
gec = prepareGameEvent(event, -1);
}
@ -380,6 +416,11 @@ void Server_Game::doStartGameIfReady(bool forceStartGame)
return;
}
if (shufflePlayers && players.size() > 1) {
shufflePlayerSeats();
players = getPlayers();
}
for (Server_AbstractPlayer *player : players.values()) {
player->setupZones();
}
@ -433,6 +474,116 @@ void Server_Game::startGameIfReady(bool forceStartGame)
emit sigStartGameIfReady(forceStartGame);
}
void Server_Game::shufflePlayerSeats()
{
QMutexLocker locker(&gameMutex);
// Collect players in current seat order (fall back to id order for seats
// predating seatOrder). Spectators keep their seats.
QList<Server_AbstractParticipant *> players;
for (int id : seatOrder) {
auto *participant = participants.value(id, nullptr);
if (participant && !participant->isSpectator()) {
players.append(participant);
}
}
for (auto *participant : participants.values()) {
if (!participant->isSpectator() && !players.contains(participant)) {
players.append(participant);
}
}
QList<Server_AbstractParticipant *> spectators;
for (auto *participant : participants.values()) {
if (participant->isSpectator()) {
spectators.append(participant);
}
}
// Fisher-Yates shuffle of the players.
QList<Server_AbstractParticipant *> shuffledPlayers;
while (!players.isEmpty()) {
const int index = rng->rand(0, players.size() - 1);
shuffledPlayers.append(players.takeAt(index));
}
reseatPlayers(shuffledPlayers + spectators, true);
}
void Server_Game::reorderPlayerSeats(const QList<QString> &orderedNames)
{
QMutexLocker locker(&gameMutex);
// Collect players in current seat order (fall back to id order for seats
// predating seatOrder). Spectators keep their seats.
QList<Server_AbstractParticipant *> players;
for (int id : seatOrder) {
auto *participant = participants.value(id, nullptr);
if (participant && !participant->isSpectator()) {
players.append(participant);
}
}
for (auto *participant : participants.values()) {
if (!participant->isSpectator() && !players.contains(participant)) {
players.append(participant);
}
}
QList<Server_AbstractParticipant *> spectators;
for (auto *participant : participants.values()) {
if (participant->isSpectator()) {
spectators.append(participant);
}
}
// Order the players according to the given names, appending any player that
// was not listed (should not happen) in their current seat order.
QList<Server_AbstractParticipant *> orderedPlayers;
QSet<Server_AbstractParticipant *> placed;
for (const QString &name : orderedNames) {
for (auto *participant : players) {
if (!placed.contains(participant) && (QString::fromStdString(participant->getUserInfo()->name()) == name)) {
orderedPlayers.append(participant);
placed.insert(participant);
break;
}
}
}
for (auto *participant : players) {
if (!placed.contains(participant)) {
orderedPlayers.append(participant);
}
}
reseatPlayers(orderedPlayers + spectators, false);
}
void Server_Game::reseatPlayers(const QList<Server_AbstractParticipant *> &orderedParticipants, bool randomized)
{
QMutexLocker locker(&gameMutex);
// Keep player ids stable and only reorder the seating/turn order. Changing
// ids would break old clients (stale localPlayerId from Event_GameJoined
// duplicates their own seat as an opponent), invalidate the home-server
// per-user game/seat mapping used for ISL command routing, and orphan the
// host id. All of those maps stay valid because ids never change here.
// The current host therefore keeps its id and needs no re-key tracking.
seatOrder.clear();
for (auto *participant : orderedParticipants) {
if (!participant->isSpectator()) {
seatOrder.append(participant->getPlayerId());
}
}
// Broadcast the new seating order so that every client can display it.
Event_PlayerOrderChanged event;
for (auto *participant : orderedParticipants) {
if (!participant->isSpectator()) {
event.add_player_names(QString::fromStdString(participant->getUserInfo()->name()).toStdString());
}
}
event.set_randomized(randomized);
sendGameEventContainer(prepareGameEvent(event, -1));
}
void Server_Game::stopGameIfFinished()
{
QMutexLocker locker(&gameMutex);
@ -563,6 +714,7 @@ void Server_Game::addPlayer(Server_AbstractUserInterface *userInterface,
allSpectatorsEver.insert(playerName);
} else {
allPlayersEver.insert(playerName);
seatOrder.append(newParticipant->getPlayerId());
// if the original creator of the game joins, give them host status back
//! \todo Transferring host to spectators has side effects.
@ -592,6 +744,7 @@ void Server_Game::removeParticipant(Server_AbstractParticipant *participant, Eve
room->getServer()->removePersistentPlayer(QString::fromStdString(participant->getUserInfo()->name()), room->getId(),
gameId, participant->getPlayerId());
participants.remove(participant->getPlayerId());
seatOrder.removeAll(participant->getPlayerId());
bool spectator = participant->isSpectator();
GameEventStorage ges;
@ -611,11 +764,21 @@ void Server_Game::removeParticipant(Server_AbstractParticipant *participant, Eve
participant->prepareDestroy();
if (playerHost) {
// Pass host to the next player in seat order so the game always keeps
// a host while players remain.
int newHostId = -1;
for (int id : seatOrder) {
if (participants.contains(id)) {
newHostId = id;
break;
}
}
if (newHostId == -1) {
for (auto *otherPlayer : getPlayers().values()) {
newHostId = otherPlayer->getPlayerId();
break;
}
}
if (newHostId != -1) {
hostId = newHostId;
sendGameEventContainer(prepareGameEvent(Event_GameHostChanged(), hostId));
@ -771,7 +934,20 @@ void Server_Game::nextTurn()
}
auto players = getPlayers();
const QList<int> keys = players.keys();
// Advance in seat order, not id order, so shuffles/reorders change the
// turn sequence while ids stay stable.
QList<int> keys;
keys.reserve(players.size());
for (int id : seatOrder) {
if (players.contains(id)) {
keys.append(id);
}
}
for (int id : players.keys()) {
if (!keys.contains(id)) {
keys.append(id);
}
}
int listPos = -1;
if (activePlayer != -1) {
listPos = keys.indexOf(activePlayer);
@ -821,7 +997,7 @@ void Server_Game::createGameJoinedEvent(Server_AbstractParticipant *joiningParti
event2.set_active_phase(activePhase);
bool omniscient = (joiningParticipant->isSpectator() && spectatorsSeeEverything) || joiningParticipant->isJudge();
for (auto *participant : participants.values()) {
for (auto *participant : getParticipantsInSeatOrder()) {
participant->getInfo(event2.add_player_list(), joiningParticipant, omniscient, true);
}
@ -950,6 +1126,7 @@ void Server_Game::getInfo(ServerInfo_Game &result) const
result.set_spectators_can_chat(spectatorsCanTalk);
result.set_spectators_omniscient(spectatorsSeeEverything);
result.set_share_decklists_on_load(shareDecklistsOnLoad);
result.set_shuffle_players(shufflePlayers);
result.set_spectators_count(getSpectatorCount());
result.set_start_time(startTime.toSecsSinceEpoch());
}

View file

@ -74,6 +74,11 @@ private:
int hostId;
ServerInfo_User *creatorInfo;
QMap<int, Server_AbstractParticipant *> participants;
// Seating/turn order as an ordered list of player ids. Player ids themselves
// are stable for the lifetime of the participant; shuffling or reordering
// only permutes this list so old clients, ISL routes and persistent
// references (all keyed by player id) keep working.
QList<int> seatOrder;
QSet<QString> allPlayersEver, allSpectatorsEver;
bool gameStarted;
bool gameClosed;
@ -90,6 +95,7 @@ private:
bool spectatorsSeeEverything;
int startingLifeTotal;
bool shareDecklistsOnLoad;
bool shufflePlayers;
int inactivityCounter;
int startTimeOfThisGame, secondsElapsed;
bool firstGameStarted;
@ -109,6 +115,8 @@ private:
Server_AbstractParticipant *recipient,
bool omniscient,
bool withUserInfo);
QList<Server_AbstractParticipant *> getParticipantsInSeatOrder() const;
void reseatPlayers(const QList<Server_AbstractParticipant *> &orderedParticipants, bool randomized);
void storeGameInformation();
bool canSeePrivateEventsFor(const Server_AbstractParticipant *participant, int privatePlayerId) const;
signals:
@ -149,6 +157,10 @@ public:
{
return participants;
}
QList<int> getSeatOrder() const
{
return seatOrder;
}
int getGameId() const
{
return gameId;
@ -189,6 +201,10 @@ public:
{
return shareDecklistsOnLoad;
}
bool getShufflePlayers() const
{
return shufflePlayers;
}
Response::ResponseCode
checkJoin(ServerInfo_User *user, const QString &_password, const RoleInfo &roleInfo, bool overrideRestrictions);
bool containsUser(const QString &userName) const;
@ -202,6 +218,8 @@ public:
bool kickParticipant(int playerId);
void startGameIfReady(bool forceStartGame);
void stopGameIfFinished();
void shufflePlayerSeats();
void reorderPlayerSeats(const QList<QString> &orderedNames);
int getActivePlayer() const
{
return activePlayer;

View file

@ -1116,6 +1116,7 @@ Server_ProtocolHandler::cmdCreateGame(const Command_CreateGame &cmd, Server_Room
int startingLifeTotal = cmd.has_starting_life_total() ? cmd.starting_life_total() : 20;
bool shareDecklistsOnLoad = cmd.has_share_decklists_on_load() ? cmd.share_decklists_on_load() : false;
bool shufflePlayers = cmd.has_shuffle_players() ? cmd.shuffle_players() : false;
const int gameId = databaseInterface->getNextGameId();
if (gameId == -1) {
@ -1137,7 +1138,8 @@ Server_ProtocolHandler::cmdCreateGame(const Command_CreateGame &cmd, Server_Room
.spectatorsCanTalk = cmd.spectators_can_talk(),
.spectatorsSeeEverything = cmd.spectators_see_everything(),
.startingLifeTotal = startingLifeTotal,
.shareDecklistsOnLoad = shareDecklistsOnLoad};
.shareDecklistsOnLoad = shareDecklistsOnLoad,
.shufflePlayers = shufflePlayers};
auto *game = new Server_Game(config, room);

View file

@ -66,6 +66,7 @@ set(PROTO_FILES
command_set_card_attr.proto
command_set_card_counter.proto
command_set_counter.proto
command_set_player_order.proto
command_set_playmat.proto
command_set_sideboard_lock.proto
command_set_sideboard_plan.proto
@ -110,6 +111,7 @@ set(PROTO_FILES
event_list_rooms.proto
event_move_card.proto
event_notify_user.proto
event_player_order_changed.proto
event_player_properties_changed.proto
event_remove_from_list.proto
event_remove_messages.proto

View file

@ -0,0 +1,15 @@
syntax = "proto2";
import "game_commands.proto";
message Command_SetPlayerOrder {
extend GameCommand {
optional Command_SetPlayerOrder ext = 1206;
}
// The desired seating order, expressed as player names. Only used when
// randomize is false.
repeated string player_names = 1;
// When set, the server shuffles the seating order instead of using
// player_names.
optional bool randomize = 2;
}

View file

@ -24,4 +24,9 @@ message Event_GameStateChanged {
// the amount of seconds since the game started
optional uint32 seconds_elapsed = 5;
// the id of the player this event is addressed to. Used to re-identify the
// local player after seats have been shuffled. Only set for events sent to
// a specific participant, never for omniscient/replay events.
optional sint32 local_player_id = 6;
}

View file

@ -0,0 +1,13 @@
syntax = "proto2";
import "game_event.proto";
message Event_PlayerOrderChanged {
extend GameEvent {
optional Event_PlayerOrderChanged ext = 2023;
}
// The new seating order, expressed as player names.
repeated string player_names = 1;
// Whether the order was obtained by randomizing rather than set explicitly.
optional bool randomized = 2;
}

View file

@ -180,6 +180,11 @@ message GameCommand {
/// Server: Server_Player::cmdSetPlaymat
/// Client: reflected via player properties changed event
SET_PLAYMAT = 1035;
/// Set the seating (turn) order of the players.
/// Server: Server_AbstractParticipant::cmdSetPlayerOrder
/// Client: reflected via Event_PlayerOrderChanged
SET_PLAYER_ORDER = 1206;
}
extensions 100 to max;

View file

@ -34,6 +34,7 @@ message GameEvent {
CHANGE_ZONE_PROPERTIES = 2020;
REVERSE_TURN = 2021;
GAME_LOG_NOTICE = 2022;
PLAYER_ORDER_CHANGED = 2023;
}
optional sint32 player_id = 1 [default = -1];
extensions 100 to max;

View file

@ -78,6 +78,9 @@ message Command_CreateGame {
// share decklists with all players when selected
optional bool share_decklists_on_load = 14;
// shuffle the player order (seats) before each game starts
optional bool shuffle_players = 15;
}
message Command_JoinGame {

View file

@ -48,6 +48,9 @@ message ServerInfo_Game {
// decklists are sent to all players when loaded
optional bool share_decklists_on_load = 15;
// players are shuffled into new seats before each game starts
optional bool shuffle_players = 16;
// the current player count
optional uint32 player_count = 30;

View file

@ -71,6 +71,11 @@ bool GameSettings::getShareDecklistsOnLoad() const
return getValue("shareDecklistsOnLoad", "game").toBool();
}
bool GameSettings::getShufflePlayers() const
{
return getValue("shuffleplayers", "game").toBool();
}
bool GameSettings::getRememberGameSettings() const
{
return getValue("rememberGameSettings", "game", QString(), true).toBool();
@ -151,6 +156,11 @@ void GameSettings::setShareDecklistsOnLoad(bool _shareDecklistsOnLoad)
setValue(_shareDecklistsOnLoad, "shareDecklistsOnLoad", "game");
}
void GameSettings::setShufflePlayers(bool _shufflePlayers)
{
setValue(_shufflePlayers, "shuffleplayers", "game");
}
void GameSettings::setRememberGameSettings(bool _rememberGameSettings)
{
setValue(_rememberGameSettings, "rememberGameSettings", "game");

View file

@ -27,6 +27,7 @@ public:
[[nodiscard]] bool getCreateGameAsSpectator() const override;
[[nodiscard]] int getDefaultStartingLifeTotal() const override;
[[nodiscard]] bool getShareDecklistsOnLoad() const override;
[[nodiscard]] bool getShufflePlayers() const override;
[[nodiscard]] bool getRememberGameSettings() const override;
[[nodiscard]] bool getLocalGameRememberSettings() const override;
[[nodiscard]] int getLocalGameMaxPlayers() const override;
@ -44,6 +45,7 @@ public:
void setCreateGameAsSpectator(bool _createGameAsSpectator);
void setDefaultStartingLifeTotal(int _defaultStartingLifeTotal);
void setShareDecklistsOnLoad(bool _shareDecklistsOnLoad);
void setShufflePlayers(bool _shufflePlayers);
void setRememberGameSettings(bool _rememberGameSettings);
void setLocalGameRememberSettings(bool value);
void setLocalGameMaxPlayers(int value);

View file

@ -17,6 +17,7 @@ set(ORACLE_WIN32_QM_INSTALL_DIR "translations")
# Sources
# ------------------------
set(oracle_SOURCES
src/database_writer.cpp
src/main.cpp
src/oraclewizard.cpp
src/oracleimporter.cpp

View file

@ -0,0 +1,52 @@
#include "database_writer.h"
#include <QCoreApplication>
#include <QIODevice>
#include <QSaveFile>
#include <QXmlStreamReader>
namespace OracleDatabase
{
QString validateXml(const QByteArray &data)
{
QXmlStreamReader xml(data);
if (!xml.readNextStartElement() || xml.name() != QLatin1String("cockatrice_carddatabase")) {
return QCoreApplication::translate("OracleDatabase", "The download is not a Cockatrice card database.");
}
const auto version = xml.attributes().value("version");
if (version != QLatin1String("3") && version != QLatin1String("4")) {
return QCoreApplication::translate("OracleDatabase", "The downloaded card database version is not supported.");
}
while (!xml.atEnd()) {
xml.readNext();
}
if (xml.hasError()) {
return QCoreApplication::translate("OracleDatabase",
"The downloaded card database is incomplete or invalid: %1")
.arg(xml.errorString());
}
return {};
}
bool saveXml(const QString &fileName, const QByteArray &data, QString *error)
{
const QString validationError = validateXml(data);
if (!validationError.isEmpty()) {
if (error) {
*error = validationError;
}
return false;
}
QSaveFile file(fileName);
if (!file.open(QIODevice::WriteOnly) || file.write(data) != data.size() || !file.commit()) {
if (error) {
*error = file.errorString();
}
return false;
}
if (error) {
error->clear();
}
return true;
}
} // namespace OracleDatabase

View file

@ -0,0 +1,17 @@
#ifndef ORACLE_DATABASE_WRITER_H
#define ORACLE_DATABASE_WRITER_H
#include <QByteArray>
#include <QString>
namespace OracleDatabase
{
// Validate the complete downloaded document without allocating a card database.
// Empty string means valid; otherwise returns a diagnostic suitable for the wizard.
QString validateXml(const QByteArray &data);
// Never truncate the installed database: validate, write a sibling temporary file,
// check the complete write and atomically commit it.
bool saveXml(const QString &fileName, const QByteArray &data, QString *error = nullptr);
} // namespace OracleDatabase
#endif

View file

@ -100,6 +100,10 @@ static CardSet::Priority getSetPriority(const QString &setType, const QString &s
bool OracleImporter::readSetsFromByteArray(QByteArray data)
{
importSuccessful = false;
importError.clear();
allSets.clear();
rawSetsData.clear();
const RawJson::ScanProgressCallback progress =
progressReporting
? [this](
@ -764,7 +768,15 @@ int OracleImporter::startImport()
{
static ICardSetPriorityController *noOpController = new NoopCardSetPriorityController();
importCancelled.storeRelease(0);
importSuccessful = false;
importError.clear();
sets.clear();
cards.clear();
localizedEntries.clear();
splitLocalizedTexts.clear();
if (allSets.isEmpty()) {
return failImport(tr("No set has been selected for import."));
}
// Pre-allocate the cards hash to avoid rehashing during import. Keys are
// distinct card names while raw ranges only count printings (AllPrintings
@ -786,9 +798,7 @@ int OracleImporter::startImport()
for (const SetToDownload &curSetToParse : allSets) {
if (importCancelled.loadAcquire()) {
// The wizard was closed mid-import: stop at the next set boundary so
// the caller can wait for this future without processing every set.
break;
return failImport(tr("The card database import was cancelled."));
}
CardSetPtr newSet = CardSet::newInstance(noOpController, curSetToParse.getShortName(),
@ -798,30 +808,20 @@ int OracleImporter::startImport()
// parse only this set's slice of the raw document so the whole JSON tree is
// never kept in memory at once
const RawJson::SetDataRange &rawRange = curSetToParse.getRawRange();
const qsizetype rangeEnd = rawRange.start + rawRange.length;
if (rawRange.start < 0 || rawRange.length <= 0 || rangeEnd > rawSetsData.size()) {
// rawSetsData is cleared by releaseSetData() while SetToDownload copies
// taken from getSets() keep their ranges, and nothing else enforces the
// pairing — so never index past the buffer on stale/mismatched ranges.
qWarning() << "error: out-of-bounds raw range for set" << curSetToParse.getShortName() << "skipping";
++setIndex;
emit setIndexChanged(0, setIndex, curSetToParse.getLongName());
continue;
if (rawRange.start < 0 || rawRange.length <= 0 || rawRange.start > rawSetsData.size() ||
rawRange.length > rawSetsData.size() - rawRange.start) {
return failImport(tr("The source data for set %1 is unavailable. Load the sets file again.")
.arg(curSetToParse.getShortName()));
}
// sliced() shares the buffer instead of deep-copying the slice; the largest
// sets in AllPrintings are tens of MB, so the copy is worth avoiding here.
const QByteArray setBytes = rawSetsData.sliced(rawRange.start, rawRange.length);
QJsonParseError parseError;
const QJsonDocument setDoc = QJsonDocument::fromJson(setBytes, &parseError);
if (parseError.error != QJsonParseError::NoError) {
qWarning() << "error: parsing card data for set" << curSetToParse.getShortName() << ":"
<< parseError.errorString();
++setIndex;
// Keep the progress accounting honest: a set that failed to parse
// still advanced the index, so report it (with zero imported cards)
// rather than letting SaveSetsPage's bar stall per failed set.
emit setIndexChanged(0, setIndex, curSetToParse.getLongName());
continue;
return failImport(
tr("Could not import set %1: %2").arg(curSetToParse.getShortName(), parseError.errorString()));
}
if (!setDoc.isObject() || !setDoc.object().value("cards").isArray()) {
return failImport(tr("Set %1 does not contain a card list.").arg(curSetToParse.getShortName()));
}
// Only add the set to the database once its slice parsed cleanly;
@ -838,7 +838,14 @@ int OracleImporter::startImport()
emit setIndexChanged(numCardsInSet, setIndex, curSetToParse.getLongName());
}
if (importCancelled.loadAcquire()) {
return failImport(tr("The card database import was cancelled."));
}
if (cards.isEmpty()) {
return failImport(tr("No cards were imported. The existing card database has been kept."));
}
applyLocalizedData();
importSuccessful = true;
emit setIndexChanged(0, setIndex, QString());
@ -846,8 +853,23 @@ int OracleImporter::startImport()
return setIndex;
}
int OracleImporter::failImport(const QString &message)
{
importSuccessful = false;
importError = message;
sets.clear();
cards.clear();
localizedEntries.clear();
splitLocalizedTexts.clear();
qWarning() << message;
return 0;
}
bool OracleImporter::saveToFile(const QString &fileName, const QString &sourceUrl, const QString &sourceVersion)
{
if (!importSuccessful) {
return false;
}
CockatriceXml4Parser parser(new NoopCardSetPriorityController());
return parser.saveToFile(createDefaultMagicFormats(), sets, cards, fileName, sourceUrl, sourceVersion);
@ -861,6 +883,7 @@ void OracleImporter::releaseSetData()
void OracleImporter::clear()
{
prepareImport();
sets.clear();
cards.clear();
allSets.clear();

View file

@ -198,6 +198,11 @@ private:
* the whole import (or racing it).
*/
QAtomicInt importCancelled;
bool importSuccessful = false;
QString importError;
// Discard the incomplete in-memory database; the installed file is untouched.
int failImport(const QString &message);
/**
* The ISO-639 language code whose foreignData is imported; "en" by default.
@ -278,7 +283,24 @@ public:
* the wizard can hand over its decompressed buffer without copying it.
*/
bool readSetsFromByteArray(QByteArray data);
// Returns the complete selected-set count on success, zero on failure or cancellation.
int startImport();
bool hasSuccessfulImport() const
{
return importSuccessful;
}
const QString &getImportError() const
{
return importError;
}
// Call on the owning thread before scheduling a worker, so a cancellation
// arriving before that worker starts is not lost.
void prepareImport()
{
importCancelled.storeRelease(0);
importSuccessful = false;
importError.clear();
}
/**
* @brief Requests an in-flight startImport() to stop at the next set boundary.
*

View file

@ -1,6 +1,7 @@
#include "oraclewizard.h"
#include "client/settings/cache_settings.h"
#include "database_writer.h"
#include "main.h"
#include "oracleimporter.h"
#include "pages.h"
@ -141,6 +142,13 @@ void OracleWizard::reject()
}
QWizard::reject();
// A hidden wizard does not close the application's last visible window.
// Stop background refreshes explicitly so the hosting client receives a verdict,
// using the same rule as main(): a reject after the database was written is still
// a successful update, only a reject that wrote nothing counts as a failure.
if (backgroundMode) {
QCoreApplication::exit(hasSavedDatabase() ? 0 : 1);
}
}
void OracleWizard::runInBackground()
@ -212,17 +220,10 @@ void OracleWizard::disableButtons()
bool OracleWizard::saveTokensToFile(const QString &fileName)
{
QFile file(fileName);
if (!file.open(QIODevice::WriteOnly)) {
qDebug() << "File open (w) failed for" << fileName;
QString error;
if (!OracleDatabase::saveXml(fileName, tokensData, &error)) {
qWarning() << error;
return false;
}
if (file.write(tokensData) == -1) {
qDebug() << "File write (w) failed for" << fileName;
return false;
}
file.close();
return true;
}

View file

@ -1,6 +1,7 @@
#include "pages.h"
#include "client/settings/cache_settings.h"
#include "database_writer.h"
#include "main.h"
#include "oracleimporter.h"
#include "oraclewizard.h"
@ -43,6 +44,7 @@
#include <QWizard>
#include <QtConcurrentRun>
#include <QtGlobal>
#include <algorithm>
#include <cstdio>
#include <functional>
#include <libcockatrice/settings/personal_settings.h>
@ -169,7 +171,16 @@ LoadSetsResult loadSetsData(const QPointer<OracleImporter> &importer, QByteArray
break;
}
if (data.startsWith("<")) {
// Test the first non-space byte in place: trimmed() would duplicate the whole
// payload, which on the plain-XML path is the largest download we handle.
const auto firstNonSpace = std::find_if_not(data.cbegin(), data.cend(),
[](char c) { return c == ' ' || c == '\t' || c == '\n' || c == '\r'; });
const bool looksLikeXml = firstNonSpace != data.cend() && *firstNonSpace == '<';
if (looksLikeXml) {
result.errorMessage = OracleDatabase::validateXml(data);
if (!result.errorMessage.isEmpty()) {
return result;
}
result.ok = true;
result.plainXml = true;
result.xmlData = std::move(data);
@ -651,6 +662,10 @@ void LoadSetsPage::importFinished()
} else if (!result.ok && !result.plainXml) {
qWarning() << tr("The file was retrieved successfully, but it does not contain any sets data.");
}
if (!result.ok) {
wizard()->reject();
return;
}
emit readyToContinue();
return;
}
@ -717,7 +732,7 @@ SaveSetsPage::SaveSetsPage(QWidget *parent) : OracleWizardPage(parent)
bool SaveSetsPage::isComplete() const
{
return !importActive;
return !importActive && importReady;
}
void SaveSetsPage::cleanupPage()
@ -730,8 +745,11 @@ void SaveSetsPage::cleanupPage()
void SaveSetsPage::initializePage()
{
importReady = false;
retranslateUi();
if (wizard()->downloadedPlainXml) {
importReady = true;
emit completeChanged();
messageLog->hide();
progressBar->hide();
if (wizard()->backgroundMode) {
@ -757,6 +775,9 @@ void SaveSetsPage::initializePage()
emit completeChanged();
const QPointer<OracleImporter> importer = wizard()->importer;
if (auto *activeImporter = importer.data()) {
activeImporter->prepareImport();
}
importFuture = QtConcurrent::run([importer] { return importer ? importer->startImport() : 0; });
importWatcher.setFuture(importFuture);
}
@ -788,22 +809,28 @@ void SaveSetsPage::importFinished()
const int setsImported = importWatcher.result();
const QPointer<OracleImporter> importer = wizard()->importer;
importReady = importer && importer->hasSuccessfulImport() && setsImported == totalSets;
const QString error = importer ? importer->getImportError() : tr("The card database importer is unavailable.");
if (importer) {
importer->releaseSetData();
}
wizard()->button(QWizard::NextButton)->setEnabled(importReady);
emit completeChanged();
if (!importReady) {
const QString message = error.isEmpty() ? tr("No set has been imported.") : error;
if (wizard()->backgroundMode) {
if (setsImported == 0) {
qWarning() << tr("No set has been imported.");
qWarning() << message;
wizard()->reject();
} else {
QMessageBox::critical(this, tr("Error"), message);
}
emit readyToContinue();
return;
}
progressBar->setValue(progressBar->maximum());
if (setsImported == 0) {
QMessageBox::critical(this, tr("Error"), tr("No set has been imported."));
if (wizard()->backgroundMode) {
emit readyToContinue();
}
}
@ -854,6 +881,18 @@ void SaveSetsPage::updateTotalProgress(int cardsImported, int setIndex, const QS
bool SaveSetsPage::validatePage()
{
if (!isComplete()) {
return false;
}
const auto failSave = [this](const QString &message) {
qWarning() << message;
if (wizard()->backgroundMode) {
wizard()->reject();
} else {
QMessageBox::critical(this, tr("Error"), message);
}
return false;
};
QString defaultPath = SettingsCache::instance().getCardDatabasePath();
QString windowName = tr("Save card database");
QString fileType = tr("XML; card database (*.xml)");
@ -872,24 +911,21 @@ bool SaveSetsPage::validatePage()
QFileInfo fi(fileName);
QDir fileDir(fi.path());
if (!fileDir.exists() && !fileDir.mkpath(fileDir.absolutePath())) {
return false;
return failSave(tr("The database directory could not be created: %1").arg(fileDir.absolutePath()));
}
if (wizard()->downloadedPlainXml) {
QFile file(fileName);
if (!file.open(QIODevice::WriteOnly)) {
qDebug() << "File write (w) failed for" << fileName;
return false;
}
if (file.write(wizard()->xmlData) < 1) {
qDebug() << "File write (w) failed for" << fileName;
return false;
QString error;
if (!OracleDatabase::saveXml(fileName, wizard()->xmlData, &error)) {
return failSave(tr("The file could not be saved to %1: %2").arg(fileName, error));
}
wizard()->xmlData.clear();
} else if (!wizard()->importer->saveToFile(fileName, wizard()->getCardSourceUrl(),
wizard()->getCardSourceVersion())) {
QMessageBox::critical(this, tr("Error"), tr("The file could not be saved to %1").arg(fileName));
return false;
// The importer refuses to write a database whose import did not finish, and
// reports the reason there; do not dress an import failure up as a disk failure.
const QString importError = wizard()->importer->getImportError();
return failSave(importError.isEmpty() ? tr("The file could not be saved to %1").arg(fileName) : importError);
}
wizard()->markDatabaseSaved();

View file

@ -128,6 +128,7 @@ private:
QFuture<int> importFuture;
int totalSets = 0;
bool importActive = false;
bool importReady = false;
protected:
void initializePage() override;

View file

@ -1,5 +1,6 @@
#include "pagetemplates.h"
#include "database_writer.h"
#include "oraclewizard.h"
#include <QCheckBox>
@ -184,9 +185,17 @@ void SimpleDownloadFilePage::actDownloadFinished()
}
QNetworkReply::NetworkError errorCode = reply->error();
if (errorCode != QNetworkReply::NoError) {
QMessageBox::critical(this, tr("Error"), tr("Network error: %1.").arg(reply->errorString()));
wizard()->enableButtons();
const QString message = tr("Network error: %1.").arg(reply->errorString());
reply->deleteLater();
// A hidden wizard cannot show a modal box: without an explicit stop it would sit
// here forever and the hosting client would wait with no timeout.
if (wizard()->backgroundMode) {
qWarning() << message;
wizard()->reject();
return;
}
QMessageBox::critical(this, tr("Error"), message);
wizard()->enableButtons();
return;
}
@ -218,6 +227,15 @@ void SimpleDownloadFilePage::actDownloadFinished()
bool SimpleDownloadFilePage::saveToFile()
{
const auto failSave = [this](const QString &message) {
qWarning() << message;
if (wizard()->backgroundMode) {
wizard()->reject();
} else {
QMessageBox::critical(this, tr("Error"), message);
}
return false;
};
QString defaultPath = getDefaultSavePath();
QString windowName = getWindowTitle();
QString fileType = getFileType();
@ -236,12 +254,11 @@ bool SimpleDownloadFilePage::saveToFile()
QFileInfo fi(fileName);
QDir fileDir(fi.path());
if (!fileDir.exists() && !fileDir.mkpath(fileDir.absolutePath())) {
return false;
return failSave(tr("The database directory could not be created: %1").arg(fileDir.absolutePath()));
}
if (!internalSaveToFile(fileName)) {
QMessageBox::critical(this, tr("Error"), tr("The file could not be saved to %1").arg(fileName));
return false;
return failSave(tr("The file could not be saved to %1").arg(fileName));
}
// clean saved downloadData
@ -252,17 +269,10 @@ bool SimpleDownloadFilePage::saveToFile()
bool SimpleDownloadFilePage::internalSaveToFile(const QString &fileName)
{
QFile file(fileName);
if (!file.open(QIODevice::WriteOnly)) {
qDebug() << "File open (w) failed for" << fileName;
QString error;
if (!OracleDatabase::saveXml(fileName, downloadData, &error)) {
qWarning() << error;
return false;
}
if (file.write(downloadData) == -1) {
qDebug() << "File write (w) failed for" << fileName;
return false;
}
file.close();
return true;
}

View file

@ -220,6 +220,18 @@ cockatrice_add_test(
libcockatrice_deck_list
libcockatrice_utility
)
cockatrice_add_test(
NAME
card_art_crop_test
SOURCES
${CMAKE_SOURCE_DIR}/cockatrice/src/interface/widgets/cards/card_art_utils.cpp
${VERSION_STRING_CPP}
card_art_crop_test.cpp
LIBS
libcockatrice_card
INCLUDE_DIRS
${CMAKE_SOURCE_DIR}/cockatrice/src
)
cockatrice_add_test(
NAME
server_rate_limiter_test

View file

@ -0,0 +1,92 @@
#include "interface/widgets/cards/card_art_utils.h"
#include <QApplication>
#include <QImage>
#include <QPainter>
#include <QTransform>
#include <gtest/gtest.h>
#include <libcockatrice/card/card_info.h>
#include <libcockatrice/card/printing/exact_card.h>
namespace
{
ExactCard makeCard(bool landscape)
{
CardInfo::UiAttributes attributes;
attributes.landscapeOrientation = landscape;
return ExactCard(CardInfo::newInstance("Artwork", "", false, {}, {}, {}, {}, attributes));
}
QPixmap landscapeCardWithFrame()
{
// Normalized landscape frame: the title reaches 14% down the upright
// image, and the Battle type line starts at 56%. Artwork has two colors
// so the test also detects a wrong or missing rotation.
QImage upright(1400, 1000, QImage::Format_RGB32);
upright.fill(Qt::red);
QPainter painter(&upright);
painter.fillRect(QRect(70, 140, 1260, 420), Qt::green);
painter.fillRect(QRect(700, 140, 630, 420), Qt::blue);
painter.end();
QTransform storedOrientation;
storedOrientation.rotate(-90);
return QPixmap::fromImage(upright.transformed(storedOrientation));
}
TEST(CardArtCrop, LandscapeCropExcludesNameAndRulesBoxesAndRotatesUpright)
{
const QImage crop = CardArtUtils::cropCardArt(landscapeCardWithFrame(), makeCard(true)).toImage();
ASSERT_GT(crop.width(), crop.height());
ASSERT_FALSE(crop.isNull());
for (int y = 0; y < crop.height(); ++y) {
for (int x = 0; x < crop.width(); ++x) {
EXPECT_EQ(x < crop.width() / 2 ? QColor(Qt::green) : QColor(Qt::blue), crop.pixelColor(x, y));
}
}
}
TEST(CardArtCrop, PortraitCropRetainsItsExistingArtworkBounds)
{
QImage source(745, 1040, QImage::Format_RGB32);
for (int y = 0; y < source.height(); ++y) {
for (int x = 0; x < source.width(); ++x) {
source.setPixelColor(x, y, QColor(x % 256, y % 256, (x + y) % 256));
}
}
const QPixmap input = QPixmap::fromImage(source);
const QImage crop = CardArtUtils::cropCardArt(input, makeCard(false)).toImage();
EXPECT_EQ(source.copy(QRect(52, 114, 641, 458)), crop);
}
TEST(CardArtCrop, DevicePixelRatioDoesNotChangeTheArtworkPixels)
{
QPixmap source = landscapeCardWithFrame();
const QPixmap normal = CardArtUtils::cropCardArt(source, makeCard(true));
source.setDevicePixelRatio(2.0);
const QPixmap highDpi = CardArtUtils::cropCardArt(source, makeCard(true));
EXPECT_EQ(normal.size(), highDpi.size());
EXPECT_EQ(2.0, highDpi.devicePixelRatio());
QImage normalPixels = normal.toImage();
QImage highDpiPixels = highDpi.toImage();
normalPixels.setDevicePixelRatio(1.0);
highDpiPixels.setDevicePixelRatio(1.0);
EXPECT_EQ(normalPixels, highDpiPixels);
}
TEST(CardArtCrop, EmptyAndSmallImagesStayWithinTheirBounds)
{
EXPECT_TRUE(CardArtUtils::cropCardArt({}, makeCard(true)).isNull());
QPixmap small(1, 1);
small.fill(Qt::green);
EXPECT_EQ(small.toImage(), CardArtUtils::cropCardArt(small, makeCard(true)).toImage());
EXPECT_EQ(small.toImage(), CardArtUtils::cropCardArt(small, makeCard(false)).toImage());
}
} // namespace
int main(int argc, char **argv)
{
qputenv("QT_QPA_PLATFORM", "offscreen");
QApplication app(argc, argv);
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}

View file

@ -36,6 +36,12 @@
#include <libcockatrice/utility/card_ref.h>
#include <string>
#ifdef Q_OS_UNIX
#include <csignal>
#include <sys/resource.h>
#include <unistd.h>
#endif
namespace
{
@ -204,6 +210,64 @@ TEST(CardDatabaseTest, Xml4LocalizedDataRoundTrip)
ASSERT_EQ(printings.first().getProperty("manacost"), "1R");
}
#ifdef Q_OS_UNIX
/**
* @brief A failed write keeps the previously installed card database intact.
*
* This is the writer Oracle uses to install a refreshed database, so the truncation
* bug has to be covered here and not only through OracleDatabase::saveXml.
*/
TEST(CardDatabaseTest, Xml4ShortWriteKeepsInstalledFile)
{
NoopCardSetPriorityController controller;
CardSetPtr set =
CardSet::newInstance(&controller, "TST", "Test Set", "expansion", QDate(), CardSet::PriorityPrimary);
QHash<QString, QString> props;
props["manacost"] = "1R";
PrintingInfo printing(set, LazyPropertiesHash(props));
SetToPrintingsMap setsInfo;
setsInfo["TST"].append(printing);
CardInfo::UiAttributes attributes = {.tableRow = 1};
CardInfoPtr card =
CardInfo::newInstance("Lightning Bolt", QString(1024, 'x'), false, {}, {}, {}, setsInfo, attributes);
SetNameMap sets;
sets.insert("TST", set);
CardNameMap cards;
cards.insert("Lightning Bolt", card);
QTemporaryDir tempDir;
ASSERT_TRUE(tempDir.isValid());
const QString fileName = tempDir.filePath("cards.xml");
QFile installed(fileName);
ASSERT_TRUE(installed.open(QIODevice::WriteOnly));
ASSERT_EQ(installed.write("installed database"), 18);
installed.close();
// A real filesystem write failure, isolated in the death-test child so the
// process-wide file limit never affects other cases or the parent runner.
ASSERT_EXIT(
{
std::signal(SIGXFSZ, SIG_IGN);
struct rlimit limit;
limit.rlim_cur = 128;
limit.rlim_max = 128;
if (setrlimit(RLIMIT_FSIZE, &limit) != 0) {
_exit(2);
}
CockatriceXml4Parser writer(&controller);
_exit(writer.saveToFile({}, sets, cards, fileName) ? 1 : 0);
},
::testing::ExitedWithCode(0), "");
ASSERT_TRUE(installed.open(QIODevice::ReadOnly));
EXPECT_EQ(installed.readAll(), "installed database");
}
#endif
/**
* @brief Path provider serving a sandboxed copy of the test card database.
*/

View file

@ -278,6 +278,37 @@ TEST(DeckListModelCustomZones, SortPlainNodeDoesNotReorderCustomZones)
EXPECT_EQ(mapping[1].second, 1);
}
TEST(DeckListModelCustomZones, ManaGroupsUseNumericOrderWithoutChangingOtherNodes)
{
InnerDecklistNode root;
auto *board = new InnerDecklistNode(DECK_ZONE_MAIN, &root);
const QStringList original = {"10", "2", "0.5", "1000000", "1", "unknown", "nan"};
for (const auto &name : original) {
new InnerDecklistNode(name, board);
}
auto *custom = new DecklistModelSubZoneNode("1x", board);
new InnerDecklistNode("10", custom);
new InnerDecklistNode("2", custom);
root.setSortMethod(DeckSortMethod::ByName);
const auto mapping = DeckListModelCustomZones::sortWithCustomZonesLast(&root, board, Qt::AscendingOrder, true);
EXPECT_EQ(childNames(board), (QStringList{"0.5", "1", "2", "10", "1000000", "nan", "unknown", "1x"}));
QStringList before = original;
before.append("1x");
for (const auto &move : mapping) {
EXPECT_EQ(board->at(move.second)->getName(), before.at(move.first));
}
(void)DeckListModelCustomZones::sortWithCustomZonesLast(&root, board, Qt::DescendingOrder, true);
EXPECT_EQ(childNames(board), (QStringList{"1000000", "10", "2", "1", "0.5", "unknown", "nan", "1x"}));
// The numeric flag only affects groups directly under a board.
(void)DeckListModelCustomZones::sortWithCustomZonesLast(&root, custom, Qt::AscendingOrder, true);
EXPECT_EQ(childNames(custom), (QStringList{"10", "2"}));
(void)DeckListModelCustomZones::sortWithCustomZonesLast(&root, board, Qt::AscendingOrder);
EXPECT_EQ(childNames(board), (QStringList{"0.5", "1", "10", "1000000", "2", "nan", "unknown", "1x"}));
}
int main(int argc, char **argv)
{
::testing::InitGoogleTest(&argc, argv);

View file

@ -393,6 +393,28 @@ TEST(DeckListZones, MaybeboardCustomZoneCardsAreExcludedFromHash)
EXPECT_EQ(deck.getDeckHash(), expected.getDeckHash());
}
TEST(DeckListZones, PlainExportPutsMainFirstWithoutReorderingSavedZones)
{
DeckList deck;
auto *tree = deck.getTree();
ASSERT_NE(tree->addCustomZone(DECK_ZONE_SIDE, "Side Tech"), nullptr);
tree->addCard("Side Card", 2, "Side Tech", -1);
tree->addCard("Token Card", 1, DECK_ZONE_TOKENS, -1);
ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr);
tree->addCard("Main Card", 3, "Removal", -1);
ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAYBEBOARD, "Candidates"), nullptr);
tree->addCard("Maybe Card", 4, "Candidates", -1);
const QString native = deck.writeToString_Native();
EXPECT_EQ(deck.writeToString_Plain(true, false), QString("3 Main Card\nSB: 2 Side Card\n1 Token Card\n"));
EXPECT_EQ(deck.writeToString_Plain(false, false), QString("3 Main Card\n2 Side Card\n1 Token Card\n"));
EXPECT_EQ(deck.writeToString_Native(), native);
DeckList reopened(native);
EXPECT_EQ(reopened.getZoneNodes().first()->getName(), DECK_ZONE_SIDE);
EXPECT_EQ(reopened.writeToString_Plain(true, false), deck.writeToString_Plain(true, false));
}
int main(int argc, char **argv)
{
::testing::InitGoogleTest(&argc, argv);

View file

@ -9,3 +9,15 @@ cockatrice_add_test(
LABELS
server
)
cockatrice_add_test(
NAME
player_order_test
SOURCES
player_order_test.cpp
LIBS
libcockatrice_network_server_remote
libcockatrice_rng
LABELS
server
)

View file

@ -0,0 +1,268 @@
#include "game/game_config.h"
#include "game/role_info.h"
#include "game/server_abstract_participant.h"
#include "game/server_game.h"
#include "server_abstractuserinterface.h"
#include "server_response_containers.h"
#include "server_room.h"
#include "server_test_helpers.h"
#include <gtest/gtest.h>
#include <libcockatrice/protocol/pb/game_event_container.pb.h>
#include <libcockatrice/protocol/pb/response.pb.h>
#include <libcockatrice/protocol/pb/room_event.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_user.pb.h>
#include <libcockatrice/protocol/pb/session_event.pb.h>
#include <libcockatrice/rng/rng_abstract.h>
RNG_Abstract *rng = nullptr; // this needs to be defined due to other functions in server
class ScriptedRng : public RNG_Abstract
{
public:
QList<unsigned int> values;
unsigned int rand(int min, int max) override
{
const unsigned int value = values.takeFirst();
EXPECT_GE(value, static_cast<unsigned int>(min));
EXPECT_LE(value, static_cast<unsigned int>(max));
return value;
}
};
class FakeUserInterface : public Server_AbstractUserInterface
{
public:
FakeUserInterface(Server *_server, const ServerInfo_User &_userInfo) : Server_AbstractUserInterface(_server)
{
setUserInfo(_userInfo);
}
int getLastCommandTime() const override
{
return 0;
}
bool addSaidMessageSize(int) override
{
return true;
}
void sendProtocolItem(const Response &) override
{
}
void sendProtocolItem(const SessionEvent &) override
{
}
void sendProtocolItem(const GameEventContainer &) override
{
}
void sendProtocolItem(const RoomEvent &) override
{
}
};
namespace
{
QStringList participantNamesInOrder(const Server_Game &game)
{
QStringList names;
for (int id : game.getSeatOrder()) {
const auto *participant = game.getParticipants().value(id, nullptr);
if (participant) {
names.append(QString::fromStdString(participant->getUserInfo()->name()));
}
}
return names;
}
QList<int> participantIdsInOrder(const Server_Game &game)
{
return game.getSeatOrder();
}
} // namespace
class PlayerOrderTest : public ::testing::Test
{
protected:
ServerInfo_User creator;
ServerInfo_User userB;
ServerInfo_User userC;
PlayerOrderTest()
{
creator.set_name("A");
creator.set_user_level(ServerInfo_User::IsRegistered);
userB.set_name("B");
userB.set_user_level(ServerInfo_User::IsRegistered);
userC.set_name("C");
userC.set_user_level(ServerInfo_User::IsRegistered);
}
};
TEST_F(PlayerOrderTest, JoinOrderIsSeatOrder)
{
FakeServer server;
Server_Room room(0, 0, "", "", "", "", false, "", {}, &server);
FakeUserInterface uiA(&server, creator);
FakeUserInterface uiB(&server, userB);
FakeUserInterface uiC(&server, userC);
GameConfig config{.creatorInfo = creator,
.gameId = 1,
.description = QString(),
.password = QString(),
.maxPlayers = 3,
.gameTypes = QList<int>(),
.onlyBuddies = false,
.onlyRegistered = false,
.spectatorsAllowed = false,
.spectatorsNeedPassword = false,
.spectatorsCanTalk = false,
.spectatorsSeeEverything = false,
.startingLifeTotal = 20,
.shareDecklistsOnLoad = false,
.shufflePlayers = false};
Server_Game game(config, &room);
ResponseContainer rc(0);
game.addPlayer(&uiA, rc, RoleInfo{}, false);
game.addPlayer(&uiB, rc, RoleInfo{}, false);
game.addPlayer(&uiC, rc, RoleInfo{}, false);
EXPECT_EQ(participantNamesInOrder(game), QStringList({"A", "B", "C"}));
EXPECT_EQ(participantIdsInOrder(game), QList<int>({0, 1, 2}));
EXPECT_EQ(game.getHostId(), 0);
EXPECT_EQ(game.getPlayerCount(), 3);
}
TEST_F(PlayerOrderTest, ReorderPlayerSeats)
{
FakeServer server;
Server_Room room(0, 0, "", "", "", "", false, "", {}, &server);
FakeUserInterface uiA(&server, creator);
FakeUserInterface uiB(&server, userB);
FakeUserInterface uiC(&server, userC);
GameConfig config{.creatorInfo = creator,
.gameId = 1,
.description = QString(),
.password = QString(),
.maxPlayers = 3,
.gameTypes = QList<int>(),
.onlyBuddies = false,
.onlyRegistered = false,
.spectatorsAllowed = false,
.spectatorsNeedPassword = false,
.spectatorsCanTalk = false,
.spectatorsSeeEverything = false,
.startingLifeTotal = 20,
.shareDecklistsOnLoad = false,
.shufflePlayers = false};
Server_Game game(config, &room);
ResponseContainer rc(0);
game.addPlayer(&uiA, rc, RoleInfo{}, false);
game.addPlayer(&uiB, rc, RoleInfo{}, false);
game.addPlayer(&uiC, rc, RoleInfo{}, false);
game.reorderPlayerSeats({"C", "A", "B"});
// player ids are stable; only the seat order changes
EXPECT_EQ(participantNamesInOrder(game), QStringList({"C", "A", "B"}));
EXPECT_EQ(participantIdsInOrder(game), QList<int>({2, 0, 1}));
EXPECT_EQ(game.getParticipants().size(), 3);
// the host keeps its seat: creator A is still in the game, so host stays 0
EXPECT_EQ(game.getHostId(), 0);
// no re-keying means every mapping keyed by player id stays valid
const int gameId = game.getGameId();
EXPECT_EQ(uiA.getGames().value(gameId).second, 0);
EXPECT_EQ(uiB.getGames().value(gameId).second, 1);
EXPECT_EQ(uiC.getGames().value(gameId).second, 2);
}
TEST_F(PlayerOrderTest, ReorderPlayerSeatsKeepsPlayerCount)
{
FakeServer server;
Server_Room room(0, 0, "", "", "", "", false, "", {}, &server);
FakeUserInterface uiA(&server, creator);
FakeUserInterface uiB(&server, userB);
FakeUserInterface uiC(&server, userC);
GameConfig config{.creatorInfo = creator,
.gameId = 1,
.description = QString(),
.password = QString(),
.maxPlayers = 3,
.gameTypes = QList<int>(),
.onlyBuddies = false,
.onlyRegistered = false,
.spectatorsAllowed = false,
.spectatorsNeedPassword = false,
.spectatorsCanTalk = false,
.spectatorsSeeEverything = false,
.startingLifeTotal = 20,
.shareDecklistsOnLoad = false,
.shufflePlayers = false};
Server_Game game(config, &room);
ResponseContainer rc(0);
game.addPlayer(&uiA, rc, RoleInfo{}, false);
game.addPlayer(&uiB, rc, RoleInfo{}, false);
game.addPlayer(&uiC, rc, RoleInfo{}, false);
// listing only a subset appends the remaining players in their current order
game.reorderPlayerSeats({"C"});
EXPECT_EQ(participantNamesInOrder(game), QStringList({"C", "A", "B"}));
EXPECT_EQ(participantIdsInOrder(game), QList<int>({2, 0, 1}));
EXPECT_EQ(game.getPlayerCount(), 3);
EXPECT_EQ(game.getHostId(), 0);
}
TEST_F(PlayerOrderTest, ShufflePlayerSeatsIsAPermutation)
{
FakeServer server;
Server_Room room(0, 0, "", "", "", "", false, "", {}, &server);
FakeUserInterface uiA(&server, creator);
FakeUserInterface uiB(&server, userB);
FakeUserInterface uiC(&server, userC);
GameConfig config{.creatorInfo = creator,
.gameId = 1,
.description = QString(),
.password = QString(),
.maxPlayers = 3,
.gameTypes = QList<int>(),
.onlyBuddies = false,
.onlyRegistered = false,
.spectatorsAllowed = false,
.spectatorsNeedPassword = false,
.spectatorsCanTalk = false,
.spectatorsSeeEverything = false,
.startingLifeTotal = 20,
.shareDecklistsOnLoad = false,
.shufflePlayers = false};
Server_Game game(config, &room);
ResponseContainer rc(0);
game.addPlayer(&uiA, rc, RoleInfo{}, false);
game.addPlayer(&uiB, rc, RoleInfo{}, false);
game.addPlayer(&uiC, rc, RoleInfo{}, false);
// fisher-yates on [A, B, C]: rand(0, 2) -> 1 (take B), rand(0, 1) -> 0 (take A), rand(0, 0) -> 0 (take C)
auto *scriptedRng = new ScriptedRng;
scriptedRng->values = {1, 0, 0};
rng = scriptedRng;
game.shufflePlayerSeats();
EXPECT_EQ(participantNamesInOrder(game), QStringList({"B", "A", "C"}));
EXPECT_EQ(participantIdsInOrder(game), QList<int>({1, 0, 2}));
QSet<int> idSet(participantIdsInOrder(game).cbegin(), participantIdsInOrder(game).cend());
EXPECT_EQ(idSet.size(), 3); // ids are unique
// ids are stable, so the host (creator A, id 0) is unchanged
EXPECT_EQ(game.getHostId(), 0);
}

View file

@ -9,6 +9,7 @@ cockatrice_add_test(
../../oracle/src/oracleimporter.cpp
../../oracle/src/parsehelpers.cpp
../../oracle/src/raw_json_scanner.cpp
../../oracle/src/database_writer.cpp
oracle_importer_test.cpp
LIBS
libcockatrice_card

View file

@ -1,3 +1,4 @@
#include "../../oracle/src/database_writer.h"
#include "../../oracle/src/oracleimporter.h"
#include "../../oracle/src/raw_json_scanner.h"
#include "libcockatrice/card/card_info.h"
@ -6,6 +7,7 @@
#include <QByteArray>
#include <QChar>
#include <QDate>
#include <QFile>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
@ -20,6 +22,7 @@
#include <QRegularExpressionMatch>
#include <QSharedPointer>
#include <QString>
#include <QTemporaryDir>
#include <QUuid>
#include <QVariant>
#include <QVariantMap>
@ -30,6 +33,12 @@
#include <string>
#include <utility>
#ifdef Q_OS_UNIX
#include <csignal>
#include <sys/resource.h>
#include <unistd.h>
#endif
class OracleImporterTest : public ::testing::Test
{
protected:
@ -1140,6 +1149,212 @@ TEST_F(OracleImporterTest, StartImportAppliesLocalizedData)
ASSERT_EQ(result->getLocalizedText("pt"), "Raio causa 3 de dano a qualquer alvo.");
}
namespace
{
QByteArray refreshSource(const QString &name = "New Card")
{
QJsonObject card{{"name", name}, {"text", "Rules text."}, {"layout", "normal"},
{"type", "Artifact"}, {"number", "1"}, {"rarity", "common"}};
QJsonObject set{{"code", "TST"}, {"name", "Test Set"}, {"type", "expansion"}, {"cards", QJsonArray{card}}};
return QJsonDocument(QJsonObject{{"data", QJsonObject{{"TST", set}}}}).toJson(QJsonDocument::Compact);
}
} // namespace
TEST_F(OracleImporterTest, FailedLazyParseKeepsInstalledDatabase)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
const QString path = dir.filePath("cards.xml");
QFile original(path);
ASSERT_TRUE(original.open(QIODevice::WriteOnly));
ASSERT_EQ(original.write("existing database"), 17);
original.close();
// The metadata scanner skips numeric values without converting them. Qt's
// per-set parser rejects an overflowing number after selection succeeds.
auto badSet = QJsonDocument::fromJson(refreshSource()).object().value("data").toObject().value("TST").toObject();
auto goodSet = badSet;
goodSet["code"] = "AAA";
goodSet["name"] = "A successful first set";
auto badCard = badSet.value("cards").toArray().first().toObject();
badCard["number"] = "overflow";
badSet["cards"] = QJsonArray{badCard};
QByteArray data = QJsonDocument(QJsonObject{{"data", QJsonObject{{"AAA", goodSet}, {"TST", badSet}}}})
.toJson(QJsonDocument::Compact);
data.replace("\"number\":\"overflow\"", "\"number\":1e9999");
ASSERT_TRUE(importer->readSetsFromByteArray(data));
EXPECT_EQ(importer->startImport(), 0);
EXPECT_FALSE(importer->hasSuccessfulImport());
EXPECT_TRUE(importer->getImportError().contains("TST"));
EXPECT_TRUE(importer->getCardList().isEmpty());
EXPECT_FALSE(importer->saveToFile(path, {}, {}));
ASSERT_TRUE(original.open(QIODevice::ReadOnly));
EXPECT_EQ(original.readAll(), "existing database");
}
TEST_F(OracleImporterTest, EmptyImportCannotReplaceInstalledCards)
{
const QByteArray source = R"({"data":{"TST":{"code":"TST","name":"Test","type":"expansion","cards":[]}}})";
ASSERT_TRUE(importer->readSetsFromByteArray(source));
EXPECT_EQ(importer->startImport(), 0);
EXPECT_FALSE(importer->hasSuccessfulImport());
EXPECT_FALSE(importer->getImportError().isEmpty());
}
TEST_F(OracleImporterTest, MissingSourceBytesCannotProduceSuccessfulImport)
{
ASSERT_TRUE(importer->readSetsFromByteArray(refreshSource()));
const auto selected = importer->getSets();
importer->releaseSetData();
importer->getSets() = selected;
EXPECT_EQ(importer->startImport(), 0);
EXPECT_FALSE(importer->hasSuccessfulImport());
EXPECT_TRUE(importer->getImportError().contains("TST"));
}
TEST_F(OracleImporterTest, CancellationDuringFinalSetCannotSavePartialDatabase)
{
ASSERT_TRUE(importer->readSetsFromByteArray(refreshSource()));
QObject::connect(
importer, &OracleImporter::setIndexChanged, importer,
[this](int, int, const QString &name) {
if (!name.isEmpty()) {
importer->cancelImport();
}
},
Qt::DirectConnection);
EXPECT_EQ(importer->startImport(), 0);
EXPECT_FALSE(importer->hasSuccessfulImport());
EXPECT_TRUE(importer->getCardList().isEmpty());
}
TEST_F(OracleImporterTest, CancellationBeforeWorkerStartsIsNotLost)
{
ASSERT_TRUE(importer->readSetsFromByteArray(refreshSource()));
importer->prepareImport();
importer->cancelImport();
EXPECT_EQ(importer->startImport(), 0);
EXPECT_FALSE(importer->hasSuccessfulImport());
importer->prepareImport();
EXPECT_EQ(importer->startImport(), 1);
EXPECT_TRUE(importer->hasSuccessfulImport());
}
TEST_F(OracleImporterTest, NewImportReplacesPreviousInMemoryCards)
{
ASSERT_TRUE(importer->readSetsFromByteArray(refreshSource("Old Card")));
ASSERT_EQ(importer->startImport(), 1);
ASSERT_TRUE(importer->readSetsFromByteArray(refreshSource("New Card")));
EXPECT_FALSE(importer->hasSuccessfulImport());
ASSERT_EQ(importer->startImport(), 1);
EXPECT_EQ(importer->getCardList().size(), 1);
EXPECT_FALSE(importer->getCardList().contains("Old Card"));
EXPECT_TRUE(importer->getCardList().contains("New Card"));
}
TEST_F(OracleImporterTest, FailedSourceLoadCannotReusePreviousSuccessfulImport)
{
ASSERT_TRUE(importer->readSetsFromByteArray(refreshSource()));
ASSERT_EQ(importer->startImport(), 1);
ASSERT_FALSE(importer->readSetsFromByteArray("invalid JSON"));
EXPECT_FALSE(importer->hasSuccessfulImport());
EXPECT_EQ(importer->startImport(), 0);
}
TEST_F(OracleImporterTest, SuccessfulRefreshCommitsCompleteXml)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
const QString path = dir.filePath("cards.xml");
ASSERT_TRUE(importer->readSetsFromByteArray(refreshSource()));
ASSERT_EQ(importer->startImport(), 1);
importer->releaseSetData();
ASSERT_TRUE(importer->saveToFile(path, "test-source", "v1"));
QFile file(path);
ASSERT_TRUE(file.open(QIODevice::ReadOnly));
const QByteArray xml = file.readAll();
EXPECT_TRUE(xml.contains("New Card"));
EXPECT_TRUE(xml.contains("test-source"));
EXPECT_TRUE(xml.contains("</cockatrice_carddatabase>"));
}
TEST(OracleDatabaseWriter, AcceptsCompleteSupportedVersions)
{
EXPECT_TRUE(OracleDatabase::validateXml(
"<?xml version=\"1.0\"?><cockatrice_carddatabase version=\"3\"><cards/></cockatrice_carddatabase>")
.isEmpty());
EXPECT_TRUE(OracleDatabase::validateXml("<cockatrice_carddatabase version=\"4\"><cards/></cockatrice_carddatabase>")
.isEmpty());
EXPECT_FALSE(
OracleDatabase::validateXml("<cockatrice_carddatabase version=\"5\"><cards/></cockatrice_carddatabase>")
.isEmpty());
}
TEST(OracleDatabaseWriter, InvalidDownloadPreservesInstalledBytes)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
const QString path = dir.filePath("tokens.xml");
QFile original(path);
ASSERT_TRUE(original.open(QIODevice::WriteOnly));
ASSERT_EQ(original.write("installed tokens"), 16);
original.close();
for (const QByteArray &payload : {QByteArray("<html>server error</html>"),
QByteArray("<cockatrice_carddatabase version=\"4\"><cards>"), QByteArray()}) {
QString error;
EXPECT_FALSE(OracleDatabase::saveXml(path, payload, &error));
EXPECT_FALSE(error.isEmpty());
ASSERT_TRUE(original.open(QIODevice::ReadOnly));
EXPECT_EQ(original.readAll(), "installed tokens");
original.close();
}
}
TEST(OracleDatabaseWriter, CompleteDownloadReplacesInstalledBytes)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
const QString path = dir.filePath("cards.xml");
const QByteArray data("<cockatrice_carddatabase version=\"4\"><cards/></cockatrice_carddatabase>");
QString error = "previous failure";
ASSERT_TRUE(OracleDatabase::saveXml(path, data, &error));
EXPECT_TRUE(error.isEmpty());
QFile file(path);
ASSERT_TRUE(file.open(QIODevice::ReadOnly));
EXPECT_EQ(file.readAll(), data);
}
#ifdef Q_OS_UNIX
TEST(OracleDatabaseWriter, ShortWriteKeepsPreviouslyInstalledDatabase)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
const QString path = dir.filePath("cards.xml");
QFile original(path);
ASSERT_TRUE(original.open(QIODevice::WriteOnly));
ASSERT_EQ(original.write("installed database"), 18);
original.close();
const QByteArray data = "<cockatrice_carddatabase version=\"4\"><!--" + QByteArray(16384, 'x') +
"--><cards/></cockatrice_carddatabase>";
// A real filesystem write failure, isolated in the death-test child so the
// process-wide file limit never affects other cases or the parent runner.
ASSERT_EXIT(
{
std::signal(SIGXFSZ, SIG_IGN);
struct rlimit limit;
limit.rlim_cur = 128;
limit.rlim_max = 128;
if (setrlimit(RLIMIT_FSIZE, &limit) != 0) {
_exit(2);
}
_exit(OracleDatabase::saveXml(path, data) ? 1 : 0);
},
::testing::ExitedWithCode(0), "");
ASSERT_TRUE(original.open(QIODevice::ReadOnly));
EXPECT_EQ(original.readAll(), "installed database");
}
#endif
int main(int argc, char **argv)
{
::testing::InitGoogleTest(&argc, argv);