mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-11-05 01:23:24 -08:00
Compare commits
8 commits
ed119cc940
...
3a656c9c4a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a656c9c4a | ||
|
|
e558c0dffa | ||
|
|
d944273335 | ||
|
|
5ea16c805f | ||
|
|
ca2301bc96 | ||
|
|
2056043104 | ||
|
|
67330ead5a | ||
|
|
7ca697348b |
73 changed files with 1984 additions and 195 deletions
|
|
@ -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[*]}"
|
||||
|
|
|
|||
124
.github/workflows/desktop-build.yml
vendored
124
.github/workflows/desktop-build.yml
vendored
|
|
@ -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/}"
|
||||
|
|
|
|||
10
.github/workflows/docker-release.yml
vendored
10
.github/workflows/docker-release.yml
vendored
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
qt_add_resources(cockatrice_RESOURCES_RCC ${cockatrice_RESOURCES})
|
||||
# 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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ private:
|
|||
QCheckBox *spectatorsAllowedCheckBox, *spectatorsNeedPasswordCheckBox, *spectatorsCanTalkCheckBox,
|
||||
*spectatorsSeeEverythingCheckBox, *createGameAsJudgeCheckBox, *createGameAsSpectatorCheckBox;
|
||||
QCheckBox *shareDecklistsOnLoadCheckBox;
|
||||
QCheckBox *shufflePlayersCheckBox;
|
||||
QDialogButtonBox *buttonBox;
|
||||
QPushButton *clearButton;
|
||||
QCheckBox *rememberGameSettings;
|
||||
|
|
|
|||
136
cockatrice/src/interface/widgets/dialogs/dlg_turn_order.cpp
Normal file
136
cockatrice/src/interface/widgets/dialogs/dlg_turn_order.cpp
Normal 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();
|
||||
}
|
||||
51
cockatrice/src/interface/widgets/dialogs/dlg_turn_order.h
Normal file
51
cockatrice/src/interface/widgets/dialogs/dlg_turn_order.h
Normal 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
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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*/)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -57,6 +57,11 @@ public:
|
|||
return localPlayerId;
|
||||
}
|
||||
|
||||
void setLocalPlayerId(int _localPlayerId)
|
||||
{
|
||||
localPlayerId = _localPlayerId;
|
||||
}
|
||||
|
||||
[[nodiscard]] const QMap<int, PlayerLogic *> &getPlayers() const
|
||||
{
|
||||
return players;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ struct GameConfig
|
|||
bool spectatorsSeeEverything = true;
|
||||
int startingLifeTotal = 20;
|
||||
bool shareDecklistsOnLoad = true;
|
||||
bool shufflePlayers = false;
|
||||
};
|
||||
|
||||
#endif
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,10 +764,20 @@ 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 (auto *otherPlayer : getPlayers().values()) {
|
||||
newHostId = otherPlayer->getPlayerId();
|
||||
break;
|
||||
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;
|
||||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
52
oracle/src/database_writer.cpp
Normal file
52
oracle/src/database_writer.cpp
Normal 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
|
||||
17
oracle/src/database_writer.h
Normal file
17
oracle/src/database_writer.h
Normal 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
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (wizard()->backgroundMode) {
|
||||
if (setsImported == 0) {
|
||||
qWarning() << tr("No set has been imported.");
|
||||
if (!importReady) {
|
||||
const QString message = error.isEmpty() ? tr("No set has been imported.") : error;
|
||||
if (wizard()->backgroundMode) {
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -128,6 +128,7 @@ private:
|
|||
QFuture<int> importFuture;
|
||||
int totalSets = 0;
|
||||
bool importActive = false;
|
||||
bool importReady = false;
|
||||
|
||||
protected:
|
||||
void initializePage() override;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
92
tests/card_art_crop_test.cpp
Normal file
92
tests/card_art_crop_test.cpp
Normal 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();
|
||||
}
|
||||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
268
tests/movecard_tests/player_order_test.cpp
Normal file
268
tests/movecard_tests/player_order_test.cpp
Normal 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);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue