Compare commits

...

166 commits

Author SHA1 Message Date
Lukas Brübach
5e1aab2492 [Protocol] Add alt match evidence and search depth
ServerInfo_UserAlt gains match_reason, depth, and shared_count so moderators can see why an account was flagged and how far it sits from the investigated user. Command_GetUserAlts gains a depth parameter (default 1) for transitive alt-of-alt searches. No behavior change: server still answers depth 1 and leaves the new fields empty until the follow-up server/client layers land.
2026-11-02 21:53:42 +01:00
BruebachL
44d5138181
[VisualDatabase] Keep the card grid scrolling on macOS (#7764)
Some checks are pending
CodeQL / Analyze (cpp) (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
Build Desktop / Configure (push) Waiting to run
Build Desktop / vcpkg baseline (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 cover (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 26 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Desktop / Prune caches (push) Waiting to run
Build Docker / Servatrice (arm) (push) Waiting to run
Build Docker / Servatrice (x86) (push) Waiting to run
Build Docker / Publish multi-platform Servatrice image (push) Blocked by required conditions
* [VisualDatabase] Keep the card grid scrolling on macOS

The view ignored the wheel whenever the vertical scroll bar was not
visible. On macOS, scroll bars are transient: once the fade-out finishes,
QScrollbarStyleAnimation hides the scroll bar widget, so isVisible() then
reports false even though the grid has more rows to scroll. Scrolling was
dead until something else brought the bar back.

Gate on the scroll bar's range instead: it says whether there is anything
to scroll, while visibility is only about presentation. An event with no
range to scroll is still ignored, so an enclosing scroll area can take it.

* Update card_database_visual_view.cpp

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-11-02 18:18:19 +01:00
sentry[bot]
9f30892dff
[Client] Fix crash when local game commands are sent after shutdown (#7714)
Some checks are pending
CodeQL / Analyze (cpp) (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
Build Desktop / Configure (push) Waiting to run
Build Desktop / vcpkg baseline (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 cover (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 26 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Desktop / Prune caches (push) Waiting to run
Build Docker / Servatrice (arm) (push) Waiting to run
Build Docker / Servatrice (x86) (push) Waiting to run
Build Docker / Publish multi-platform Servatrice image (push) Blocked by required conditions
* [Client] Fix crash when local game commands are sent after shutdown

* [Network] Fix formatting in local client debug log

* [CI] Keep the vcpkg submodule pointer on the manifest baseline

vcpkg.json asks for builtin-baseline 9eec002 (vcpkg 2026.10.16), but the
submodule gitlink was still at 9e593bb. The runners check submodules out
shallowly, so vcpkg-tool cannot read versions/baseline.json out of the baseline
commit and every vcpkg leg (Windows and all macOS) fails in CMake configure
with:

  failed to `git show` versions/baseline.json

git phrases that as "path ... exists on disk, but not in <sha>" even when the
commit is simply absent from the shallow clone, so it reads like a tooling or
network failure rather than a mismatched pointer. Point the submodule back at
9eec002, and add a cheap job that fails when the gitlink and builtin-baseline
disagree, before the five build legs pay for it.

---------

Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com>
Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-11-02 13:28:23 +01:00
sentry[bot]
8b0c980e85
[Client] Guard dragged card ghost from painting or dropping deleted cards (#7688)
Some checks are pending
CodeQL / Analyze (cpp) (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
Build Desktop / Configure (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 cover (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 26 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Desktop / Prune caches (push) Waiting to run
Build Docker / Servatrice (arm) (push) Waiting to run
Build Docker / Servatrice (x86) (push) Waiting to run
Build Docker / Publish multi-platform Servatrice image (push) Blocked by required conditions
* [Client] Guard dragged card ghost from painting or dropping deleted cards

* [Game] Revert handling for cards destroyed mid-drag

Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com>

* [DeckView] Access pointer through QPointer in DeckViewCardDragItem

---------

Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com>
Co-authored-by: zeldazach <7460172+zeldazach@users.noreply.github.com>
2026-11-02 08:39:07 +01:00
RickyRister
62e590d300
[ServerGame] Refactor out spectator/judge bools into RoleInfo (#7761) 2026-11-02 00:37:05 -07:00
BruebachL
a1a8a59f4a
[VisualDatabase] Draw the card grid with a model/view (#7701)
* [VisualDatabase] Draw the card grid with a model/view instead of a widget per card

The visual card database built one CardInfoPictureWithTextOverlayWidget per
card, 100 per page, and tore them down on every search, resize and scroll
page. It also looked each card up again by name from the model row, kept a
shadow QList<ExactCard>, and carried paging state plus a 5 s timer that only
existed to defer the first page.

Replace that with a real view on a flattened model:

- CardDatabaseDisplayModel gains CardInfoRole and cardInfoAt(), so a view can
  get the CardInfoPtr behind a proxy row without a name round trip.
- New CardDatabaseVisualDisplayModel (libcockatrice_models) flattens the
  filtered and sorted proxy into one entry per displayed card picture: the
  preferred printing normally, every printing of the selected sets while set
  filters are active. Proxy changes rebuild it on a 30 ms coalescing timer.
- New CardDatabaseVisualView (QListView IconMode) paints only visible rows
  through the new CardPictureDelegate, keeps the old interaction contract
  (left/right/middle press -> cardClicked, hover -> cardHovered, Ctrl+wheel
  resize, no selection, no focus stealing, scroll to top on a new result) and
  repaints when CardPictureLoader reports an async image.
- CardSizeWidget::enableCtrlScrollResize() also watches scroll-area viewports,
  which is what makes Ctrl+wheel work over a QListView.
- VisualDatabaseDisplayWidget drops the paging, the shadow list, the dead
  members and the 5 s timer.

Behaviour-preserving: no gameplay change, no new user-facing strings.

* [VisualDatabase] Fix the card grid wheel step

QListView hands the delegate's item height to the scroll bar as its single step under ScrollPerPixel, so QAbstractSlider moved wheelScrollLines() whole card rows per notch. Handle the wheel in the view instead: one line is an eighth of the grid cell, so a notch moves about a third of a card row at any card size and still follows the OS line count.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-11-02 08:35:58 +01:00
BruebachL
94e6281d87
[Logs] Browse room history without filters with paginated infinite scroll (#7531)
* [Logs] Browse room history without filters with paginated infinite scroll

* [Logs] Continue pagination when switching log tabs

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-11-02 07:36:49 +01:00
BruebachL
3cf8bdb00e
[Tests] Add a headless game logic regression suite (#7702)
* [Tests] Add headless game logic regression suite

Cover CardState, CardZoneLogic and the Command_MoveCard protobufs PlayerActions emits, against libcockatrice_game with a recording AbstractClient and no graphics. The old game_smoke_test cases are migrated into the new suite.

* [Tests] Cover game event application through GameEventHandler

Feeds GameEventContainers through the real dispatcher and asserts the resulting zone and card state: move (including face down), the set-card-attr attributes plus the all-cards and does-not-untap branches, card counters, attach/detach, zone reveal properties, and drawing named cards from the deck.

* [Tests] Replay a synthetic game state through the dispatcher

Builds a GameReplay in code, feeds its containers through the same GameEventHandler path the client uses, and asserts the materialized players, zones, cards and attachments plus event ordering for move, attributes and counters. A small headless zone view stands in for the graphics side where processPlayerInfo asks for card items.

* [Tests] Cover card flip, destroy, token and turn events

Adds the remaining state-mutating event handlers: flip (face down keeps the ref, face up adopts the printing), destroy (removes the card and un-attaches its attached cards), create token (database P/T unless provided or face down) and player properties changed under the concede/unconcede contexts, plus active player and phase.

* [Tests] Cover player lifecycle and counter events

Adds the player-level events that mutate session state: leave removes the player from the manager, kicked emits gameClosed, game closed flips the closed flag and clears the started flag, host changed moves the host role to another player, and create/set/delete update the player counters with unknown ids ignored.

* [Tests] Drive the zone view request through addCard

The test emitted createCardRequested itself and then asserted it was emitted. Registering a ZoneViewZoneLogic on the zone and adding a card exercises the real prepareAddCard -> signal path, so the assertion now fails if addCard stops asking the graphics side to build the card.

* [Tests] Skip the card database when listing tests

gtest_discover_tests runs the binary with --gtest_list_tests during the build. That process built the card database and replaced its default path provider, leaving allocations that the sanitizer leg's default LeakSanitizer check reports at exit, so discovery failed on Ubuntu 26.04. No test runs in list mode, so return before the database setup.

* [Tests] Fix fixture comment nit

Wraps the RecordingClient::sent member name in backticks in the comment.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-11-02 07:35:41 +01:00
BruebachL
5134e72eb4
[Settings] Store server profiles with QSettings arrays (#7629)
The server list used hand-numbered saveName%1/server%1 keys and an O(n)
shift loop to delete entries. Store the profiles with beginReadArray and
beginWriteArray instead, and migrate the legacy keys once in the
constructor so existing profiles and saved passwords survive.

SettingsManager::batchWrite no longer disables atomic sync, so a batch is
still written through QSettings' atomic save. Add tests for add, update,
remove, password updates and the legacy migration.

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-11-02 07:30:29 +01:00
BruebachL
b37c8b828f
[Utility] Budget scrypt's XY array in the derivation memory limit (#7712)
deriveKey passes maxmem = 128nr + 128rp + 4096 to EVP_PBE_scrypt, but scrypt
also allocates XY at 256r bytes. costParamsAreSane accepts r up to 32, so any
server asking for r >= 17 made derivation fail and the client report a login
abort it could never recover from. Cover the XY array and add a regression test
at the accepted-range boundary.

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-11-02 07:29:15 +01:00
BruebachL
719863e43d
[CI] Lint only .sh files in the shell formatting pass (#7715)
The changed-file matcher reused the `*.sh` glob as a regular expression,
where the leading `*` applies to the `(^|/)` group instead of the file
name. Every path ending in "sh" matched, so NSIS include files (.nsh) were
handed to shellcheck and failed the format leg.

Match the suffix explicitly and keep the glob for the find(1) branch.

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-11-02 07:29:05 +01:00
BruebachL
d021f20d14
[Version] Bump to 3.2.0 (#7762)
Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-11-02 07:28:14 +01:00
BruebachL
ce543896f5
[Tests] Use QtTest for model contracts and signal spies (#7630)
Qt6::Test was linked into every test but never used. Add a small RAII
QAbstractItemModelTester helper and apply it to the deck list model, the card
database model and display proxy, and the sets model. Add QSignalSpy coverage
for the card-added and download-settings signals, and assert explicitly that a
card row is a leaf with no child columns (the tester does not check that).

The contract checker immediately found four model bugs, fixed here:
- DeckListModel allowed children below non-zero columns.
- CardDatabaseModel reported every card as a child of every card row.
- CardDatabaseModel reported six columns for a valid (leaf) card index.
- CardDatabaseDisplayModel claimed more rows were fetchable while rowCount
  already exposed them all, so fetchMore() inserted duplicates.

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-11-02 07:27:43 +01:00
BruebachL
429eb2a1fd
[Servatrice] Use compile-time checked connections (#7631)
* [Servatrice] Use compile-time checked connections

Replace the remaining SIGNAL/SLOT string connects in servatrice.cpp,
smtpclient.cpp, serversocketinterface.cpp and isl_interface.cpp with
pointer-to-member or lambda connects, and convert the string invokeMethod
calls to the functor overload.

QxtSmtp has overloaded signals, so those need qOverload. Slots inherited as
protected members and the QWebSocket error connection owned by the ISL fix
are left on lambdas/strings as appropriate; the vendored qxtsmtp.cpp is
untouched.

* Fix remaining review threads on the connection syntax

- Connect the IslInterface signals straight to the inherited Server slots
  instead of forwarding lambdas.
- Capture servatrice explicitly in the queued admin lambdas so they do not
  read this->servatrice after teardown.
- Move the websocket newConnection connect out of the per-pool loop.
- Terminate the logging category and restore pendingKey's indentation.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-11-02 07:13:21 +01:00
BruebachL
a74dfeef6e
[Build] Configure AUTOMOC, AUTOUIC and AUTORCC once (#7637)
The root already set CMAKE_AUTOMOC; add CMAKE_AUTOUIC and CMAKE_AUTORCC
next to it and drop the per-directory copies from 18 CMakeLists files.
These variables are inherited by the subdirectories, so the duplicates
never changed anything.

qt_standard_project_setup is not used: besides the AUTO* variables it
sets CMAKE_RUNTIME_OUTPUT_DIRECTORY on Windows, which breaks the
install(DIRECTORY "$<TARGET_FILE_DIR:...>") copies and the per-app
symbol paths, and appends to CMAKE_INSTALL_RPATH on Linux.

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-11-02 07:06:47 +01:00
BruebachL
5fe7f186d3
[CI] Make the hdiutil retry wrapper report failure (#7643)
* [CI] Make the hdiutil retry wrapper report failure

CPack reads the wrapper's exit status to decide whether the disk image was
created, so a zero exit turns a failed hdiutil into a later "Problem copying
the package" error that hides hdiutil's own message.

- Propagate hdiutil's real status instead of collapsing every failure to 1.
- Name the failing subcommand (create/attach/detach/convert) so the message
  says which CPack step gave up.
- Count attempts rather than failures in the retry line.

* Update .ci/compile.sh

Co-authored-by: tooomm <tooomm@users.noreply.github.com>

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
Co-authored-by: tooomm <tooomm@users.noreply.github.com>
2026-11-02 07:01:55 +01:00
BruebachL
50a2ae9cc8
[Server/Client] Enforce and complete the forced password change at login (#7680)
checkUserPassword ignored force_password_change on the challenge-response
path, so a reset password stayed valid. Returning PasswordChangeRequired
was not enough on its own: the session was dropped and the client told the
user to ask the administrator for the temporary password they had just used.

The pending session now keeps its identity without registering a user
session or announcing a join, may only ping and change its password, and
the client completes the change in-session before reconnecting.

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-11-02 07:01:29 +01:00
BruebachL
ef4574d2b9
[Server] Store a scrypt verifier when an admin resets a password (#7679)
The temporary password was written as a legacy salt+hash row, which makes the
next login look like an authentication downgrade. A client holding a saved
verifier refuses that downgrade, so the reset locked the account out of every
modern client.

Store the verifier and, where security/legacy_backup_key is configured, keep a
sealed legacy hash of the same temporary password as the downgrade backup, so
pre-challenge clients keep the credential path the reset used to provide.

Without the key no row shape serves both: the verifier and the legacy hash
cannot share one column, so those clients have no path at all. Warn at reset
time that the new password has no pre-challenge path, and say so in
servatrice.ini.example instead of promising that a reset gets them back in.

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-11-02 07:00:48 +01:00
tooomm
e08402ac94
bump vcpkg submodule (#7690)
Some checks are pending
CodeQL / Analyze (cpp) (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
Build Desktop / Configure (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 cover (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 26 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Desktop / Prune caches (push) Waiting to run
Build Docker / Servatrice (arm) (push) Waiting to run
Build Docker / Servatrice (x86) (push) Waiting to run
Build Docker / Publish multi-platform Servatrice image (push) Blocked by required conditions
2026-11-01 16:22:51 +01:00
BruebachL
ff393b8990
[Game] Connect zone dump response before sending the request (#7711)
ZoneViewZone::initializeCards() connected PendingCommand::finished only after ZoneViewZoneLogic::requestZoneDump() had already sent the command. Local games process the dump synchronously, so finished was emitted before the view connected and the library/sideboard view stayed empty.

Split requestZoneDump() into prepareZoneDump() and sendZoneDump(), and connect before sending, matching the TabRoom prepare/send convention.

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-11-01 12:49:35 +01:00
BruebachL
00151380e3
[CardDB] Break the CardInfo <-> CardSet cycle on reload (#7645)
Some checks are pending
CodeQL / Analyze (cpp) (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
Build Desktop / Configure (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 cover (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 26 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Desktop / Prune caches (push) Waiting to run
Build Docker / Servatrice (arm) (push) Waiting to run
Build Docker / Servatrice (x86) (push) Waiting to run
Build Docker / Publish multi-platform Servatrice image (push) Blocked by required conditions
* [CardDB] Drop the CardInfo self pointer

The weak self pointer had to be assigned right after every construction,
and a missed assignment silently stopped change notifications. CardInfo
is always owned by a QSharedPointer, so it now derives from
QEnableSharedFromThis<CardInfo> and asks for sharedFromThis() instead:
nothing to initialize, and the CardSet cycle stays broken.

CardSet::newInstance() also loses the commented-out self-pointer
assignment left over from the same design.

* [CardDB] Pin the cardInfoChanged contract without the self pointer

Dropping CardInfo's self pointer moved change notifications onto
QEnableSharedFromThis, so the signal now rests on the card being owned by
a QSharedPointer. Two tests pin that down:

- A property change on a card owned by a CardDatabase still emits
  cardInfoChanged() carrying that card, which is what the card database
  views and models listen to.
- clone() starts out unowned, so a change to the clone is reported as the
  clone's and must not reach the original's observers.

* [CardDB] Treat an unowned CardInfo as a bug, not a no-op

Every CardInfo is built inside a QSharedPointer (both factory overloads
and clone()), and no constructor notifies anyone, so sharedFromThis() is
always available by the time addToSet() or a setter runs. Assert that and
warn in a released build, instead of silently skipping the notification
or the set append.

The copy constructor keeps its explicit QEnableSharedFromThis
initializer: GCC's -Wextra warns about the uninitialized base in every
translation unit that copies a CardInfo otherwise. A comment there
records that the clone deliberately starts unowned.

* [CardDB] Break the CardInfo <-> CardSet cycle on reload

CardSet is a QList<CardInfoPtr> and CardInfo::addToSet() appends the card
to its sets, while a card keeps a CardSetPtr per printing in
setsToPrintings. The sets therefore hold strong references back to the
cards, and the previous database snapshot stayed alive after a reload, so
RSS grew by roughly a second database on every reload.

Empty the sets before the cards are dropped: CardDatabase::clear() and
swapInDatabaseData() clear the sets before replacing the containers, and
removeCard() detaches the card from its sets, so nothing keeps a card
alive on behalf of a set once the database lets go of it.

Two tests pin the behaviour: a reload and a clear() must drop the last
strong reference to a card from the previous snapshot.

* [CardDB] Keep the set detach in one place

clear() and swapInDatabaseData() both emptied every set before dropping the
cards, so share that as detachSetMembership() instead of two copies that have
to stay in sync.

removeCard() now detaches through the first printing of each set directly, the
same way CardInfo::newInstance() appends, instead of an inner loop that always
breaks after the first item.

Because clear() empties the sets up front, its per-card detach was walking
every card's printings to remove it from sets that were already empty. Split
removeCard() into the per-card detach and unindexCard(), and let clear() call
only the latter.

* [CardDB] Test that removeCard detaches the card from its set

removeCard() is the one teardown path a caller can trigger for a single card
(the token editor uses it), and it now detaches the card from its sets as
well. Without that detach the set's strong CardInfoPtr keeps the removed card
alive forever, which is exactly what the reload and clear tests pin for the
whole-snapshot paths.

The test loads the fixture, removes Cat, and requires the card to be gone from
the CAT set and to be destroyed once the test drops its own reference.

* [Servatrice] Drop the settings cache from the logger

#7602 removed #include "settingscache.h" from server_logger.cpp but left
isLoggingEnabled() reading settingsCache, so master no longer compiles: the
servatrice target fails on the undeclared global and server_logger_test, which
compiles the same file without main.cpp, fails to link it.

The same commit made reloadConfig() the single place that stores writelog and
the log filters, precisely so logging threads stop reading the global QSettings.
isLoggingEnabled() now mirrors logMessage() and reads those members instead: the
console is a sink of its own, and the logfile needs an open file plus writelog.
The mutex is mutable so the const query can take it.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-31 19:37:15 +01:00
BruebachL
344dbd2caa
[Servatrice] Fix silently broken socket error connections (#7626)
* [Servatrice] Fix silently broken socket error connections

QAbstractSocket::error() was removed in Qt 6 in favour of errorOccurred(). The ISL interface connected to the old name through SIGNAL(), which compiles but fails at runtime, so catchSocketError never ran. The WebSocket interface used the same name on QWebSocket, where it still exists but is deprecated since 6.5.

Use errorOccurred for both. The WebSocket connect needs a lambda because catchSocketError is a protected base-class slot and a base pointer-to-member is not accessible from the derived class.

* [Servatrice] Guard the WebSocket error connect against Qt 6.4

QWebSocket gained errorOccurred in 6.5 and dropped the error signal with it, so
the typed connect cannot compile against the 6.4 floor that Debian 12 and
Ubuntu 24.04 still use. Keep the typed connect on 6.5+ and fall back to the
old-style connect below it, which is what the code did before.

Verified both branches compile: forcing the guard to the else path and
rebuilding serversocketinterface.cpp succeeds, so the 6.4 spelling is intact.

* [Servatrice] Initialize ISL serverId before authentication

catchSocketError() removes islInterfaces[serverId], but the incoming
connection constructor left serverId uninitialized until initServer()
set it after the SSL handshake and certificate check. A socket error
during waitForEncrypted() therefore read an indeterminate key.

* [Servatrice] Correct the QWebSocket error signal note

error() was deprecated in 6.5, not removed; errorOccurred() was added in
6.5.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-31 19:02:55 +01:00
BruebachL
825aba953a
[Servatrice] Fail loudly when startup initialization fails (#7602)
* [Servatrice] Exit non-zero when server initialization fails

initServer() reported every startup failure through a zero exit code: the
database could not be opened, the server printed nothing of its own, and the
process looked like a clean shutdown. Log the two refusal reasons at critical
level and return 1 so supervisors and the Docker exit status can tell the
difference.

* [Servatrice] Keep console logging independent of the logfile

logMessage() returned before the console echo unless a logfile was open and
server/writelog was true, so --log-to-console produced nothing when
server/logfile was empty or could not be opened. That is how a refused database
connection could end in a silent exit.

Print to the console before the logfile gates, drop the duplicate echo from
flushBuffer(), and read writelog/logfilters once in main instead of calling the
non-thread-safe global QSettings from every logging thread.

* [Servatrice] Uninstall the log handler before tearing down globals

The handler reaches into the global logger and settingsCache. Leaving it
installed while main deletes those objects turns any Qt warning emitted during
QCoreApplication/QFactoryLoader teardown into a use-after-free; with
QT_DEBUG_PLUGINS=1 that warning exists, and Servatrice died with SIGSEGV in
QSettings::value() on the way out.

* [Servatrice] Buffer console logs on the logger thread

Address review feedback on the startup failure change:

- Append console lines to a buffer drained by the logger thread, so a
  stalled stdout reader cannot block game threads.
- Report islServer->errorString() instead of the literal call text.
- Let the constructor reuse reloadConfig() instead of parsing the filter
  list twice.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-31 19:02:49 +01:00
BruebachL
ae43659b91
[Build] Use versionless Qt CMake commands (#7639)
* [Build] Use versionless Qt CMake commands

Replace the versioned qt6_add_executable, qt6_add_resources, qt6_add_shaders,\nqt6_add_translations and qt6_wrap_cpp calls with their unversioned forms.\nAll five are documented as available at the project's 6.4 minimum and accept\nthe same arguments.\n\nVerification: full client, Oracle, server and test build passed.

* versionless qt_finalize_target and _project

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
Co-authored-by: tooomm <tooomm@users.noreply.github.com>
2026-10-31 18:53:00 +01:00
BruebachL
d4002dc52b
[Build] Reject APIs deprecated before Qt 6.4 (#7632)
Add the deprecation cutoff after FindQtRuntime so the compiler rejects any
API deprecated before the project's minimum Qt version. Qt 6.5 renamed the
switch, so the fallback name is used on 6.4.

The full client, Oracle, server and test build passes with the cutoff
enabled.

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-31 18:52:11 +01:00
BruebachL
f2c359ba9f
[CI] Keep the vcpkg cache directory guard unconditional (#7527)
The cache action creates its directory only when a prefix matches an entry, and it reads that directory unguarded when it saves, so a scope nobody has written to yet fails the job. Create it up front on every outcome, and fall back to the workspace path when the cache step was skipped and set no path output.

Co-authored-by: opencode <opencode@local>
2026-10-31 18:49:45 +01:00
BruebachL
52d3978f84
[Build] Let AUTOMOC process library headers (#7638)
The 13 libraries using qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) listed their\nheaders twice and depended on a generated source list. Add the headers to the\ntarget sources directly and delete the qt6_wrap_cpp call; AUTOMOC scans them\nand generates the same moc output.\n\nThe change exposed that server_card_counter_test never defined the rng global\nthat the server remote library references; every sibling server test does.\nGive the test the same definition so the target links.\n\nVerification: full client, Oracle, server and test build passed.

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-31 18:49:05 +01:00
BruebachL
345b0e8ba9
[Refactor] Move game logic into libcockatrice_game (#7437)
* [Refactor] Move game logic into libcockatrice_game

* [Refactor] Include card_state.h in player_actions.h

* [Game] Inject settings providers into libcockatrice_game, drop Widgets

* [Game] Follow base rename to createCardRequested after rebase

* [Game] Drop unused CardItem forward declaration

* [Game] Fix includes after moving game logic into libcockatrice_game

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-31 18:44:15 +01:00
BruebachL
78964779c6
[Refactor] Move card item creation out of game logic (#7436)
* [Refactor] Move card item creation out of game logic

* [Game] Rename signal to createCardRequested per review

* [Game] Move attachment bookkeeping onto CardState

CardState::setAttachedTo now maintains the attachedCards graph, resets the
grid point and emits the zone events itself, so game logic no longer needs
to reach into the CardItem graphics item. CardItem::setAttachedTo delegates
to the state and keeps only the view teardown (deleteLater).

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-31 18:44:15 +01:00
BruebachL
7ca23430cd
[Refactor] Retype PlayerActions onto CardState (#7435)
* [Refactor] Retype card containers and zone logic onto CardState

* [Refactor] Retype PlayerActions onto CardState

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-31 18:44:14 +01:00
BruebachL
65e9f1b370
[Refactor] Retype card containers and zone logic onto CardState (#7434)
* [Refactor] Retype card containers and zone logic onto CardState

* [Game] Restore attach bookkeeping via CardItem and repaint on state change

* [Refactor] Give custom zones a hidden graphics owner

Custom PileZoneLogic zones had no CardZone, so moving a card into one
left the CardItem drawn at its old spot. Create a hidden PileZone in
onCustomZoneAdded so cards are reparented and hidden.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-31 18:44:14 +01:00
BruebachL
5d0d466930
[Refactor] Add card name, printing id, and color to CardState (#7433)
Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-31 18:44:13 +01:00
BruebachL
edb5caec8a
[Refactor] Hold zone views and card view teardown outside game graphics (#7429)
Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-31 18:44:13 +01:00
BruebachL
2335e064ac
[Refactor] Move the attachment graph and card view teardown onto CardState (#7428)
Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-31 18:44:13 +01:00
BruebachL
47c90cda10
[Refactor] Give graphics zones a card item cache and own card placement (#7427)
Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-31 18:44:12 +01:00
BruebachL
4879ddbd0f
[Refactor] Move card selection actions to GameScene and p/t parsing to CardState (#7426)
Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-31 18:44:12 +01:00
BruebachL
c9853734ad
[Refactor] Move card identity and card data into CardState (#7425)
Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-31 18:44:12 +01:00
BruebachL
6f0c5dca12
[Refactor] Move LoadedDeck and DeckFileFormat data types into libcockatrice_deck_list (#7424)
Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-31 18:44:11 +01:00
BruebachL
85fd052fdb
[Refactor] Move ArrowRegistry out of game/ into game_graphics/board (#7423)
Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-31 18:44:11 +01:00
BruebachL
3ccd44155d
[Refactor] Build arrow and attach commands in game/ instead of ArrowItem (#7422)
Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-31 18:44:11 +01:00
BruebachL
77f882ca37
[Refactor] Build deck setup commands in GameEventHandler instead of DeckViewContainer (#7421)
Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-31 18:44:10 +01:00
BruebachL
674a607960
[Refactor] Build player counter commands in game/ instead of AbstractCounter (#7420)
Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-31 18:44:10 +01:00
BruebachL
73d5a2a0ab
[Refactor] Emit phase toolbar intent instead of protobuf commands (#7419)
Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-31 18:44:10 +01:00
BruebachL
095b135640
[Refactor] Move the remaining zone commands out of game_graphics/zones/ (#7418)
* [Refactor] Move the remaining zone commands out of game_graphics/zones/

* [Game] Move zone dump request onto ZoneViewZoneLogic per review

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-31 18:44:09 +01:00
BruebachL
563833a88a
[Refactor] Build Command_MoveCard in game/ instead of the zone views (#7417)
Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-31 18:44:09 +01:00
BruebachL
4972ebcb97
[Refactor] Move the active card from AbstractGame into GameScene (#7416)
Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-31 18:44:08 +01:00
BruebachL
94b91be5a2
[Refactor] Pass the target card into PlayerActions instead of reading activeCard (#7415)
Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-31 18:44:08 +01:00
BruebachL
93c5d85996
[Refactor] Move attach and arrow previews from game logic into CardMenu (#7414)
Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-31 18:44:08 +01:00
BruebachL
52c5bd2ea3
[Refactor] Move table row mapping from TableZone into TableZoneLogic (#7413)
* [Refactor] Move table row mapping from TableZone into TableZoneLogic

* [Game] Use TableZoneLogic::ROW_COUNT directly per review

* [Format] Match clang-format line breaking in table_zone

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-31 18:44:07 +01:00
BruebachL
d30d275296
[Refactor] Move AnnotationDialog and action data types into game/ (#7411)
Folds in the closed #7410 (Move AnnotationDialog from game/ logic to
game_graphics/) now that layers 1-2 are closed and this stack bottoms on
master directly:

- Add game_graphics/dialogs/dlg_annotation.{h,cpp} and move the
  QInputDialog subclass there; game/ no longer declares any widget type.
- Add game/player/token_info.h and game/player/move_top_cards_until_options.h.
- Move CardMenuActionType to game/player/card_menu_action_type.h.
- Graphics dialog headers include the new game/ headers instead of
  defining the structs themselves.

No behavior change.

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-31 18:44:07 +01:00
sentry[bot]
bbc7e462ff
[Installer] Graceful close and a way out of a stuck update (#7687)
Some checks are pending
CodeQL / Analyze (cpp) (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
Build Desktop / Configure (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 cover (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 26 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Desktop / Prune caches (push) Waiting to run
Build Docker / Servatrice (arm) (push) Waiting to run
Build Docker / Servatrice (x86) (push) Waiting to run
Build Docker / Publish multi-platform Servatrice image (push) Blocked by required conditions
* [Installer] Stop calling the payload free while an executable is locked

The guard answered "nothing is holding the payload" from two lookups that can
both miss a running instance: Restart Manager does not reliably report a
program's own image or the modules it mapped, and the image path of a process
running under another account cannot be read from the probe at all. A user log
showed exactly that: the first guard passed while cockatrice.exe was running
from the directory, the old uninstaller silently skipped the file it could not
delete, and the update aborted half an uninstall later with "cancelled by user"
after a close attempt the running window ignored.

Ask the file itself. A running image is opened without write sharing, so the
write open replacing it needs is refused with ERROR_SHARING_VIOLATION for as
long as the program runs, while a file that is merely being read still accepts
it. When neither lookup named anybody and a payload executable refuses that
open, the probe answers that the file is in use and that the program holding it
could not be identified - the guard's "could not confirm" dialog, whose Retry
works once the user has closed it - instead of passing the payload as free.

Only the payload executables are tested, not every file the purge removes: a
locked executable proves a program is running and a mapped DLL belongs to a
running executable, while a locked DLL or qt.conf can be the asking process
itself, which is the loop the guard already had. The open is retried once after
300 ms, and only for the files that refused, so a scanner holding a file for a
moment cannot stop an install for nothing.

The Windows probe check now covers that answer: a payload executable held by
the asking process, so that it cannot be named, has to come back as in use
rather than as free.

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

* [Installer] Close running clients over a local socket

The installer could only ask a running client to close with WM_CLOSE, which an instance hidden in the notification area or sitting on its own confirmation cannot answer usefully. Give every client a per-process local socket and have the holder probe ask it there first, falling back to WM_CLOSE for clients from before the socket.

* [Installer] Offer to check again or update by hand when files stay locked

Replace the two-answer guard prompt with force close, close by hand and check again, or cancel. Retry the automatic close while the set of holders changes, and point a cancelled run at the latest release so a user who does not want anything killed has a way forward that is not cancelling the update again.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-31 15:32:59 +01:00
tooomm
720adf5410
[Settings] Rename UI language selector & move translator hint (#7656)
* Update general_settings_page.cpp

* lint
2026-10-31 09:28:52 +01:00
sentry[bot]
0369aaffa9
[Installer] Name only the payload programs installed in the directory (#7684)
Some checks failed
CodeQL / Analyze (cpp) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
Build Desktop / Configure (push) Has been cancelled
Build Desktop / Prune caches (push) Has been cancelled
Build Docker / Servatrice (arm) (push) Has been cancelled
Build Docker / Servatrice (x86) (push) Has been cancelled
Build Desktop / Debian 13 (push) Has been cancelled
Build Desktop / Debian 12 (push) Has been cancelled
Build Desktop / Fedora 44 (push) Has been cancelled
Build Desktop / Fedora 43 (push) Has been cancelled
Build Desktop / Servatrice_Debian 12 (push) Has been cancelled
Build Desktop / Ubuntu 24.04 (push) Has been cancelled
Build Desktop / Ubuntu 26.04 (push) Has been cancelled
Build Desktop / Arch (push) Has been cancelled
Build Desktop / Ubuntu 26.04 cover (push) Has been cancelled
Build Desktop / macOS 13 Intel (push) Has been cancelled
Build Desktop / macOS 14 (push) Has been cancelled
Build Desktop / macOS 15 (push) Has been cancelled
Build Desktop / macOS 26 Debug (push) Has been cancelled
Build Desktop / Windows 10 (push) Has been cancelled
Build Docker / Publish multi-platform Servatrice image (push) Has been cancelled
The running-instance lookup named every process whose image path began with the
install directory, subfolders included. Installing into a folder that holds
another application in a subfolder therefore reported that application as
holding the previous version, and nothing the guard offers could clear it: the
close asks cockatrice.exe only, and the end answer refuses anything that is not
a payload program. Both answers were no-ops, so the update could only abort and
tell the user to close a program that was never touching the files.

Name only the programs the payload installs, and only when the image lies in the
install directory itself. A program below the directory that really does hold a
payload file is still named by the Restart Manager lookup, which is the lookup
for that question.

The Windows probe check now covers a payload-named program in a subfolder and a
program the payload does not install; both have to come back as nothing.

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-30 13:16:50 +01:00
sentry[bot]
ded83aaab5
[Installer] Ask a hidden instance to close, then offer to end it once (#7683)
Some checks are pending
CodeQL / Analyze (cpp) (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
Build Desktop / Configure (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 cover (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 26 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Desktop / Prune caches (push) Waiting to run
Build Docker / Servatrice (arm) (push) Waiting to run
Build Docker / Servatrice (x86) (push) Waiting to run
Build Docker / Publish multi-platform Servatrice image (push) Blocked by required conditions
* [Installer] Request quit for hidden Cockatrice instances and record holders in abort telemetry

* [Installer] Fix compiler warnings in holder probe and installer quit handler

* [VisualDeckStorage] Order the includes the way clang-format wants them

* [Installer] Ask a hidden instance to close, then offer to end it once

The previous attempt had the client answer a registered window message, which
cannot work for the update it was reported on: the instance being replaced is a
client from before that message exists, so it never answers, and the user is
left with "Cockatrice is still running" exactly as reported. Every step that can
free the files has to be the installer's, so the client side is reverted.

The probe now posts WM_CLOSE to the instance's own window whether that window is
on screen or only in the notification area: the request does not need a visible
window, an instance in the tray is the one this update has no other way to
reach, and the confirmation Cockatrice answers with is a window Qt shows itself,
so it is painted - unlike one restored from outside. A visible window is brought
forward first, and Qt's bookkeeping windows stay excluded, because a close to the
notification-area one terminates the instance without its confirmation.

A question of the instance's own no longer suppresses the close. Withholding it
protected nobody: the installer asked the user to close a program it had never
asked, and a current client refuses the close while its confirmation is pending
anyway, leaving the question where it is. The launcher mode keeps the
visible-only rule, because a launcher without a window has already answered its
own close and the wait ends it after a grace instead of asking a user who is not
there.

The guard asked the same question until the user gave up: after the automatic
close failed it offered to end the programs, then re-ran the probe and showed
the same dialog word for word when the answer was No, which is how an abort was
reported three times over. It now asks once - Yes ends the Cockatrice, Oracle
and Servatrice programs the payload installs and says what that costs, No
cancels - and what survives an end is named in the abort instead of being put to
the user as the same question again. The uninstaller's guard and the recovery
path after a failed purge follow the same shape.

Kept: the abort telemetry that records the holders, the close result and the end
result as event data.

---------

Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com>
Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-29 21:01:16 +01:00
sentry[bot]
9e50c5ad6e
[Installer] Recover payload files when previous uninstaller is missing or fails to launch (#7682)
Some checks are pending
CodeQL / Analyze (cpp) (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
Build Desktop / Configure (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 cover (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 26 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Desktop / Prune caches (push) Waiting to run
Build Docker / Servatrice (arm) (push) Waiting to run
Build Docker / Servatrice (x86) (push) Waiting to run
Build Docker / Publish multi-platform Servatrice image (push) Blocked by required conditions
Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com>
2026-10-29 12:10:18 +01:00
sentry[bot]
06da7f815a
[Installer] End a launcher that has no window to ask instead of prompting (#7681)
Some checks are pending
CodeQL / Analyze (cpp) (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
Build Desktop / Configure (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 cover (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 26 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Desktop / Prune caches (push) Waiting to run
Build Docker / Servatrice (arm) (push) Waiting to run
Build Docker / Servatrice (x86) (push) Waiting to run
Build Docker / Publish multi-platform Servatrice image (push) Blocked by required conditions
* [Installer] End a launcher that has no window to ask instead of prompting

The in-app updater starts this installer detached and then closes itself. A
client that accepts that close hides its window before it exits, and if it
then hangs in shutdown there is nothing left to ask: the holder probe
deliberately never posts WM_CLOSE to an instance it cannot see a window for,
so the "ask it to close again" answer only repeated itself until the user
picked Cancel and the installation aborted (Sentry COCKATRICE-DESKTOP-J).

The launcher wait now asks the launcher itself before asking the user. A new
probe mode, --close-launcher, answers whether that one process has a window a
close could reach at all; it is limited to a pid that still runs from the
directory being replaced, so a reused process id cannot be hit. When there is
no window, the installer gives the shutdown it already started one full grace
and then ends it through the handle it waited on, which is what the dialog's
Yes answer did. The prompt remains for the case it exists for: a window is up,
the instance may be waiting on its own question, and No now means only "keep
waiting".

The client is left alone: an already-installed version runs the installer
during its own update, so a change there cannot reach the run that aborts.

* [libcockatrice_rng] Include immintrin.h for AVX2 builds in SFMT

---------

Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com>
2026-10-29 04:00:42 +01:00
BruebachL
5cf3358c99
[Client] Stop reporting local login aborts as missing client features (#7678)
Some checks are pending
CodeQL / Analyze (cpp) (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
Build Desktop / Configure (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 cover (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 26 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Desktop / Prune caches (push) Waiting to run
Build Docker / Servatrice (arm) (push) Waiting to run
Build Docker / Servatrice (x86) (push) Waiting to run
Build Docker / Publish multi-platform Servatrice image (push) Blocked by required conditions
* [Client] Stop reporting local login aborts as missing client features

Local aborts (unusable saved verifier, refused authentication downgrade,
failed scrypt derivation) reuse RespClientUpdateRequired with an empty
feature list, and the handler discarded the reason and advised a client
update. Report the abort with the cause instead, and report a strict
server's challenge_response_auth answer to a credential-less login as the
missing password it is.

* [Client] Keep the login abort messages in one named set

The local login aborts each carried their wording inline at the emit site, so
changing the tone or the remedy meant editing several functions. Collect them
as named constants next to RemoteClient instead, translated at the use sites
with the RemoteClient context so lupdate still picks them up.

* [Client] Reword the login abort explanations to match what happens

The first wording sent users to a server operator to 'migrate' an account,
which operators cannot do, and told them to 'save' a login the client saves by
itself. It also blamed the device for a derivation failure the server's
parameters can cause, and the dialog led with a line that said nothing while
the reason sat in the informative slot.

Say what the client actually does in each case, give the step that works, and
show the reason as the dialog's main text. The unreachable fallback sentence
about the login not reaching the server is gone.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-28 09:48:37 +01:00
BruebachL
d89b502342
[Client] Report password-reset rate limits instead of a generic failure (#7677)
* [Client] Report password-reset rate limits instead of a generic failure

sigForgotPasswordError carried no response code, so rate limiting, a
disabled reset feature, an unknown user and a bad token all produced
"contact the server operator". Users who were only rate limited retried,
which kept them rate limited.

* [Client] Move the reset-password warning out of the response switch

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-28 09:19:42 +01:00
sentry[bot]
a83d82b5e8
[Installer] Recover leftover payload files when previous uninstaller leaves them behind (#7676)
* [Installer] Recover leftover payload files when previous uninstaller leaves them behind

* [Installer] Fix leftover recovery on the 64-bit auto-uninstall path

- Record the 64-bit uninstaller exit code after ExecWait returns. It was
  captured before the call (always empty), so WaitForOldPayloadToDisappear
  always aborted before RemoveOldPayloadLeftovers could run.
- Restore the pre-uninstall user-data backup before aborting out of the wait,
  including the guard's cancel and probe-failure prompts, so the only copy
  cannot be stranded in %TEMP% for the next run to wipe.
- Report the leftover removal as done only when every payload executable is
  gone and RetryDllPurge reports no stale DLLs, instead of checking
  cockatrice.exe alone.
- Return directly after a successful recovery instead of reusing the poll
  counter, so the log stops claiming the directory "was already clear" and
  the normal settle delay is kept.

* [Installer] Delete the old uninstaller only after recovery succeeds

RemoveOldPayloadLeftovers cleared the attribute and deleted
$INSTDIR\uninstall.exe on every attempt, before the attempt was known to
work. On failure the installer aborts telling the user to run it again, but
UninstallString then points at a missing uninstaller: ExecWait leaves the
exit code empty, WaitForOldPayloadToDisappear takes the "cancelled or
failed" branch, and RemoveOldPayloadLeftovers is gated on exit code 0, so
no later run can ever get past it - a half-removed old install the previous
revision never produced.

Delete it in rop_done instead, after $OldPayloadRemoved is set.
WriteUninstaller recreates it on success anyway, and nothing in the
success verdict depends on it being gone.

---------

Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com>
Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-28 09:08:54 +01:00
sentry[bot]
bf3562a85a
[Installer] Offer to end running Cockatrice immediately when auto-close fails (#7675)
Some checks are pending
CodeQL / Analyze (cpp) (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
Build Desktop / Configure (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 cover (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 26 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Desktop / Prune caches (push) Waiting to run
Build Docker / Servatrice (arm) (push) Waiting to run
Build Docker / Servatrice (x86) (push) Waiting to run
Build Docker / Publish multi-platform Servatrice image (push) Blocked by required conditions
* [Installer] Offer to end running Cockatrice immediately when auto-close fails

* [Installer] Keep the way out that costs nothing in the first prompt

The prompt now offers to end Cockatrice, so what the deleted close-again text
said has to survive in it: an instance the probe reports as waiting for an
answer to a question of its own has been brought forward, and answering that
window is the way out that ends nothing. Say so, and say where to go when Yes
leaves it listed - Task Manager - so an end that cannot take effect does not
loop on the same dialog.

* [Installer] Bring the comments back in line with the code

GuardCloseTried no longer counts closes, and the file header, CloseHolders and
GuardAppsNotRunning still described an escalation after a second close, as did
the probe's --end-holders note.

* [Installer] End Oracle and Servatrice too when the guard's end answer is confirmed

The guard's end answer only ever stopped cockatrice.exe, so an Oracle holding
the card database or a Servatrice service kept the payload locked and the
prompt came back with nothing left that could free it.

The probe now ends every program the payload installs, services included:
isPayloadProgram() - the list restart_manager_holders already used for its
parent walk - decides the set, so a scanner holding a DLL or a program the
user keeps in the install directory is still named and left to the user. A
service is terminated rather than sent a stop through the SCM, which notices
the process exit and marks it stopped afterwards.

The guard prompt now says what ending costs: an unsaved deck, an unwritten
setting, players on a running Servatrice, and an Oracle mid-way through the
card database. It also keeps the notification-area hint, which the probe's
"has no window to ask" answer depends on, and drops the claim that Oracle
and Servatrice had been brought forward.

* [Installer] Give every running-program prompt the same end-or-retry answers

Four prompts meet a running program and each offered a different set: the
guard ended it, the launcher wait only kept waiting, and the recovery prompt
after a failed purge could only ask Cockatrice to close. With Oracle or
Servatrice holding the files, that last one had no answer that could free
anything - Yes and No both retried the purge and Cancel aborted an install
whose old version was already gone.

All four now follow the guard's shape: one automatic attempt, then a prompt
whose Yes ends the programs it names and whose No retries, with Cancel to
give up.

- RecoverFromHolders closes once and purges before it asks anything, then
  offers EndHolders - the same end answer as the guard - instead of a close
  the user had to trigger himself.
- WaitForLauncherToExit can end the launcher by pid, the one process it
  holds a pid for, rather than looping 60 s waits with Cancel as the only
  way out.
- END_COST and AFTER_CLOSING_GUIDANCE are !defines, so the cost of ending
  and the guidance for the answers that end nothing read the same in every
  prompt.

* [Installer] End the launcher through the handle it was waited on

The "end it" answer used to look the launcher's pid up again after a dialog that
can stay open as long as the user takes, so a pid that had been reused in the
meantime - or a /PID= that was never the launcher - would be terminated instead.
The handle the wait already holds names that process and cannot be handed to
another, so the terminate answer now acts on it.

PROCESS_TERMINATE is requested along with PROCESS_SYNCHRONIZE; when only the
synchronize right is granted the launcher is still waited on and the answer falls
back to asking it to close, instead of reporting a live launcher as gone and
writing the payload over it.

* [Installer] Say what the end answer ends, and report a failed end

The prompt offered "end every program listed above", but EndHolders only ends the
programs the payload installs: a scanner holding a payload DLL, or a service the
Restart Manager names by its long name, was listed and then silently left alone, so
Yes looked like it did nothing and the same dialog came back unchanged.

The wording now says which programs Yes ends and that anything else has to be closed
by hand, and the round after an end that did not free the files says so, so the same
dialog no longer reappears word for word. The three prompts are one macro so they
cannot drift apart again.

The added sentences eat into the 1024 characters one NSIS string holds, so the
holder line's budget drops from 400 to 320 characters to keep the longest message
(cancel line included) inside it: 977 characters, measured against the worst case
the probe can produce.

---------

Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com>
Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-27 19:09:21 +01:00
sentry[bot]
30d3afc7b4
[Updater] Close a running Cockatrice when the installer asks (#7672)
Some checks are pending
CodeQL / Analyze (cpp) (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
Build Desktop / Configure (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 cover (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 26 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Desktop / Prune caches (push) Waiting to run
Build Docker / Servatrice (arm) (push) Waiting to run
Build Docker / Servatrice (x86) (push) Waiting to run
Build Docker / Publish multi-platform Servatrice image (push) Blocked by required conditions
* [Updater] Refuse a close that arrives while one is already asking

MainWindow::closeEvent() had a re-entrancy guard that returned without touching
the event. A QCloseEvent is accepted unless something ignores it, so a second
close request - an installer replacing our files posts one every 30 s - accepted
the close while the first one was still blocked on its own confirmation. close()
then hid this window, hid the confirmation dialog with it, and left the process
running inside the nested loop that dialog had opened: no window, no way to
answer it, and every later close request accepted into the same dead end. That
is what an update looked like when it could not close a running client, and it
is why the installer could name the process, bring it forward and ask it to
close for minutes without anything happening.

Track how far the shutdown has got instead. While a confirmation is up, a close
from anywhere else is refused, which leaves the question in front of the user;
once the shutdown has been accepted, a duplicate close (Qt delivers the event
twice on some platforms) is accepted again so it cannot cancel the quit.

* [Installer] Leave an instance that is asking a question alone

An instance that answered a close by hiding rather than quitting - the state the
client used to get into - has no visible window left, and the confirmation it is
waiting on is hidden with the window that owns it. The close probe only ever
looked at visible windows, so it restored the main window, posted WM_CLOSE into
the same dead end, and reported "still running" without saying why.

Show every window the instance left hidden before choosing the one to close, and
skip the close when the window it would reach is disabled: a modal dialog of the
instance's own is up, and posting the close then only hides the question again.
Name what is left of the window in the report as well - gone, still open, hidden
again, and whether the thread answers at all - so the log tells a refused close
from one that was never delivered.

* [Installer] Offer to end a Cockatrice that will not close

A close is a request, and an instance that does not answer it - waiting on a
question nobody is looking at, or stuck where a close cannot reach - leaves the
user with no way forward: the files cannot be replaced while it runs, and
clicking Yes again only repeats the request.

After a second close that changed nothing, offer the only thing that can free
the files: end it. The prompt says what it costs - an unsaved deck, a running
game or a setting it has not written yet - the default button is the safe one,
and it is a separate answer rather than a relabelled "Yes", so it is not
reached by clicking through the close prompt. Only cockatrice.exe is ever
ended: Oracle writes the card database in place and Servatrice is a service, so
both are named and left to the user, exactly as they are for the close.

The probe gains an --end-holders mode for it, and the uninstaller's guard gets
the same offer.

* [Installer] Never name the process that is doing the asking

The old uninstaller is started with _?= so it runs in place, from the very
directory it is emptying, and an installer can be downloaded into the folder it
installs to. Both are then processes running from the directory, so the probe
named them - and the guard asked the user to close the program that was asking.
That question cannot be answered: a close and the end offer both skip anything
that is not cockatrice.exe, so the guard repeated itself until the update or the
uninstall was cancelled.

The probe and the processes that started it are now never named, by either
lookup. The walk up the parent chain stops at the first program the payload
installs, because in an in-app update the installer's own parent is the
Cockatrice instance being replaced - the one the guard exists to name.

The Windows check held its locked file in the script that starts the probe, so
that holder is excluded by design now and the check read it as "nothing found".
It holds the file from a process of its own instead, and asserts the exclusion
itself: a file that only the asking process holds comes back as nothing.

* [Installer] Restore only the windows a user could answer

Showing every hidden window an instance owns put Qt's own bookkeeping windows on
screen: the tray icon keeps one to receive the TaskbarCreated message, styled
WS_OVERLAPPED and titled QTrayIconMessageWindow. Restored, it is an empty white
rectangle in the taskbar with no title bar to close it - it has no window
procedure that would hide it again, and the process is busy in the very loop the
restore is meant to break, so it stayed until the process was force-ended.

Only framed, non-empty windows are restored and considered for the close now.
That also keeps the close off Qt's tray window, which answers WM_CLOSE by asking
the application to terminate - a quit that skips the confirmation the close is
supposed to reach.

* [Installer] Uninstall the previous version before installing over it

A regular install over an existing one asked "a previous version must be
uninstalled", then ran the old uninstaller interactively. That uninstaller
put its confirmation page behind the installer window, where it could not be
answered, and it only removed the installation after the installer had
already written the new one over it.

Run the previous version's uninstaller silently instead. The installer has
already asked the question the user answered, so a second, hidden confirmation
is pure risk: with /S there is no window to be behind, and ExecWait is then
waiting for something that cannot outlive it. QuietUninstallString is used
when the old installation has one, otherwise /S is appended.

ExecWait only waits while the uninstaller runs in place: one that did not get
a usable _?= copies itself to %TEMP% and returns at once, while the copy keeps
deleting - which is how an uninstall can finish after the install it was
supposed to precede. Log the exit code and wait for the old cockatrice.exe to
disappear before restoring user data or writing anything, and abort with a
message instead of installing into a directory that is still being emptied.

Cancelling the "must be uninstalled" prompt now quits the installer; it used
to skip the page and install over the old version anyway.

* [Installer] Do not show windows the application has hidden

The guard restored every hidden window an instance owned before asking it to
close, so that a confirmation hidden together with its window would come back.
Windows does as it is told and shows them; the application does not, because Qt
still believes they are hidden and never paints them. What the user gets is a
blank white rectangle, on screen and on the taskbar, that answers nothing - and
the question it was meant to bring back is not answerable in it either.

An instance with no window a user could answer is named and left to the user
instead, and the prompt says what to do about it: open it from the notification
area. Ending it stays the last resort, offered once a close has been asked for
twice.

The abort after a failed uninstall now says what it saw - the old version is
still there after its uninstaller ran - instead of promising a removal that is
not happening, which is the state a cancelled old uninstaller leaves behind.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-27 11:19:49 +01:00
sentry[bot]
92d082e735
[VisualDeckStorage] Guard against widget deletion during tag edit dialog (#7674)
Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com>
2026-10-27 09:14:52 +01:00
sentry[bot]
aa42cfbccc
[VisualDeckEditor] Create menus before central frame to prevent Windows crash in setParent_sys (#7673)
Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com>
2026-10-27 09:12:37 +01:00
sentry[bot]
d42b9607c5
[Installer] Check for running instances with the Restart Manager probe (#7671)
Some checks are pending
CodeQL / Analyze (cpp) (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
Build Desktop / Configure (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 cover (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 26 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Desktop / Prune caches (push) Waiting to run
Build Docker / Servatrice (arm) (push) Waiting to run
Build Docker / Servatrice (x86) (push) Waiting to run
Build Docker / Publish multi-platform Servatrice image (push) Blocked by required conditions
* [Installer] Check for running instances with the Restart Manager probe

The running-instance guard was built on powershell.exe started from the
32-bit NSIS stub, which is the 32-bit Windows PowerShell: its Get-Process
cannot read the path of a 64-bit process, so a running 64-bit Cockatrice
was reported as not running, the update went ahead, and the purge then
failed on the DLLs that instance still had mapped.

Use tools/installer-holder-probe instead - the same Restart Manager lookup
the abort path already had - before anything is replaced and in the
uninstaller as well. It is bitness-agnostic, so the Sysnative and WMI
fallbacks are gone.

The guard looks only at the payload, asks Cockatrice to close once by
itself (WM_CLOSE, the same request as the window's close button) and then
offers close again / retry after closing by hand / cancel for whatever the
close cannot reach - Oracle writes the card database in place, Servatrice
has no window - without force-stopping anything. A failed purge offers the
same close-and-retry, tries one last purge when nobody is named, and every
dialog shows in every mode: a silent reinstall stays silent until
something goes wrong.

* [Installer] Name processes running from the install directory

The running-instance guard answers from the Restart Manager, which reports
who has a file open. A running instance does not have to have one open: its
own image and the modules it loaded are mapped into it rather than opened by
it, and a mapping is not reliably reported. That is the state the probe was
added for - every DLL still on disk, an instance running from the directory,
and "nothing is holding them any more" as the answer.

A guard built on file handles alone is blind to exactly that instance, so it
passes, the purge fails, the holder lookup after it names nobody either, and
the install ends in a warning that no running program was found: no close is
attempted and no retry is offered. A manual install with another instance
open never asks it to close at all.

Name every process whose image lives in the directory being replaced as
well, with the same bitness-safe QueryFullProcessImageNameW the probe
already uses to label its holders. The image lookup is listed first - it is
the instance the user is asked to close - and the Restart Manager entries
follow with the pids already named dropped, so the close still reaches
everything that holds a file. A Restart Manager call that fails only makes
the probe unable to answer when the image lookup named nobody either.

Ask instead of dead-ending when the probe cannot answer at all: Retry checks
again, Ignore carries on, Abort cancels, in the installer and the uninstaller
alike, and the abort dialog says the check failed instead of claiming no
running program was found.

The Windows CI probe check covers the new lookup with a process started from
the directory that holds no file open.

* [Installer] Offer to wait again when the launcher has not exited

A client that is still closing after 60 s is usually not hung: it is showing
its own "quit anyway?" confirmation for an unsaved deck, an open game or a
card database update, and the user is the one who has to answer it. The
installer answered that with a plain warning and stopped, so the only way
forward was to close Cockatrice by hand and run the update again - which on
an in-app update means finding the downloaded installer.

Ask instead: Retry brings the client to the front, asks it to close again
(WM_CLOSE, nothing forced) and keeps waiting on its handle, Cancel stops as
before. There is no "install anyway" answer because a live process has its
image and Qt DLLs mapped, so writing the payload over them is the broken
install this wait exists to prevent - and the guard that runs right after
names the same process by its image path and stops there, so ignoring would
only move the same question a moment later.

* [Installer] Ask about a launcher that has not exited after ten seconds

A minute of silence is a long time to sit in front of an installer that has no
window yet, and the case it is waiting for is not a slow flush: a client that
started an update exits in well under a second, so a minute means it is almost
always showing its own "quit anyway?" confirmation with nobody looking at it.

Wait ten seconds for the first question instead, and the full grace for the
ones after it. The normal path is untouched - WaitForSingleObject returns as
soon as the handle signals, so a client that exits in 200 ms still costs 200
ms - and answering the first question once does not turn into a stream of
prompts while the user is in Cockatrice's window.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-26 19:25:10 +01:00
sentry[bot]
4793c1fc04
[Installer] Offer to close blocking processes and retry purge on upgrade (#7664)
Some checks are pending
CodeQL / Analyze (cpp) (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
Build Desktop / Configure (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 cover (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 26 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Desktop / Prune caches (push) Waiting to run
Build Docker / Servatrice (arm) (push) Waiting to run
Build Docker / Servatrice (x86) (push) Waiting to run
Build Docker / Publish multi-platform Servatrice image (push) Blocked by required conditions
* [Installer] Offer to close blocking processes and retry purge on upgrade

* [Installer] Retry the purge after closing only our own applications

The first version of this fix closed every Restart Manager holder through
RmShutdown, forcing what refused to close. RmShutdown also stops services, and
the only API that brings them back is RmRestart, which would restart Cockatrice
itself into the files that are about to be replaced; the forced pass also
contradicts the running-instance guard, which never force-closes Cockatrice
because it may be holding an unsaved deck. The guard ran first anyway, so for
the case the fix exists for - a Cockatrice restarted during the update - the
probe never got to run: the guard waited 60 s and aborted.

Keep the prompt and the retry, but ask only Cockatrice, Oracle and Servatrice to
close, with WM_CLOSE and nothing else, and leave every other holder named by the
probe to the user. Repeat the prompt while the user keeps saying Yes, refresh
the names after a failed retry, and keep the /R abort message pointing at the
downloaded installer.

Also clear the read-only attribute before every purge attempt. Delete and RMDir
leave such a file in place, so the retry could never make progress, and the
diagnostics branch that named the cause had no action behind it.

The holder probe goes back to being diagnostic-only, so its Restart Manager
code and the --close argument are dropped.

* [Installer] Make an accepted close of the running client actually land

WM_CLOSE is a request, and Cockatrice answers it: while a card database
update is running, a game is open or a deck is unsaved it puts up its own
"are you sure?" confirmation. That dialog opened behind the installer's
prompt, so the user saw nothing happen; the next Yes was posted to a main
window the app's own modal dialog had disabled, so it was ignored as well,
and the identical yes/no prompt came back with no hint of why.

- Activate the matching window before posting WM_CLOSE, so the app's own
  confirmation is in front where it can be answered.
- Wait up to 30 s for cockatrice.exe to exit instead of purging after a
  fixed 2 s, so the purge measures the app gone rather than its dialog.
- On the second and later rounds ask with Retry/Cancel and say what is
  needed, instead of repeating the same offer that just did nothing.
- Stop promising Oracle and Servatrice: they are console applications, so
  CloseMainWindow() has nothing to send WM_CLOSE to. They are not
  force-stopped either - Oracle writes the card database in place.

* [Installer] Close the client from the holder probe, not PowerShell

Accepting the offer to close did nothing on a real machine: the instance stayed
up, never came forward, and the same prompt came back.

The prompt names its holders from the Restart Manager probe, but the close itself
went through powershell.exe: Get-Process -Name, a strict "path starts with
$INSTDIR" filter, then Process.CloseMainWindow() and a WScript.Shell AppActivate.
Every step of that is silent. A process whose path cannot be read is filtered
out, a process .NET finds no main window for makes CloseMainWindow() return
false without posting anything, and AppActivate is refused for a background
caller - so an attempted close, a missed close and a close request the client
ignored all looked the same in the log: nothing.

Ask the probe instead. installer-holder-probe --close-holders closes exactly the
processes Restart Manager just named as holding a file of the version being
replaced, so no executable path has to be guessed and a third party application
that merely shares the image name is never in the set. Per instance it restores
and brings the top-level window forward (AttachThreadInput, so the client's own
"really quit?" confirmation is on screen rather than behind the installer), posts
WM_CLOSE to the main window, and waits up to 30 s for the process to exit. Its
report names every holder with what happened to it, including the ones left to
the user, and goes into the installer log as one line.

That also removes the separate PowerShell polling loop that used to wait for the
close: the probe holds a handle on the process it asked, so the purge that
follows measures a settled directory.

Oracle and Servatrice are still never closed, and nothing is force-stopped.

---------

Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com>
Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-26 09:45:52 +01:00
BruebachL
675e1f7447
[Translation] Name languages without reading the translation files (#7662)
Some checks are pending
CodeQL / Analyze (cpp) (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
Build Desktop / Configure (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 cover (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 26 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Desktop / Prune caches (push) Waiting to run
Build Docker / Servatrice (arm) (push) Waiting to run
Build Docker / Servatrice (x86) (push) Waiting to run
Build Docker / Publish multi-platform Servatrice image (push) Blocked by required conditions
* [Translation] Name languages without reading the translation files

The language selector derived every label from the language's own .qm file:
it loaded the file and translated the literal "English" in the "i18n"
context. lupdate could never extract that literal from the callers, which
pass the DEFAULT_LANG_NAME macro, so a dummy QT_TRANSLATE_NOOP in
cache_settings.cpp existed to give it something to extract. #7050 deleted
that dummy, the next lupdate push dropped the string from Transifex, and
the following pull rewrote the .ts files without it. lrelease only emits
translated messages, so 22 of 25 .qm files returned an empty name.

The names are data, not translations: a selector has to be readable before
any translation is loaded, and a label must not depend on the translation
of the language it names. TranslationLoader::languageDisplayName now holds
them next to availableLanguages, falling back to Qt's native language name
and then to the code, so a language added later still gets a label.

Also select the configured language by item data instead of by matching the
translated "English" string, which pointed at the wrong row whenever the
lookup came back empty, and restore that English entry's own name: en_US is
"English (United States)" so it can no longer be confused with the
untranslated default.

Oracle shared the code path, so its wizard language page is fixed too.

* [Translation] Name the default language selection once

Review follow-up: log the unresolvable language through the page's own
category, and move the en_US entry both selectors fall back to for an unset
setting into TranslationLoader::defaultLanguageCode.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-26 02:39:26 +01:00
sentry[bot]
858b270fa9
[Installer] Retry locked DLL purge before aborting upgrade (#7657)
Some checks are pending
CodeQL / Analyze (cpp) (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
Build Desktop / Configure (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 cover (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 26 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Desktop / Prune caches (push) Waiting to run
Build Docker / Servatrice (arm) (push) Waiting to run
Build Docker / Servatrice (x86) (push) Waiting to run
Build Docker / Publish multi-platform Servatrice image (push) Blocked by required conditions
* [Installer] Retry locked DLL purge before aborting upgrade

* [Installer] Keep the purge verdict when the portable purge runs

$IsUpgrade was read from IsExistingCockatriceInstall after the portable purge,
which deletes the very markers that function looks for. A purge whose only
survivors were non-Qt DLLs (abseil_dll.dll, libssl-3-x64.dll) or content under
Plugins therefore reported "not an install", $IsUpgrade became 0 and the abort
was skipped - the mixed DLL set the retry exists to prevent. Measure the
directory before either purge block, where it still describes the directory as
it arrived, and let RetryDllPurge's own verdict decide the abort.

A portable install into a directory without portable.dat is neither purge
block, so $StaleRuntimeAfterPurge was never assigned there and the abort could
not fire either. Track whether a purge ran and measure that directory with
HasStaleRuntimeFiles instead, which is what every upgrade did before the retry.

---------

Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com>
Co-authored-by: BruebachL <44814898+BruebachL@users.noreply.github.com>
Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-25 20:40:57 +01:00
ebbit1q
4d8ab54313
Revert "[Version] Bump to 3.2.0 for beta release. (#7653)" (#7660)
This reverts commit e6b4c51585.
2026-10-25 20:19:23 +01:00
BruebachL
e6b4c51585
[Version] Bump to 3.2.0 for beta release. (#7653)
Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-25 20:14:21 +01:00
sentry[bot]
4ab875be48
[VisualDeckStorage] Keep databaseLoadIndicator alive when swapping in folder widget (#7651)
Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com>
2026-10-25 17:35:46 +01:00
sentry[bot]
f3fb3331d1
[Cards] Guard against null QScreen when positioning enlarged pixmap (#7650)
Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com>
2026-10-25 17:35:10 +01:00
BruebachL
93309e01a2
[Server/Client/Protocol] Let moderators clear banner cards (#7546)
Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-25 13:19:20 +01:00
BruebachL
b4e24f958a
[Network] Do not format discarded debug messages, parse without memmove (#7606)
Some checks are pending
CodeQL / Analyze (cpp) (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
Build Desktop / Configure (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 cover (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 26 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Desktop / Prune caches (push) Waiting to run
Build Docker / Servatrice (arm) (push) Waiting to run
Build Docker / Servatrice (x86) (push) Waiting to run
Build Docker / Publish multi-platform Servatrice image (push) Blocked by required conditions
* [Network] Do not format discarded debug messages, parse without memmove

Protocol debug logging gets the full protobuf message rendered through
TextFormat and reflection. On the server this happened before every
session/room/game/moderator/admin/developer command, even though the
default Server_ProtocolHandler::logDebugMessage() is a no-op and even
when servatrice has no log file configured. On the clients the same
string was built as an argument to qCDebug(), which evaluates its
arguments regardless of whether the category is enabled.

Server_ProtocolHandler now exposes a virtual logsDebugMessages() hook.
The base implementation returns false, so handlers without a log
destination spend nothing. AbstractServerSocketInterface overrides it
with ServerLogger::isLoggingEnabled(), which checks the log file and the
server/writelog setting that logMessage() itself consults. All debug
message call sites check the hook before formatting. The client
send/receive paths check QLoggingCategory::isDebugEnabled() first, the
same pattern AbstractClient already uses for its slow-command warning.
ISL message decoding does the same.

Byte stream decoding called inputBuffer.remove(0, n) for the length
header and then again for every message. Each call shifts all remaining
bytes, so a read carrying k messages cost O(k * bytes remaining). The
three receive loops (remote client, TCP server interface, ISL interface)
now parse at a read offset and compact the consumed prefix once, when
they stop because a full message is not buffered yet. No buffering or
size-limit behavior changes.

Also in this batch:
- Server_ProtocolHandler::processGameCommandContainer() copied the whole
  games map under gameListMutex just to look up one game id. A new
  getGameInfo() performs the single lookup and returns the room/player
  ids.
- The TCP receive loop's `ok` flag is initialized, so a ParseFromArray
  exception can no longer leave it indeterminate before `if (ok)`.
- The exception log prints only the message being parsed instead of
  hex-dumping the entire pending buffer.

Full build and ctest (628 tests) pass, including the server tests.

* [Network] Address review comments

- Move getGameInfo() out of the header into server_abstractuserinterface.cpp.
- Drop the consumed prefix once after the parse loop instead of on every
  early return: the two "not enough data" exits now break out of the loop.
  Applied to the remote client, TCP server and ISL receive loops.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-25 12:49:24 +01:00
Neel Jogani
a4c4c67674
Fix tab retranslation (#7580)
* Retranslate all open tabs on language change

* Address review: deeper Visual DB retranslation, index-based Archidekt size logic

In Qt 6, setItemText() on the current item emits currentTextChanged but not
currentIndexChanged, so connecting the Archidekt combos to currentTextChanged
fired a search on every language change. Both combos now use currentIndexChanged,
and buildSearchUrl() compares the size-logic combo by index so translated labels
cannot drop sizeLogic.

* Derive display mode button text in one place
2026-10-25 12:30:17 +01:00
BruebachL
f33cfcb288
[UI] Show 'Password saved' placeholder when verifier is stored (#7649)
Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-25 10:34:56 +01:00
BruebachL
5fbc636f93
[Models] Use QCollator for natural, cached sorting (#7633)
Some checks are pending
CodeQL / Analyze (cpp) (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
Build Desktop / Configure (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 cover (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 26 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Desktop / Prune caches (push) Waiting to run
Build Docker / Servatrice (arm) (push) Waiting to run
Build Docker / Servatrice (x86) (push) Waiting to run
Build Docker / Publish multi-platform Servatrice image (push) Blocked by required conditions
* [Models] Use QCollator for natural, cached sorting

QString::localeAwareCompare rebuilds its collation data for every
comparison, and it compares numbers as text. Keep one QCollator per model,
enable numeric mode, and use it for the card name, set and user name sorts.

Add a test that sorts "Forest 1", "Forest 2", "Forest 10" through
DeckListSortFilterProxyModel and checks the natural order.

* [Models] Sort naturally under the C locale too

Numeric mode only takes effect through a real locale: under the C locale,
which headless CI processes run in, QCollator silently falls back to string
order and sorts "Forest 10" before "Forest 2". That made collator_sort_test
fail on every Linux leg while passing locally.

Add makeNaturalSortCollator(), which enables numeric mode and pins English
only when the process locale is the degenerate one, and build the models'
collators through it. Users with a real locale keep their own collation.

* [Models] Fix compile of natural sorting (QStringView mismatch)

* nit: initialize collators in member initializer lists

* nit: namespace makeNaturalSortCollator under NaturalSort

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-25 01:01:04 +02:00
BruebachL
5ddfcd7ead
[Pictures] Watch the custom picture folder instead of polling (#7635)
The local picture loader walked the whole custom picture tree every ten
seconds on the GUI thread. Watch the root and every subdirectory with
QFileSystemWatcher, debounce bursts of events into a single 500 ms refresh,
and keep a five-minute fallback poll for filesystems where watching fails.

Add an explicit-path constructor and indexedPaths() so the indexing can be
tested without SettingsCache, and emit indexChanged() after a rebuild.

Verification: cockatrice built.

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-25 00:36:14 +02:00
BruebachL
518c7e6125
[Network] Add a configured QNetworkAccessManager factory (#7628)
* [Network] Add a configured QNetworkAccessManager factory

Every HTTP caller built its own QNetworkAccessManager, so the system proxy
and a transfer timeout were easy to omit. Add NetworkManagerFactory::create()
as the single place that applies the process-wide proxy configuration and a
transfer timeout, and use it at all 14 construction sites.

Per-site settings such as disk caches and manual redirect policies stay with
their callers.

* [Tests] Link libcockatrice_network into the tests that compile its headers

release_channel.cpp and card_picture_loader_worker.cpp now include
libcockatrice/network/network_manager.h, but the test targets that compile
them only linked libcockatrice_settings, so the include directory never
reached them and every platform failed with "file not found":
loader_local_matching_test and picture_loader_benchmark_test.

libcockatrice_network is an INTERFACE library, so linking it costs these
targets nothing but hands over the include path.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-25 00:33:43 +02:00
BruebachL
3b07b10b19
[Avatar] Respect EXIF orientation and decode at target size (#7634)
Read the avatar with setAutoTransform(true) so camera orientation is
applied, and ask QImageReader for a scaled decode at MAX_AVATAR_DIMENSION
instead of decoding a full-resolution photo and scaling it afterwards.

Verification: cockatrice built.

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-25 00:11:09 +02:00
BruebachL
66b9338c90
[QtUtils] Use makeScrollArea() for the remaining scroll areas (#7625)
Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-24 22:50:25 +02:00
BruebachL
77a3875c25
[VisualDatabase] Debounce filter refreshes with QtUtils::Debouncer (#7624)
Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-24 22:48:11 +02:00
BruebachL
28c8474f03
[QtUtils] Add Debouncer helper (#7623)
* [QtUtils] Add Debouncer helper

* Extract long message to variable before confirm() call

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-24 22:43:10 +02:00
BruebachL
11e2bb0d2f
[QtUtils] Adopt window fade, path, bold font and confirmation helpers (#7622)
Some checks are pending
CodeQL / Analyze (cpp) (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
Build Desktop / Configure (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 cover (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 26 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Desktop / Prune caches (push) Waiting to run
Build Docker / Servatrice (arm) (push) Waiting to run
Build Docker / Servatrice (x86) (push) Waiting to run
Build Docker / Publish multi-platform Servatrice image (push) Blocked by required conditions
Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-24 21:43:50 +02:00
BruebachL
eac149656b
[QtUtils] Add window fade, path opening, bold font and confirmation helpers (#7621)
Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-24 21:38:54 +02:00
BruebachL
e806085681
[QtUtils] Use makeLayout() for zero-margin layouts (#7620)
* [QtUtils] Add parent lookup, layout clearing, scroll area and monospace helpers

* [QtUtils] Adopt shared Qt helpers across the client

* [QtUtils] Add layout creation and relayout helpers

* Swap parameter order (spacing before margin) and remove static from relayout()

* [QtUtils] Use relayout() after rebuilding dynamic layouts

* [QtUtils] Use makeLayout() for zero-margin layouts

* Consolidate setSpacing() calls into makeLayout() using new parameter order

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-24 19:06:18 +02:00
BruebachL
06b9ca368f
[QtUtils] Add layout creation and relayout helpers (#7619)
* [QtUtils] Add layout creation and relayout helpers

* Swap parameter order (spacing before margin) and remove static from relayout()

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-24 19:01:56 +02:00
BruebachL
67a212d677
[Theme] Drop stale pixmap caches on palette-only preview (#7600)
Some checks are pending
CodeQL / Analyze (cpp) (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
Build Desktop / Configure (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 cover (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 26 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Desktop / Prune caches (push) Waiting to run
Build Docker / Servatrice (arm) (push) Waiting to run
Build Docker / Servatrice (x86) (push) Waiting to run
Build Docker / Publish multi-platform Servatrice image (push) Blocked by required conditions
A palette preview repaints widgets through applyStyleAndPalette without running
themeChangedSlot, so generator-owned pixmaps stayed at the previous palette
until the next full theme switch. Clear them where the palette is applied.

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-24 10:40:20 +02:00
BruebachL
4028606bd9
[CardDB] Clear CardInfo relations before deferred deletion (#7615)
removeCard() and swapInDatabaseData() schedule CardRelation deletion with deleteLater(), but the owning CardInfo could outlive them through an external shared pointer and kept returning the freed pointers from getAllRelatedCards(). Add CardInfo::clearDirectRelations() and call it after scheduling deletion so the UI can no longer dereference dangling relations.

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-24 10:28:24 +02:00
Neel Jogani
6e770eb7b2
Retranslate Home tab when the language changes (#7579)
* Retranslate Home tab when the language changes

TabSupervisor::retranslateUi() never included the Home tab, and
HomeWidget set its button text only once, when the buttons were
created. Switching languages left the Home tab in the old language
until it was reopened.

Store the Home buttons as members, add HomeWidget::retranslateUi()
to set their text, and call it from TabHome and TabSupervisor. The
connect button text still only changes for the Connecting,
Disconnected and LoggedIn statuses, as before.

* Address review: plain ClientStatus, drop optional and placeholder
2026-10-24 08:35:31 +02:00
Neel Jogani
e73dd2b520
Refactor display type toggler and add language change handling in Vis… (#7581)
* Refactor display type toggler and add language change handling in VisualDeckDisplayOptionsWidget

* Address review: extract updateDisplayTypeButton(), document changeEvent

* Restore trailing newline in visual_deck_display_options_widget.cpp
2026-10-24 08:34:19 +02:00
BruebachL
ab13a6748f
[QtUtils] Adopt shared Qt helpers across the client (#7618)
* [QtUtils] Add parent lookup, layout clearing, scroll area and monospace helpers

* [QtUtils] Adopt shared Qt helpers across the client

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-24 08:26:21 +02:00
BruebachL
1e0fc35d94
[QtUtils] Add parent lookup, layout clearing, scroll area and monospace helpers (#7617)
Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-24 08:26:02 +02:00
BruebachL
bbc6003f0a
[CrashReporting] Name the files and processes behind a failed installer purge (#7590)
Some checks are pending
CodeQL / Analyze (cpp) (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
Build Desktop / Configure (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 cover (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 26 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Desktop / Prune caches (push) Waiting to run
Build Docker / Servatrice (arm) (push) Waiting to run
Build Docker / Servatrice (x86) (push) Waiting to run
Build Docker / Publish multi-platform Servatrice image (push) Blocked by required conditions
* [CrashReporting] Log which files and processes blocked the installer purge

A purge that fails aborts the upgrade with a message that can only guess
at the cause, and the log only listed which DLLs survived. Record what
Windows reports for each survivor and which processes still have the
install directory mapped, then repeat the holder list in the abort
message.

The diagnostics are additive and run only on the abort path: they open
each survivor for DELETE access and close it again without deleting, so a
failed upgrade can never end up half-removed. The purge itself is
unchanged.

* [CrashReporting] Name the holders of a failed purge with Restart Manager

The process-and-module scan could only name a holder whose image lives in the
install directory or that loaded one of its DLLs, so the case this diagnostic
exists for - an antivirus scanner holding a plain handle - was reported as "no
holder named, only a code". Reaching the Restart Manager from the NSIS script
would have meant marshalling RM_PROCESS_INFO through System::Call as a table of
hand-computed offsets, which cannot be checked without a Windows toolchain, and
that is why it was dropped when this diagnostics was written.

Move the call into tools/installer-holder-probe: a small executable the same
Windows build compiles, linking nothing from this project so it still starts
when the installed runtime is what the purge could not replace. It rides inside
the installer and is extracted to $PLUGINSDIR only when the purge has already
failed, so the successful path is unchanged and nothing new is installed.

.ci/compile.sh runs it against a file held with FileShare.None on the Windows
runners, so the lookup is exercised on real Windows instead of only compiled.

* [CrashReporting] Fix the holder probe check and cap the holder line

The probe block ran after the package steps, where the working directory is
already the build dir, so its $BUILD_DIR-prefixed paths named build/build and
the PowerShell file could not be written. Move it ahead of install/package,
where the build dir is still the cwd, and name both files relative to it.
Guard it on MAKE_NO_CLIENT, since the probe is only built with the client.

Cap the rendered holder line at HOLDER_LINE_CHAR_MAX: the count limit alone
does not keep long service names inside the NSIS string that also carries the
abort text and the log path. Whole entries are kept and the rest counted, with
the first holder always shown.

Also run cmake-format on the probe's CMakeLists so the lint job accepts it.

* [CrashReporting] Report a read-only purge survivor and bound the probe check

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-24 00:46:54 +02:00
BruebachL
ad4320cd7b
[CardDB] Stream card properties from blobs (#7608)
* [CardDB] Stream card properties from blobs

Parsed and cached cards used to keep a materialized QHash<QString,
QString> for their properties: LazyPropertiesHash only deferred building
that hash until the first query, and the cache writer re-serialized it on
save.

- LazyPropertiesHash answers value(), contains(), size(), isEmpty() and
  keys() straight out of the serialized blob, so a property query no
  longer builds a hash.
- serialize() is public, and the cache writer passes the stored blob
  through instead of serializing a hash it just rebuilt.
- CardInfo and PrintingInfo expose getPropertiesBlob() and
  getPropertyCount(); PrintingInfo::operator== compares key by key
  without materializing either side.
- The XML parsers build cards via newInstanceFromPropertiesBlob(), which
  derives the simple name and the alternate names that the parser used
  to keep next to the hash.

The reload cycle fix stays out of this change; it depends on changing
the CardInfo self pointer and is reviewed separately.

* [CardDB] Address review comments on streaming properties

* [CardDB] Address final review nits on property streaming

Move BlobReader and the blob accessors into LazyPropertiesHash as private
members so the parsing helper is declared with the class instead of hiding
in the translation unit.

Add LazyPropertiesHash::serializedFrom() and use it where a PrintingInfo is
built from a fresh property hash, replacing the nested
LazyPropertiesHash(LazyPropertiesHash::serialize(props)) construction.

The pre-materialized QHash constructor stays: CardInfo's non-blob factory
still uses it for the oracle import path, and the tests cover it directly.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-24 00:17:31 +02:00
BruebachL
c25a2c5c3b
[CardDB] Avoid copying printing maps and property strings (#7605)
Some checks are pending
CodeQL / Analyze (cpp) (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
Build Desktop / Configure (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 cover (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 26 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Desktop / Prune caches (push) Waiting to run
Build Docker / Servatrice (arm) (push) Waiting to run
Build Docker / Servatrice (x86) (push) Waiting to run
Build Docker / Publish multi-platform Servatrice image (push) Blocked by required conditions
* [CardDB] Avoid copying printing maps and property strings

Reading a card's sets was a measured hotspot: `SetToPrintingsMap setMap =
card->getSets();` copies the whole QMap, and then iterating it with a
non-const iterator detaches and deep-copies every PrintingInfo, including
its property hash, on first use. A micro-benchmark of that exact pattern
(copy plus non-const iteration) came out about 19x slower than a const
reference over the same data.

The querier, the cache writer, the XML writer, the picture loader and the
printing/set selection widgets now take `getSets()` by const reference and
only use const iterators. The tests that iterate generated data are
covered by the existing card database and picture loader suites.

String accessors were also returning by value where the value lives in a
hash that has already been materialized:
- CardInfo::getProperty() and the legacy getCardType()/getColors()/
  getManaCost()/getCmc()/getPowTough()/getLoyalty() accessors,
- PrintingInfo::getProperty(), getUuid(), getFlavorName(), getArtist(),
  and getSet(),
- ExactCard::getPrinting() and getCardPtr(),
- CardSet::getShortName(), getLongName() and getSetType().

getPrinting() in particular copied a PrintingInfo on every call, and a
copy of an unmaterialized PrintingInfo has to take the properties mutex.
Callers that need an owned copy still get one; the rest just read.

Other allocation removals in the same path:
- LazyPropertiesHash::ensureMaterialized() checks an atomic flag before
  taking the mutex, so property reads after the first one do not lock.
  The flag is release-stored after materialization and acquire-loaded by
  readers/copies, preserving the existing locking for the first read.
- CardSet::getCorrectedShortName() no longer rebuilds its QSet of
  reserved Windows file names on every call.
- CardSet::getIsKnownIgnored() no longer formats the release date into a
  throwaway string just to check its length.
- The querier builds its card-type/format count caches without
  `cards.values()` or a properties key list, reserves its result lists,
  and picks a random card without materializing `cards.keys()`.
- The querier's pinned-printing path reuses the CardInfoPtr it already
  has instead of looking the card up again by name.
- CardDatabaseCache/XML serialization copy the alt-name QSet by
  reference.
- The picture loader compiles its !prop!/!set! regular expressions once
  instead of per URL template, and walks the transform map with an
  iterator instead of keys() plus operator[].

Full build and ctest (628 tests) pass.

* [CardDB] Address review comments

PrintingInfo is a value type: it is stored in QLists and returned by value
from the querier, so a const-ref getter can hand out a reference into a
temporary. Return the QStrings and the CardSetPtr by value again.

The constFind lookup stays, so a property read still never materializes the
lazy property hash - only the last shared-data refcount is paid.

* [CardDB] Undo the const-ref QString return changes

QString is implicitly shared, so returning by value costs only a refcount
and avoids handing out references into a lazily materialized hash. The
constFind lookups stay, so a property read still never materializes the
hash.

* [CardDB] Use QMap::asKeyValueRange() in transformUrl

The range is a view over the map's own iterators and yields references to
the stored key and value, so it keeps the no-allocation property of the
manual const iteration.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-23 11:58:58 +02:00
BruebachL
b85436461e
[Filters] Precompute term data instead of redoing it per card (#7603)
* [Filters] Precompute term data instead of redoing it per card

Card filters are evaluated once per card in the database, and several
attributes derived constant values from the filter term on every single
evaluation:

- acceptColor() ran up to twelve case-insensitive string replacements and
  a space strip for each card, then called getColors() again for every
  character of the term.
- acceptRarity() ran the rarity abbreviation expansion loop per card.
- acceptManaCost() uppercased and sorted the term per card, before
  sorting the card's own cost.
- acceptLoyalty() and acceptFormat() trimmed/lowercased the term per card.
- relationCheck() re-parsed the numeric operator and operand per card,
  for every cmc/power/toughness/loyalty comparison.

The term of a FilterItem never changes, so these values are now computed
once in the constructor and only the card-dependent part remains in the
accept path. For colors this also removes the repeat getColors() call,
which materializes the printing properties hash.

Additionally:
- CardInfoPtr is passed by const reference through FilterItemList,
  FilterItem and FilterTree instead of by value, removing an atomic
  refcount pair per card per filter node.
- Main/sub type filtering no longer allocates a QStringList via split();
  it looks the " - " separator up directly and compares QStringViews.
- acceptSet() and acceptPowerToughness() fetch the printing/set or the
  power/toughness string once instead of once per branch/accessor.

No filtering behavior is intended to change. Invalid numeric terms (for
example a bare "<") now return false instead of indexing trimmedTerm[1]
past the end.

Verified with filter_string_test (42 tests) and a full client build.

* [Filters] Address review comments

- Replace the main/sub type separator accessor function with a plain
  namespace-scope constant.
- Take card type, colors, cmc, loyalty, power/toughness and the printing's
  set by value; those getters already return by value.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-23 11:23:17 +02:00
BruebachL
f4b4365464
[Search] Stop allocating per card in card-name completion (#7604)
* [Search] Stop allocating per card in card-name completion

CardSearchModel::updateSearchResults() runs on every textEdited()
signal, walks the whole card database and runs Levenshtein against every
matching name. Three costs dominated that path:

1. Every candidate built a QStringList of its searchable names, then
   lowercased each name into another QString, only to break out of the
   loop after the first hit.
2. The sort comparator called sortableName() for each comparison, which
   re-fetched the name (QMap lookup plus QString construction) O(n log n)
   times.
3. levenshteinDistance() allocated a full len1 x len2 vector of vectors.

The search model now lowercases the one name it tests directly, stores
the tie-breaking name length once per result, and the comparator only
compares integers. levenshteinDistance() uses the standard two-row
formulation: same result, O(len2) memory instead of O(len1*len2), with
early exits for empty and equal strings.

FilterString query closures get the same treatment:
- Set queries iterate the set map directly instead of materializing
  keys()/values() lists for every card.
- Rarity queries iterate printings in place instead of copying them into
  a QList<PrintingInfo> first.
- Power/toughness split the string with indexOf and compare QStringViews
  instead of allocating a QStringList.
- Field queries do one hash lookup instead of contains() followed by
  value().
- Color queries bind the colors string by reference instead of copying.

Verified with filter_string_test (42 tests) and a full client build.

* [Search] Address review comments

- Use std::any_of/std::none_of over the set map's key/value iterators
  instead of hand-rolled loops.
- Extract the name matching into matchingLowercaseName() returning
  std::optional<QString>.
- Reuse the matched name's length for the distance tie-break instead of
  looking the name up again, removing one getLocalizedName() call per card.
- Take the color strings by value; the getters already return by value.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-23 11:23:04 +02:00
BruebachL
4980f88a7a
[CardDB] Sort power/toughness without splitting every comparison (#7607)
* [CardDB] Sort power/toughness without splitting every comparison

When the card database is sorted by the P/T column, lessThan() runs
O(n log n) times over the whole view, and each call split both strings
into a QStringList ("power/toughness") and then read the two elements
back. For the roughly 36k cards in the database that is millions of
list and string allocations, all for a value that contains at most one
separator.

The comparator now locates the single '/' with indexOf and passes the
two halves as QStringView to lessThanNumerically(), which is changed
from QString to QStringView. The numeric parsing, the "1+*" fallback and
the locale-aware tie-break are unchanged, and strings with no separator
or more than one still fall through to the plain string comparison,
exactly as split()-based size checks did before.

lessThanNumerically() also initializes its two bool out-parameters; they
were declared uninitialized, which only worked because toFloat() always
assigns them.

Compared old and new comparator over a corpus of 2024 power/toughness
strings (4096576 ordered pairs, including malformed values): zero
differences. Full build and ctest (628 tests) pass.

* Extract well-formedness checks in P/T comparator

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-23 10:34:13 +02:00
BruebachL
e666a4978d
[VisualDatabase] Use scoped QSignalBlockers for filter updates (#7641)
* [VisualDatabase] Use scoped QSignalBlockers for filter updates

updateFilterModel() blocked signals on the filter model and its filter tree,\nthen unblocked each of them twice. The second pair was a no-op only because\nthe first pair had already restored the default. Use two scoped\nQSignalBlockers instead, so the models are unblocked exactly once before the\nexplicit changed()/layoutChanged() notifications.\n\nVerification: cockatrice built.

* Drop stale typo note from QSignalBlocker comment

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-23 10:33:29 +02:00
tooomm
c4c7cc0253
[README] Small tweaks (#7585)
Some checks are pending
CodeQL / Analyze (cpp) (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
Build Docker / Servatrice (arm) (push) Waiting to run
Build Docker / Servatrice (x86) (push) Waiting to run
Build Docker / Publish multi-platform Servatrice image (push) Blocked by required conditions
2026-10-22 20:13:43 +02:00
BruebachL
0fe4fd0d8c
[CardDB] Guard guessCard and findPrintingWithId against missing cards (#7592)
Some checks failed
CodeQL / Analyze (cpp) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
Build Desktop / Configure (push) Has been cancelled
Build Desktop / Prune caches (push) Has been cancelled
Build Docker / Servatrice (arm) (push) Has been cancelled
Build Docker / Servatrice (x86) (push) Has been cancelled
Build Desktop / Debian 13 (push) Has been cancelled
Build Desktop / Debian 12 (push) Has been cancelled
Build Desktop / Fedora 44 (push) Has been cancelled
Build Desktop / Fedora 43 (push) Has been cancelled
Build Desktop / Servatrice_Debian 12 (push) Has been cancelled
Build Desktop / Ubuntu 24.04 (push) Has been cancelled
Build Desktop / Ubuntu 26.04 (push) Has been cancelled
Build Desktop / Arch (push) Has been cancelled
Build Desktop / Ubuntu 26.04 cover (push) Has been cancelled
Build Desktop / macOS 13 Intel (push) Has been cancelled
Build Desktop / macOS 14 (push) Has been cancelled
Build Desktop / macOS 15 (push) Has been cancelled
Build Desktop / macOS 26 Debug (push) Has been cancelled
Build Desktop / Windows 10 (push) Has been cancelled
Build Docker / Publish multi-platform Servatrice image (push) Has been cancelled
* [CardDB] Guard guessCard and findPrintingWithId against missing cards

guessCard() dereferenced a null CardInfoPtr through findPrintingWithId() when the name was unknown and a providerId was given. Return an empty ExactCard instead, and make findPrintingWithId() null-safe for every caller.

Add regression tests covering both paths.

* [CardDB] Rely on PrintingInfo's default null set in findPrintingWithId

The explicit nullptr was redundant: the constructor already defaults
the set to null.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-20 21:33:56 +02:00
BruebachL
9980c90cea
[Filter] Allow spaces around the colon in search queries (#7529)
* [Filter] Allow spaces around the colon in search queries

* [Filter] Keep whitespace out of the color-identity operator

Allowing `ws?` between `color` and the identity marker made `c i: g`
parse as identity green, changing the meaning of text that previously
read as a generic `c` plus a field query on `i`. Whitespace belongs
around the separator, not inside an operator token: `ci : g` and
`ci: g` stay valid, `c i: g` keeps its old parse.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-20 21:20:10 +02:00
BruebachL
9709fe4a1a
[FlowWidget] Restore arrow-key navigation for items in flow widgets (#7591)
Some checks are pending
CodeQL / Analyze (cpp) (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
Build Desktop / Configure (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 cover (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 26 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Desktop / Prune caches (push) Waiting to run
Build Docker / Servatrice (arm) (push) Waiting to run
Build Docker / Servatrice (x86) (push) Waiting to run
Build Docker / Publish multi-platform Servatrice image (push) Blocked by required conditions
Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-20 16:53:04 +02:00
BruebachL
829152f388
[Tooling] Add publish_beta.sh to automate Development beta tagging (#7505)
Some checks are pending
CodeQL / Analyze (cpp) (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
Build Desktop / Configure (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 cover (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 26 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Desktop / Prune caches (push) Waiting to run
Build Docker / Servatrice (arm) (push) Waiting to run
Build Docker / Servatrice (x86) (push) Waiting to run
Build Docker / Publish multi-platform Servatrice image (push) Blocked by required conditions
Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-19 15:01:18 +02:00
BruebachL
c6f682f9cf
[CrashReporting] Offer to send the installer log on abort (#7524)
* [CrashReporting] Installer log send offer on abort (inc 4)

DSN parsed to CPACK_SENTRY_* (scheme-aware for http self-hosts) before
include(CPack); new SendInstallerLog.ps1.in builds a byte-accurate
Sentry envelope (128 KiB tail cap, X-Sentry-Auth, exit 0/1) with the
abort reason read from the log file itself, so the NSIS side needs no
string functions and quoting stays trivial. AbortInstall asks consent
(attended installs only, silent path untouched, abort never
swallowed). Empty/malformed DSN disables the offer; no secrets in repo.

* [CrashReporting] Embed installer-log sender via a relative NSIS path

The AbortInstall hook embedded SendInstallerLog.ps1 through the absolute
CPACK_SENTRY_PS1_PATH, which carries CMake's forward slashes (D:/a/...).
makensis does not treat a forward-slash drive path as absolute, so it
resolved it relative to project.nsi, found nothing and aborted packaging
on the Windows jobs of every branch in the stack.

Embed it with the same '..\..\..\' relative path that already resolves
NSIS.definitions.nsh: both are configure_file'd into the build root,
three levels up from where CPack places project.nsi. Drop the now-unused
CPACK_SENTRY_PS1_PATH cache variable.

* [CrashReporting] Drop the installer /CRASHTEST hook, tidy the log sender

Remove the /CRASHTEST switch and the matching COCKATRICE_SENTRY_DSN
environment override on the installer abort path. With no deliberate
in-installer trigger left, a package built without a baked DSN must never
offer to post a user's log to an endpoint chosen by whatever happens to be
in the environment, so the prompt is now gated on CPACK_SENTRY_ENABLED
alone. This also retires the comment that claimed /S /R /CRASHTEST reaches
the prompt: the switch was evaluated before /R set ReinstallMode, so that
form never showed it.

Alongside: correct the CPACK_SENTRY_RELEASE comment (PROJECT_VERSION keeps
its "-<label>" on Development tags where CPACK_PACKAGE_VERSION does not),
delete the unused CPACK_SENTRY_HOST/CPACK_SENTRY_PROJECT_ID cache vars,
document the DSN regex's path-prefix fallback, log a declined send, keep
the uploaded installer log free of helper-injected newlines, and bound
the post with -TimeoutSec/-NonInteractive.

* [CrashReporting] Derive the installer DSN vars as plain variables

Review nit: every CPACK_SENTRY_* value is recomputed from
COCKATRICE_SENTRY_DSN on each configure, so CACHE ... FORCE bought
nothing. Set empty defaults once, override them in the match branch, and
drop the duplicated else() copies. Still CPACK_-prefixed, so include(CPack)
still snapshots them into CPackConfig.cmake.

* [CrashReporting] Gate the installer log send at build time

Review nit: test CPACK_SENTRY_ENABLED with the NSIS preprocessor
instead of copying it into $SentryEnabled and branching at runtime, so a
package built without a DSN compiles the prompt out rather than carrying
dead code. The configure_file that produces the helper is gated the same
way, so a build without reporting neither references nor embeds it.

* [CrashReporting] Disclose the Windows user name in the send prompt

Review nit: say that the log can include the Windows user name (a real
part of what gets uploaded) and drop the "No account needed." clause,
which answered a question the prompt never raised.

* [CrashReporting] Show progress while the installer log is sent

Review nit: DetailPrint a line before nsExec runs, so the details pane
says something while the send is in flight instead of staying blank for
up to the 60 s /TIMEOUT.

* [CrashReporting] Rename the Sentry event local in the installer helper

Review nit: $Event is a PowerShell automatic variable, so name the local
$sentryEvent instead. Also drop $firstLine: Select-String returns the
matching line alone, so the reason is already one physical line and the
extra split added nothing.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-19 14:57:04 +02:00
BruebachL
9f59f5c495
[CrashReporting] Breakpad capture, session detection and symbol upload (#7523)
* [CrashReporting] Breakpad everywhere, session detection, symbol CI (inc 3)

Breakpad on all platforms (no crashpad packaging); remove the Windows
MiniDumpWithFullMemory filter and the installer WER LocalDumps full-dump
registration. QLockFile per-process session tracking replaces the
Windows-only .dmp scan, placed after parser.process and the instance
hand-off so help/version exits and handed-off secondaries never leak a
lock. CI: Release-only DSN bake from SENTRY_DSN secret, tag-only
sentry-cli symbol upload (org cockatrice, project native).

Tests: 533/533 ctest green, format clean, code gate PASS.

* [CrashReporting] Enable http_retry, harden symbol upload, fix NSIS context

Sets sentry_options_set_http_retry so the cache_keep-held crash
envelope in Ask mode is written with a retry filename and actually
flushes on giveConsent() instead of sitting in <db>/cache until
pruning.

Adds continue-on-error to both sentry-cli install steps so a Sentry
registry outage or broken Git Bash install cannot block release
assets.

Cleans the legacy WER CrashDumps folder under SetShellVarContext
current (under all it resolves to ProgramData, not the user's
AppData) in installer and uninstaller; registry delete works in
either context.

* [CrashReporting] Extract the startup crash-report flow from main() per review

* [CrashReporting] Break up the CrashReporter class doc per review

* [CrashReporting] Drop the invalid --release from the symbol upload

`debug-files upload` never accepted `--release` (checked against the pinned
sentry-cli 3.8.0: the subcommand declares --org/--project and a set of
type/id/scan options, and execute() reads no release; the only global flags are
--url/--header/--auth-token/--log-level/--quiet/--allow-failure). Clap rejects
the unknown flag before any upload happens, so every tag build has been shipping
without symbols, with continue-on-error keeping the release pipeline green.

Release association is also the wrong mechanism: debug information files are
matched to events by debug ID via the event's debug_meta. The Sentry release of
a native event comes from the event itself (COCKATRICE_SENTRY_RELEASE), not from
the DIF upload. Remove the flag and the now-unused SENTRY_RELEASE env from both
invocations (Linux, and Windows/macOS).

The symbol upload stays continue-on-error (a Sentry outage must not block the
release assets); revisit once it has proven itself on a tag.

* [CrashReporting] Split debug information out of packaged release binaries

-g is required so the CI symbol upload has DWARF to send, but it was left in
the shipped binaries too: the Linux Release client is 563 MiB, 552 MiB of it
debug info, and every distribution channel pays for that.

Keep the build tree unchanged (CI uploads symbols from build/) and strip only
the packaged copy. A CPack pre-build script extracts each ELF's debug info to
COCKATRICE_DEBUG_SYMBOL_DIR and then runs `strip --strip-debug --strip-unneeded`.
.eh_frame is allocatable and survives, so unwinding is unaffected, and the build
ID is identical in the stripped binary and the sidecar.

Measured on a local Release build (GCC -O2 -g, WITH_CRASH_REPORTING=ON),
bytes:

  binary      build tree  packaged before  packaged after  .debug sidecar
  cockatrice 590,722,560      590,722,560      28,086,480     579,623,576
  oracle      53,432,608       53,432,608       1,649,888      52,075,344
  tar.gz of both trees: 289,964,521 -> 19,018,058 (~15x smaller).

Only ELF (Linux/BSD) is handled: Windows already links /DEBUG with separate
PDBs, and macOS extracts a dSYM from the bundle. Opt-out via
COCKATRICE_SPLIT_DEBUG_SYMBOLS; skipped with a warning when objcopy/strip are
missing. CPACK_PRE_BUILD_SCRIPTS (not CPACK_INSTALL_SCRIPTS, which runs before
staging) invokes it, and the script refuses to run unless CPACK_TEMPORARY_DIRECTORY
is a real CPack staging tree so a missing variable can never turn its glob into
a filesystem-wide walk.

* [CrashReporting] Upload Windows PDBs from where Ninja actually puts them

The Windows branch of the symbol upload looked in build/<target>/Release, which
is the Visual Studio generator's layout. The Windows leg uses Ninja, which is
single-config: linker PDBs land in build/<target>/, next to the binaries — the
same paths the "[Windows] Upload PDBs" artifact step below globs with
if-no-files-found: error.

So paths was always empty on Windows, the step printed "No symbol paths found,
skipping symbol upload" and exited 0: a silent no-op behind continue-on-error.
Every Windows build would then report "A required debug information file was
missing" for cockatrice.exe with a PDB-style debug id (GUID-age), exactly as if
no upload step existed at all.

Use the artifact step's globs. Also turn the empty-path case into a failure in
both symbol steps: a silent skip is indistinguishable from a successful upload
(which is how the wrong path went unnoticed), and continue-on-error already
keeps a broken upload from failing the release.

Verified by extracting both steps' run blocks and executing the Windows branch
against a Ninja-style tree: it now passes the three linker PDBs to sentry-cli
(and not the CMakeFiles/*.pdb intermediates), and exits non-zero with an
::error:: annotation when there is nothing to upload.

* [CrashReporting] Archive PDBs on tag builds so a failed upload is recoverable

The "[Windows] Upload PDBs" artifact step skipped tag builds
(github.ref_type != 'tag'), so for a release the symbols existed only inside
the Sentry upload — which is continue-on-error. A failed upload therefore lost
the ability to symbolicate that release permanently: no rebuild produces the
same PDB, since the debug id is a per-build GUID+age. That is how every
Windows crash reported so far became unreadable.

Archive the PDBs for tag builds as well, and order the step after
"Upload to release" so a missing PDB can never block a release from being
published. Retention stays at the repository default; nothing consumes the
artifact automatically, it is the manual recovery path
(gh run download <run> -n <name>-PDBs, then sentry-cli debug-files upload).

Verified by re-parsing the workflow and asserting the step order
(release -> PDBs -> attest) and the broadened condition.

* [CrashReporting] Upload the Windows executables with their PDBs

Sentry needs two things per image on Windows: symbolication and stack
unwinding. The PDB covers symbolication, but for a 64-bit PE the Call Frame
Information (.pdata/.xdata) lives in the executable and is not copied into the
PDB, so uploading PDBs alone leaves the image at "Stack Unwinding: Missing".
That is exactly what the event's Images panel showed for cockatrice.exe, while
Sentry's own Microsoft symbol server resolved KERNELBASE.dll and ucrtbase.dll
from the same dump.

sentry-cli also needs the pair in the same invocation: only then does it
associate the PE's code identifier with the PDB's debug identifier, otherwise
"the identifiers diverge and Sentry might not be able to resolve the correct
file for symbolication" (docs: Debug Identifiers).

Do the same for macOS, where the dSYM carries the debug information but the
Mach-O carries the unwind information, and include the executables in the
symbol artifact so a re-upload still has the pair to offer.

Verified by extracting the step and running both branches against stub
harnesses: Windows passes the three exe/pdb pairs in a single sentry-cli
invocation and still fails loudly on an empty tree; macOS passes the dSYM and
the Mach-O together.

* [CrashReporting] Upload the shipped Qt runtime DLLs too

Qt ships no PDBs with its MSVC SDK, so every Qt frame in a crash report
resolved to nothing: an image with neither PDB nor PE has no unwind
information (on 64-bit Windows the Call Frame Information lives in the PE
and is not copied into the PDB) and no symbol source to fall back on.

The list is required-runtime.txt, the manifest .ci/compile.sh already
asserts the installer against, so what gets uploaded is exactly what an
install carries. Qt frames now get unwinding plus export-table names; line
numbers still need Qt's debug information files, which the SDK omits.

The guards are not cosmetic: the step runs under `bash -eo pipefail`, where
a sed over a missing file or an unmatched ls glob aborts the whole step, and
continue-on-error makes that look identical to a successful upload.

* [CrashReporting] Upload only the binaries, archive symbols only on tags

The Linux leg handed sentry-cli whole build directories: 471 candidate files,
of which 7 were object files that can never match a stack frame. Only the
three linked binaries carry the build-ids Sentry looks up; the packaged copy
is split and stripped but keeps that same build-id.

The Windows symbol archive ran on every packaging build even though only tag
builds upload to Sentry, so a PR build paid for an artifact nothing could
ever consume. Tags keep it as the durable copy for a failed upload.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-19 14:57:04 +02:00
BruebachL
ad0fd2d4b0
[CrashReporting] Sentry send path with consent gating (#7522)
* [CrashReporting] Real send path with consent gating (DSN still placeholder)

require_user_consent + give/revoke around the Ask dialog, message event
with in-memory crash-log.txt bytes attachment, before_send scrubs
message/logentry/breadcrumbs/data of cockatrice:// secrets. Failure box
offers Save (shows path) / Discard (revokes + rejects) / Cancel (back
to dialog); reject() override so X/Escape cannot bypass revocation;
silent AlwaysSend path falls back to a local file. Empty DSN keeps the
previous mock behavior byte-identical.

Tests: 528/528 ctest green, format clean, code + UI gates PASS.

* [CrashReporting] Attach crash log and user note to the pending crash event

The redacted log used to ride a separate message event from the native
crash (minidump + stack), forcing manual correlation in Sentry. Fold it
into the pending crash envelope(s) instead by appending well-formed
attachment items before consent releases them: sentry-native replays
cached envelopes byte-for-byte (raw read, raw serialize in 0.17.3), so
the appended crash-log.txt and user-note.txt items upload with the very
event that already carries the dump and stack.

Covers both storage locations: Ask mode's consent-held files in
<database>/cache and AlwaysSend's transport-dumped files in
<database>/<run>.run, which must be attached before sentry_init replays
them. When no pending crash event exists (unclean exit without a native
crash) the standalone message event remains as fallback, now also
carrying the optional note.

The dialog gains an optional free-text field ('Here's what I was
doing...') that travels as user-note.txt; the mock JSON records it too.

* [CrashReporting] Send PROJECT_VERSION as the Sentry release

main() reported VERSION_STRING (PROJECT_VERSION_FRIENDLY) as the Sentry
release — that carries a build date, so every build of the same version
landed in its own release, and it never matched the installer, which
reports CPACK/PROJECT_VERSION. Bake COCKATRICE_SENTRY_RELEASE=
PROJECT_VERSION instead: date-free, identical between client and
installer, and derived from the git tag on tag builds, so events,
symbol uploads and the installer log all group under one release.

* [CrashReporting] Friendlier crash dialog copy

Reword the report dialog along a single friendly arc: headline ('Oops,
we crashed — and now we need your help to make Cockatrice better!'),
why helping is easy, exactly what gets collected (crash dump with stack
trace, app version, OS info, redacted log excerpt), the value of each
report ('better and less crashy for everyone'), and the consent choice
('Send and remember makes this automatic without interruption in the
future'). The checkbox itself stays direction-neutral ('Remember my
choice') because it also persists a 'Don't send' decision.

* [CrashReporting] Log where crash reports go and warn when they cannot

The client resolved COCKATRICE_SENTRY_DSN (environment over the baked-in
value) without ever saying which one won, and sentry-native refuses to build
a request for a DSN it considers invalid without reporting anything, so a
tester pointing the client at a scratch project had nothing to go on when a
crash never appeared.

Say which DSN source won, and log the destination (host and project id, never
the key) once the SDK is armed. Warn instead of staying quiet when no DSN is
configured at all, when a DSN is malformed - disabling reporting rather than
silently falling back to the project baked into the build, which is the rule
the installer already follows in cmake/CrashReporting.cmake - and when the
build was compiled without Sentry support. A deliberate Never-send stays at
info level: that is a choice, not a misconfiguration.

Also warn when a pending crash envelope was captured for a different
destination than the session that will upload it. sentry-native posts every
envelope with the DSN held in the transport state of the session doing the
sending, and envelopes restored from disk are raw, so the "dsn" header of a
cached envelope is never read: a crash captured for one project can be
uploaded to another with nothing in the log to show it.

Tests cover the DSN shape check (same unanchored pattern the build matches, so
a DSN this build accepted is never rejected here), destination description
without the key, and the mismatch detection, which stays quiet for a different
key on the same project, unreadable files, and headers without a dsn.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-19 14:57:03 +02:00
BruebachL
6385f3fab3
[CrashReporting] Mock crash-report dialog and reporting preference (#7521)
* [CrashReporting] Mock dialog, sentry skeleton and CI prerequisites

Mock: DlgCrashReport preview dialog (no account needed, redacted log
preview), CrashReportingMode Ask/Always/Never setting in DebugSettings,
--simulate-crash flag and Help-menu preview entry. Send writes a local
mock JSON only.

Vendor sentry-native 0.17.3 via FetchContent behind
WITH_CRASH_REPORTING (breakpad everywhere, backend none under
sanitizers) plus a CrashReporter facade with early init and post-loop
shutdown. DSN is a placeholder: empty CMake default with
COCKATRICE_SENTRY_DSN env override, so SDK init is skipped and mock
behavior is unchanged until a real DSN is configured.

CI prerequisites riding here so every layer below builds green:
libcurl dev plus CA bundles in the Linux images and CodeQL env, sentry
Qt integration off (its own find_package cannot see the macOS thin Qt
layout), and the StrFunc Using construction makensis requires.

Tests: ctest green, format clean, code plus UI gates PASS.

* [CrashReporting] Forward mode changes to SDK and fix Linux CI opt-in

Connects PersonalSettings::crashReportingModeChanged to
giveConsent()/revokeConsent() so switching from AlwaysSend to
NeverSend (or Ask) mid-session reaches the SDK instead of leaving
consent given until restart.

Forwards WITH_CRASH_REPORTING into the docker container the way
CCACHE_DIR is, so the Linux package leg actually builds with crash
reporting ON instead of silently building OFF.

* [CrashReporting] Give crash-report logging its own category per review

* [CrashReporting] Drop unused DebugSettings constructor visibility per review

* [CrashReporting] Document reporting modes on the enum per review

* [CrashReporting] Match the dump scan to the writer's temp fallback

Review nit: when the handler's SHGetKnownFolderPath lookup fails it writes to
<temp>/../.., but the startup scan looked in QDir::tempPath(), so a dump
produced through the fallback would never be found and its crash would never
offer to be reported. Walk up the same two levels in the scan.

The path is Windows-only (#ifdef Q_OS_WIN); the statements were compiled and
run standalone against Qt 6 to confirm the API use.

* [CrashReporting] Complete the dump-scan fallback path

The previous commit walked the scan's fallback up two levels but dropped the
CrashDumps/Cockatrice suffix that the writer appends unconditionally, so a dump
written through the fallback was still invisible to the scan.

Evaluate the writer's expression verbatim rather than mirroring it with QDir:
cdUp() is existence-checked and silently leaves the path alone when the
directory is missing, parent_path() is not, and QDir::absoluteFilePath() joins
the shared suffix without risking a doubled separator when the walk lands on a
root. Verified by compiling the block verbatim against Qt 6 with -Wextra
-Werror and comparing it with the writer's join for both the normal and the
fallback case.

Also record that %LOCALAPPDATA% is the scan's stand-in for
FOLDERID_LocalAppData and why the two normally agree.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-19 14:57:03 +02:00
Zach H
ea6af0048c
[Server] Parse the rightmost address from forwarded header lists (#7586)
Some checks are pending
CodeQL / Analyze (cpp) (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
Build Desktop / Configure (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 cover (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 26 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Desktop / Prune caches (push) Waiting to run
Build Docker / Servatrice (arm) (push) Waiting to run
Build Docker / Servatrice (x86) (push) Waiting to run
Build Docker / Publish multi-platform Servatrice image (push) Blocked by required conditions
X-Forwarded-For carries a comma-separated list, and constructing a
QHostAddress from the raw header value returns a null address for it, so a
configured X-Forwarded-For or a multi-hop header never resolved and websocket
clients stayed on the proxy's address.

- Add parseForwardedAddress, returning the rightmost usable entry of a
  forwarded header list
- Use it for the websocket handshake instead of parsing the raw value
- Exempt loopback from per-address auth rate limits so a same-host proxy with a
  customized trusted_sources list cannot lock every user out
- Add forwarded_address_test

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-18 16:53:55 -04:00
Zach H
46cdbac830
[Servatrice] Match trusted_sources by address, not substring (#7582)
trusted_sources was checked with QString::contains. A dual-stack listener
reports a local proxy as ::ffff:127.0.0.1, which never matches the default
"127.0.0.1,::1". Behind nginx the forwarded client IP header was then
ignored, every websocket user shared the proxy's address, and logins
failed with "too many concurrent connections" once max_users_per_address
was reached.

Add isTrustedSource(), which parses each entry as a QHostAddress and
compares exact addresses with IPv4-mapped addresses normalized, and use it
at all four call sites. The ban command also read server/trusted_sources
instead of the documented security/trusted_sources; it now uses the same
getter. Entries that are not IP addresses are logged at startup since they
no longer match anything.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 16:50:04 -04:00
BruebachL
4343544588
[Client] Settings: isolate the selected section when drilling down (#7488)
* [Client] Settings: isolate the selected section when drilling down

Show only the selected section's group box. Restore the overview on
page rows. Filter search results to the control's containing section.
Reset the scroll position on every change.

Reworked to stay in the enlarged modal instead of the settings tab.

* [Client] Settings: re-clicking an isolated section restores the overview

* [Settings] Keep section row selected when drilling down per review

* [Settings] Stop stealing nav selection mid-gesture per review

* [Settings] Sample nav selection pre-press so re-click detection works per review

* Don't flash the section on drill-down

* [Client] Settings: clear the section filter when selecting the page row per review

onTabClicked() returned early for the already-current page before switchPageContent() could call applySectionFilter(index, nullptr), so clicking the page row while a section was isolated moved the selection but left the page filtered. Only skip the restore when no section is isolated.

* [Settings] Select the search result's section in the sidebar per review

Reaching a setting through the search isolated its section and flashed the
widget, but left the navigation selection on the page row, so the sidebar
looked like it had only jumped to the section's page. Extract the section-row
selection shared with drill-down into selectSectionRow() and use it for search
results too, keeping the gesture/re-click behavior untouched.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-18 22:48:52 +02:00
BruebachL
4750e67b7f
[Client] Settings: sub-page rows deep-linking into page sections (#7487)
Add AbstractSettingsPage::getSections built from each page's group boxes.
Show each multi-group page's sections as child rows in the navigation
tree. Section rows show the parent page and scroll to and highlight the
group box. Start pages collapsed. Keep single-group pages flat.

Reworked to stay in the enlarged modal instead of the settings tab.

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-18 22:48:51 +02:00
BruebachL
4974cac59a
[Client] Settings: add top-right cogwheel shortcut (#7485)
* [Client] Settings: add top-right cogwheel shortcut

Add a subtle flat settings button to the tab bar corner that opens the
settings dialog via the existing menu action slot. Icon and tooltip
follow the menu action in retranslateUi.

* [Client] Settings: flat black-cogwheel corner entry per review

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-18 22:48:51 +02:00
BruebachL
860625be4d
[Client] Settings: grouped left navigation for settings pages (#7484)
* [Client] Settings: grouped left navigation for settings pages

Replace the top icon-button bar in SettingsView with a grouped left
QTreeWidget: Application, Cards and Data, Chat and Sound, Advanced. The
active page is the selected row (palette highlight); search mode hides
the tree; group headers expand but never select. Page tr() contexts
stay DlgSettings; new group names are SettingsView strings.

Reworked to stay in the enlarged modal instead of the settings tab.

* [Settings] Make groupedPages static const per review

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-18 22:48:50 +02:00
BruebachL
665f7267e4
[Client] Settings: enlarge the settings dialog (#7483)
* [Client] Settings: enlarge the settings dialog

Keep settings modal per review: size to ~90% of the main window
(5% margins each side) with screen fallback, size grip and
750x700 minimum bounded to the screen. Drops the tab approach
while giving dense pages room without the tab-switch friction.

* [Client] Settings: clamp dialog width to 1280 per review

* [Client] Settings: extract dialog size calculation per review

Move the host/screen target math out of the DlgSettings constructor into a calculateDialogSize helper. Drop the leftover 'per review' wording from the clamp comment. No behavior change.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-18 22:48:50 +02:00
BruebachL
679762c624
[Client] Settings: extract SettingsView from DlgSettings (#7482)
* [Client] Settings: extract SettingsView from DlgSettings

Move the search bar, tab bar, stacked pages and search results out of
DlgSettings into a reusable SettingsView widget. The dialog becomes a
thin frame (title, OK button, close validation) delegating to the view.
No visual or behavior change; tr() contexts preserved as DlgSettings.

* [Client] Settings: match CI clang-format-18 line breaking

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-18 22:48:50 +02:00
tooomm
a12b97ec50
[Translation] Move config into git version control (#7575)
Some checks are pending
CodeQL / Analyze (cpp) (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
Build Desktop / Configure (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 cover (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 26 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Desktop / Prune caches (push) Waiting to run
Build Docker / Servatrice (arm) (push) Waiting to run
Build Docker / Servatrice (x86) (push) Waiting to run
Build Docker / Publish multi-platform Servatrice image (push) Blocked by required conditions
* Create transifex.yml

* Update transifex.yml

* Update and rename transifex.yml to transifex_github_config.yml

* Update and rename transifex_github_config.yml to github_integration_config.yml
2026-10-18 14:29:51 +02:00
RickyRister
b849a57dab
[TabRoom] Fix compile failure due to unused lambda capture (#7577) 2026-10-18 11:10:26 +02:00
BruebachL
4c03e0062b
[CI] Detect 64-bit Windows NSIS builds via pointer size, not generator platform (#7573)
Some checks are pending
CodeQL / Analyze (cpp) (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
Build Desktop / Configure (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 cover (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 26 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Desktop / Prune caches (push) Waiting to run
Build Docker / Servatrice (arm) (push) Waiting to run
Build Docker / Servatrice (x86) (push) Waiting to run
Build Docker / Publish multi-platform Servatrice image (push) Blocked by required conditions
CMAKE_GENERATOR_PLATFORM is Visual-Studio-only and stays empty under the
Ninja generator that Windows CI switched to in #7380, so TRICE_IS_64_BIT
was always 0 and every Windows artifact was mislabeled 32-bit. That fired
the installer's 'installing a 32-bit version on a 64-bit system' warning
and routed a genuinely 64-bit build into Program Files (x86) with the
32-bit registry view.

Use CMAKE_SIZEOF_VOID_P, which reflects the actual target architecture
for every generator (same test FindVCredistRuntime.cmake already uses).

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-17 22:35:27 +02:00
dependabot[bot]
fe8a3f486b
Bump actions/upload-artifact from 4 to 7 (#7574)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 7.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4...v7)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-17 22:08:33 +02:00
transifex-integration[bot]
12587e2da9
Updates for project Cockatrice and language ru (#7561)
Some checks are pending
CodeQL / Analyze (cpp) (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
Build Desktop / Configure (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 cover (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 26 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Desktop / Prune caches (push) Waiting to run
Build Docker / Servatrice (arm) (push) Waiting to run
Build Docker / Servatrice (x86) (push) Waiting to run
Build Docker / Publish multi-platform Servatrice image (push) Blocked by required conditions
* Translate oracle_en@source.ts in ru [Manual Sync]

71% of minimum 5% translated source file: 'oracle_en@source.ts'
on 'ru'.

Sync of partially translated files: 
untranslated content is included with an empty translation 
or source language content depending on file format

* Translate cockatrice_en@source.ts in ru [Manual Sync]

48% of minimum 5% translated source file: 'cockatrice_en@source.ts'
on 'ru'.

Sync of partially translated files: 
untranslated content is included with an empty translation 
or source language content depending on file format

---------

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2026-10-17 16:43:14 +02:00
transifex-integration[bot]
c9650db87e
Updates for project Cockatrice and language yue (#7569)
* Translate oracle_en@source.ts in yue [Manual Sync]

82% of minimum 5% translated source file: 'oracle_en@source.ts'
on 'yue'.

Sync of partially translated files: 
untranslated content is included with an empty translation 
or source language content depending on file format

* Translate cockatrice_en@source.ts in yue [Manual Sync]

43% of minimum 5% translated source file: 'cockatrice_en@source.ts'
on 'yue'.

Sync of partially translated files: 
untranslated content is included with an empty translation 
or source language content depending on file format

* Translate oracle_en@source.ts in yue [Manual Sync]

82% of minimum 5% translated source file: 'oracle_en@source.ts'
on 'yue'.

Sync of partially translated files: 
untranslated content is included with an empty translation 
or source language content depending on file format

---------

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2026-10-17 16:41:32 +02:00
transifex-integration[bot]
890bc0d4a4
Translate cockatrice_en@source.ts in zh-Hant [Manual Sync] (#7572)
35% of minimum 5% translated source file: 'cockatrice_en@source.ts'
on 'zh-Hant'.

Sync of partially translated files: 
untranslated content is included with an empty translation 
or source language content depending on file format

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2026-10-17 16:30:35 +02:00
transifex-integration[bot]
3f93e93f13
Translate cockatrice_en@source.ts in sv [Manual Sync] (#7571)
16% of minimum 5% translated source file: 'cockatrice_en@source.ts'
on 'sv'.

Sync of partially translated files: 
untranslated content is included with an empty translation 
or source language content depending on file format

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2026-10-17 16:07:52 +02:00
transifex-integration[bot]
aee866b242
Updates for project Cockatrice and language sr (#7566)
* Translate oracle_en@source.ts in sr [Manual Sync]

23% of minimum 5% translated source file: 'oracle_en@source.ts'
on 'sr'.

Sync of partially translated files: 
untranslated content is included with an empty translation 
or source language content depending on file format

* Translate cockatrice_en@source.ts in sr [Manual Sync]

7% of minimum 5% translated source file: 'cockatrice_en@source.ts'
on 'sr'.

Sync of partially translated files: 
untranslated content is included with an empty translation 
or source language content depending on file format

---------

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2026-10-17 15:47:26 +02:00
transifex-integration[bot]
cc35258cdb
Updates for project Cockatrice and language pl (#7560)
* Translate oracle_en@source.ts in pl [Manual Sync]

82% of minimum 5% translated source file: 'oracle_en@source.ts'
on 'pl'.

Sync of partially translated files: 
untranslated content is included with an empty translation 
or source language content depending on file format

* Translate cockatrice_en@source.ts in pl [Manual Sync]

41% of minimum 5% translated source file: 'cockatrice_en@source.ts'
on 'pl'.

Sync of partially translated files: 
untranslated content is included with an empty translation 
or source language content depending on file format

---------

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2026-10-17 15:43:45 +02:00
transifex-integration[bot]
03bfa2dcc6
Updates for project Cockatrice and language en@pirate (#7567)
* Translate oracle_en@source.ts in en@pirate [Manual Sync]

7% of minimum 5% translated source file: 'oracle_en@source.ts'
on 'en@pirate'.

Sync of partially translated files: 
untranslated content is included with an empty translation 
or source language content depending on file format

* Translate cockatrice_en@source.ts in en@pirate [Manual Sync]

5% of minimum 5% translated source file: 'cockatrice_en@source.ts'
on 'en@pirate'.

Sync of partially translated files: 
untranslated content is included with an empty translation 
or source language content depending on file format

---------

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2026-10-17 15:43:06 +02:00
transifex-integration[bot]
c049c5c2b7
Translate cockatrice_en@source.ts in cs [Manual Sync] (#7570)
13% of minimum 5% translated source file: 'cockatrice_en@source.ts'
on 'cs'.

Sync of partially translated files: 
untranslated content is included with an empty translation 
or source language content depending on file format

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2026-10-17 15:41:31 +02:00
transifex-integration[bot]
47133bcc6b
Updates for project Cockatrice and language en_US (#7568)
* Translate oracle_en@source.ts in en_US [Manual Sync]

93% of minimum 5% translated source file: 'oracle_en@source.ts'
on 'en_US'.

Sync of partially translated files: 
untranslated content is included with an empty translation 
or source language content depending on file format

* Translate cockatrice_en@source.ts in en_US [Manual Sync]

67% of minimum 5% translated source file: 'cockatrice_en@source.ts'
on 'en_US'.

Sync of partially translated files: 
untranslated content is included with an empty translation 
or source language content depending on file format

---------

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2026-10-17 15:40:23 +02:00
transifex-integration[bot]
5b895dc553
Updates for project Cockatrice and language pt_BR (#7562)
* Translate oracle_en@source.ts in pt_BR [Manual Sync]

92% of minimum 5% translated source file: 'oracle_en@source.ts'
on 'pt_BR'.

Sync of partially translated files: 
untranslated content is included with an empty translation 
or source language content depending on file format

* Translate cockatrice_en@source.ts in pt_BR [Manual Sync]

52% of minimum 5% translated source file: 'cockatrice_en@source.ts'
on 'pt_BR'.

Sync of partially translated files: 
untranslated content is included with an empty translation 
or source language content depending on file format

---------

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2026-10-17 15:38:59 +02:00
transifex-integration[bot]
a1aa3d9aa9
Updates for project Cockatrice and language pt (#7563)
* Translate oracle_en@source.ts in pt [Manual Sync]

50% of minimum 5% translated source file: 'oracle_en@source.ts'
on 'pt'.

Sync of partially translated files: 
untranslated content is included with an empty translation 
or source language content depending on file format

* Translate cockatrice_en@source.ts in pt [Manual Sync]

21% of minimum 5% translated source file: 'cockatrice_en@source.ts'
on 'pt'.

Sync of partially translated files: 
untranslated content is included with an empty translation 
or source language content depending on file format

---------

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2026-10-17 15:38:09 +02:00
transifex-integration[bot]
f565910790
Updates for project Cockatrice and language el (#7552)
* Translate oracle_en@source.ts in el [Manual Sync]

52% of minimum 5% translated source file: 'oracle_en@source.ts'
on 'el'.

Sync of partially translated files: 
untranslated content is included with an empty translation 
or source language content depending on file format

* Translate cockatrice_en@source.ts in el [Manual Sync]

10% of minimum 5% translated source file: 'cockatrice_en@source.ts'
on 'el'.

Sync of partially translated files: 
untranslated content is included with an empty translation 
or source language content depending on file format

---------

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2026-10-17 15:37:23 +02:00
transifex-integration[bot]
74ae39f0d4
Updates for project Cockatrice and language et (#7550)
* Translate oracle_en@source.ts in et [Manual Sync]

80% of minimum 5% translated source file: 'oracle_en@source.ts'
on 'et'.

Sync of partially translated files: 
untranslated content is included with an empty translation 
or source language content depending on file format

* Translate cockatrice_en@source.ts in et [Manual Sync]

6% of minimum 5% translated source file: 'cockatrice_en@source.ts'
on 'et'.

Sync of partially translated files: 
untranslated content is included with an empty translation 
or source language content depending on file format

---------

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2026-10-17 15:35:35 +02:00
transifex-integration[bot]
33df0916a8
Updates for project Cockatrice and language de (#7555)
* Translate oracle_en@source.ts in de [Manual Sync]

93% of minimum 5% translated source file: 'oracle_en@source.ts'
on 'de'.

Sync of partially translated files: 
untranslated content is included with an empty translation 
or source language content depending on file format

* Translate cockatrice_en@source.ts in de [Manual Sync]

67% of minimum 5% translated source file: 'cockatrice_en@source.ts'
on 'de'.

Sync of partially translated files: 
untranslated content is included with an empty translation 
or source language content depending on file format

---------

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2026-10-17 13:51:01 +02:00
transifex-integration[bot]
44155064b4
Updates for project Cockatrice and language es (#7551)
* Translate oracle_en@source.ts in es [Manual Sync]

93% of minimum 5% translated source file: 'oracle_en@source.ts'
on 'es'.

Sync of partially translated files: 
untranslated content is included with an empty translation 
or source language content depending on file format

* Translate cockatrice_en@source.ts in es [Manual Sync]

62% of minimum 5% translated source file: 'cockatrice_en@source.ts'
on 'es'.

Sync of partially translated files: 
untranslated content is included with an empty translation 
or source language content depending on file format

---------

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2026-10-17 13:49:07 +02:00
transifex-integration[bot]
1756501ded
Updates for project Cockatrice and language it (#7554)
* Translate oracle_en@source.ts in it [Manual Sync]

93% of minimum 5% translated source file: 'oracle_en@source.ts'
on 'it'.

Sync of partially translated files: 
untranslated content is included with an empty translation 
or source language content depending on file format

* Translate cockatrice_en@source.ts in it [Manual Sync]

67% of minimum 5% translated source file: 'cockatrice_en@source.ts'
on 'it'.

Sync of partially translated files: 
untranslated content is included with an empty translation 
or source language content depending on file format

---------

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2026-10-17 13:26:12 +02:00
transifex-integration[bot]
a19e0e459a
Updates for project Cockatrice and language ja (#7556)
* Translate oracle_en@source.ts in ja [Manual Sync]

81% of minimum 5% translated source file: 'oracle_en@source.ts'
on 'ja'.

Sync of partially translated files: 
untranslated content is included with an empty translation 
or source language content depending on file format

* Translate cockatrice_en@source.ts in ja [Manual Sync]

41% of minimum 5% translated source file: 'cockatrice_en@source.ts'
on 'ja'.

Sync of partially translated files: 
untranslated content is included with an empty translation 
or source language content depending on file format

---------

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2026-10-17 13:22:56 +02:00
transifex-integration[bot]
c5b24e81da
Updates for project Cockatrice and language fi (#7549)
* Translate oracle_en@source.ts in fi [Manual Sync]

82% of minimum 5% translated source file: 'oracle_en@source.ts'
on 'fi'.

Sync of partially translated files: 
untranslated content is included with an empty translation 
or source language content depending on file format

* Translate cockatrice_en@source.ts in fi [Manual Sync]

33% of minimum 5% translated source file: 'cockatrice_en@source.ts'
on 'fi'.

Sync of partially translated files: 
untranslated content is included with an empty translation 
or source language content depending on file format

---------

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2026-10-17 13:21:03 +02:00
transifex-integration[bot]
2d4aa4414a
Updates for project Cockatrice and language zh-Hans (#7565)
* Translate oracle_en@source.ts in zh-Hans [Manual Sync]

92% of minimum 5% translated source file: 'oracle_en@source.ts'
on 'zh-Hans'.

Sync of partially translated files: 
untranslated content is included with an empty translation 
or source language content depending on file format

* Translate cockatrice_en@source.ts in zh-Hans [Manual Sync]

42% of minimum 5% translated source file: 'cockatrice_en@source.ts'
on 'zh-Hans'.

Sync of partially translated files: 
untranslated content is included with an empty translation 
or source language content depending on file format

---------

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2026-10-17 13:18:18 +02:00
transifex-integration[bot]
72f0a8975f
Translate oracle_en@source.ts in tr [Manual Sync] (#7564)
28% of minimum 5% translated source file: 'oracle_en@source.ts'
on 'tr'.

Sync of partially translated files: 
untranslated content is included with an empty translation 
or source language content depending on file format

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2026-10-17 13:16:21 +02:00
transifex-integration[bot]
b231f55df4
Updates for project Cockatrice and language nl (#7559)
* Translate oracle_en@source.ts in nl [Manual Sync]

85% of minimum 5% translated source file: 'oracle_en@source.ts'
on 'nl'.

Sync of partially translated files: 
untranslated content is included with an empty translation 
or source language content depending on file format

* Translate cockatrice_en@source.ts in nl [Manual Sync]

61% of minimum 5% translated source file: 'cockatrice_en@source.ts'
on 'nl'.

Sync of partially translated files: 
untranslated content is included with an empty translation 
or source language content depending on file format

---------

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2026-10-17 13:15:29 +02:00
transifex-integration[bot]
07a699851d
Updates for project Cockatrice and language ko (#7557)
* Translate oracle_en@source.ts in ko [Manual Sync]

50% of minimum 5% translated source file: 'oracle_en@source.ts'
on 'ko'.

Sync of partially translated files: 
untranslated content is included with an empty translation 
or source language content depending on file format

* Translate cockatrice_en@source.ts in ko [Manual Sync]

20% of minimum 5% translated source file: 'cockatrice_en@source.ts'
on 'ko'.

Sync of partially translated files: 
untranslated content is included with an empty translation 
or source language content depending on file format

---------

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2026-10-17 13:14:00 +02:00
transifex-integration[bot]
a331da10e8
Updates for project Cockatrice and language fr (#7553)
* Translate oracle_en@source.ts in fr [Manual Sync]

93% of minimum 5% translated source file: 'oracle_en@source.ts'
on 'fr'.

Sync of partially translated files: 
untranslated content is included with an empty translation 
or source language content depending on file format

* Translate cockatrice_en@source.ts in fr [Manual Sync]

68% of minimum 5% translated source file: 'cockatrice_en@source.ts'
on 'fr'.

Sync of partially translated files: 
untranslated content is included with an empty translation 
or source language content depending on file format

---------

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2026-10-17 12:40:45 +02:00
transifex-integration[bot]
8e391f6fa0
Translate oracle_en@source.ts in nb [Manual Sync] (#7558)
50% of minimum 5% translated source file: 'oracle_en@source.ts'
on 'nb'.

Sync of partially translated files: 
untranslated content is included with an empty translation 
or source language content depending on file format

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2026-10-17 12:25:50 +02:00
transifex-integration[bot]
09846d26f3
Translate oracle_en@source.ts in ca [Manual Sync] (#7548)
85% of minimum 5% translated source file: 'oracle_en@source.ts'
on 'ca'.

Sync of partially translated files: 
untranslated content is included with an empty translation 
or source language content depending on file format

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2026-10-17 12:07:49 +02:00
BruebachL
a139084ad6
[Security] Add challenge-response auth with scrypt verifiers and stop storing plaintext passwords (#7193)
Some checks are pending
CodeQL / Analyze (cpp) (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
Build Desktop / Configure (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 cover (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 26 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Desktop / Prune caches (push) Waiting to run
Build Docker / Servatrice (arm) (push) Waiting to run
Build Docker / Servatrice (x86) (push) Waiting to run
Build Docker / Publish multi-platform Servatrice image (push) Blocked by required conditions
* [Security] Add challenge-response auth with scrypt verifiers and stop storing plaintext passwords

Challenge-response authentication: the client derives a scrypt verifier
(RFC 7914, EVP_PBE_scrypt, N=32768, r=8, p=1) and authenticates with
HMAC-SHA256(key, nonce), so neither the password nor its hash is
transmitted. The stored format becomes
"$scrypt$<n>$<r>$<p>$<salt>$<verifier>" and Response_PasswordSalt
now carries the cost parameters. Strict servers only accept scrypt
verifiers; legacy accounts are migrated after a successful login.

Fix #344 for challenge-response servers: a saved profile stores the
derived verifier under the password key instead of the plaintext password.
The connect dialog loads it without revealing it, autoconnect passes it
through, the change-password dialog no longer prefills the old password
field with it, and the client only persists the verifier when
"Save password" is checked.

Took 3 minutes

Took 1 minute

Took 10 seconds

Took 7 minutes

* [Security] Harden credential format acceptance and challenge nonce validation

* [Security] Refuse KDF downgrade; trim profile name and warn on silent credential drop

* [Security] Add SupportsAll ServerOptions enumerator to fix enum validation assert

The server advertised both SupportsPasswordHash (1) and
SupportsChallengeResponseAuth (2) via a bitwise-OR that produced the
value 3, which no enumerator declared. Protobuf's proto2 enum
validation rejects the value in _internal_set_server_options, crashing
servatrice on the debug assertion. Add a SupportsAll = 3 enumerator with
an identical wire value so the bitmask reads unchanged while the value
is now a documented, valid member of the enum.

* [Security] Normalize username when issuing challenge nonce

cmdRequestPasswordSalt bound the challenge nonce to the raw, un-simplified
username while cmdLogin verifies it against the simplified name. A user
who typed leading or trailing whitespace in the username box would be
silently rejected during challenge-response login even with the correct
password. Simplify at the nonce-issuing site so both commands agree on
the normalized name.

Also drive-by formatting: collapse the now single-expression
serverOptions initializer onto one line.

* [Security] Keep an encrypted legacy password backup for old-client logins

Migrating an account to scrypt stranded pre-challenge clients, which can
only present the legacy salt+hash: after migration the server served them
a scrypt salt they cannot use, so the only recovery was a password reset.

When security/legacy_backup_key is configured (64 hex chars,
openssl rand -hex 32), submitPasswordVerifier now seals the legacy
credential with AES-256-GCM under that key before overwriting the row,
storing it in password_legacy_backup with the time it was last used. Old
clients keep working: cmdRequestPasswordSalt serves the backup's 16-char
salt in password_salt so they can derive the legacy hash they were
always able to compute, while new clients derive the verifier from the
new scrypt_salt field, which always carries the verifier salt. The
non-challenge login path accepts the plaintext or legacy hash against
the decrypted backup and refreshes its window; a migrated row with no
usable backup fails closed.

Backups age out so a database dump taken after the window closes never
carries a crackable legacy hash: getUsableLegacyBackup purges on first
use past security/legacy_backup_ttl_days (default 90, clamped 1..3650),
and a six-hourly sweep drops backups idle accounts never touch.
changeUserPassword clears the backup since a new credential invalidates
it. Without a configured key nothing is sealed or purged, so a
temporarily missing key cannot destroy existing backups.

The AAD is the simplified user name on both sides, sealing the blob to
its row so it cannot be moved between accounts. Sealing failures
degrade to the previous lockout behaviour rather than blocking
migration. The columns extend the unmerged 0036->0037 migration, so the
schema version stays 37.

* [Security] Address review: proto compat, plaintext verify, AAD case, strict scope

Fix the two blockers and follow-ups from the 2026-10-16 review:

- Advertise challenge-response in a new supports_challenge_response
  field instead of the ServerOptions enum and drop SupportsAll:
  proto2 enums are closed, so already-released clients parsed
  server_options = 3 as NoOptions and fell back to plaintext
  passwords. server_options stays exactly SupportsPasswordHash
  on the wire; old clients ignore the new field.
- Verify a plaintext password directly against a migrated scrypt
  row with verifyPassword() so email changes (cmdAccountEdit)
  and old clients sending password keep working without a
  legacy backup key. Pre-hashed values are still checked
  against the backup, and its TTL window is refreshed only
  when the backup is what actually matched.
- Use the stored row name as the AES-GCM AAD on both seal
  and open so a login whose case differs from the stored
  name (utf8mb4_unicode_ci) still opens its backup.
- Require challenge-response in strict mode only for
  registered SQL accounts (userExists gate), leaving
  guest/unregistered logins and none/password auth
  methods unaffected.
- Document in servatrice.ini.example that a captured
  legacy hash can take over a not-yet-migrated account
  and that strict only constrains registered SQL
  accounts. Schema version is 38.

* [Security] Raise legacy backup TTL default to 180 days

Gives operators and slow-to-upgrade users a longer downgrade
window for pre-challenge clients before unused encrypted
legacy password backups age out.

* [Security] Fix servatrice.cpp formatting per clang-format-18

* [Security] Add missing forward declaration for Command_SubmitPasswordVerifier

The cmdSubmitPasswordVerifier declaration references the proto-generated
type, which otherwise does not name a type in this header and breaks
every C++ build.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
Co-authored-by: BruebachL <bruebachl@cockatrice.example>
2026-10-16 22:52:26 +02:00
BruebachL
155f7b0143
[Servatrice] Relay room channel messages over ISL (#7480)
* [Servatrice] Relay room channel messages over ISL

* [Servatrice] Skip audit log for unknown ISL channel per review

- externalRoomSay drops messages for channels not in the local
  catalog, matching Server_Room::say, instead of logging what was
  never delivered

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-16 22:46:40 +02:00
BruebachL
ce51e7147d
[Servatrice] Log room channel messages in audit log (#7479)
* [Servatrice] Log room channel messages and bump schema to 38

* Fixup: show room_channel audit rows and keep migration nullable

The log history query returns room_channel rows, but the log tab
dropped them. Render them in the room table with an extra Channel
column. Also drop the NOT NULL on target_type from the 37->38
migration so migrated schemas match the nullable servatrice.sql
definition (and strict-mode ALTERs cannot fail on legacy NULL rows).
Update the channel tests to the flag-based privilege model: the admin
fixture now carries IsModerator alongside IsAdmin, as the database
layer assigns.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-16 22:46:39 +02:00
BruebachL
c99113ab0e
[Client] Tabbed room channels with unread states and collapsible chat (#7478)
* [Client] Add room channel selector with per-channel chat views

* [Client] Tabbed room channels with unread states, collapse, read-only, banners

Reworks the room chat channel display: channels move out of the combobox
into a tab bar below the message input showing every channel available
to the user.

- Each tab shows its unread message count and is tinted when it holds
  unreads or a mention; both colors are user-configurable chat settings
  (channelUnreadColor, channelMentionColor) with a settings-page section.
- Clicking the active tab collapses the chat: message view and input
  hide and the splitter section minimizes to the tab strip; expanding
  restores the previous splitter sizes. Viewing a channel clears its
  unread state.
- Channels the user may read but not write (per write_access_level)
  show with the message input disabled and a read-only placeholder;
  sends are guarded locally, the server still enforces.
- Channel banner_message strings render at the top of their channel
  view on join, like the room join message on Main.
- Replayed channel history no longer inflates unread counts; moderation
  redaction now applies across all channel views.

* Fixup: keep chat tab bar visible when chat is collapsed

QSplitter children are collapsible by default, so setSizes({1, 0})
hid the whole chat group box including the channel tab bar.
Mark the chat widget non-collapsible so collapsing clamps to its
minimum size (title + tab bar) instead.

* [Room] Explicitly initialize MAIN_CHANNEL per review

* [Room] Address channel tab review follow-ups

- gate @all mentions on moderator/admin sender to match ChatView,
  whole-word matching for own name and highlight words
- hide single-channel tab bar so old servers do not show a lone Main
  tab that collapses chat on misclick
- capture target ChatView in sendMessage so errors land in the
  channel the message was sent from
- share ignore/unregistered filtering between RoomSay and
  RoomChannelSay via isRoomMessageIgnored

* [Client] Fix tab_room.cpp formatting per clang-format-18

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
Co-authored-by: BruebachL <bruebachl@cockatrice.example>
2026-10-16 22:46:39 +02:00
BruebachL
05dcde69db
[Server] Gate room channels on privilege levels (#7477)
* [Server] Gate room channels on privilege levels

* [Server] Filter channel catalog per user, split read/write gating, add banners

- Server_Room::getInfo() now takes the requesting user and only lists
  channels that user may read. Previously every room member received the
  full catalog (e.g. the moderators channel) while message delivery was
  already gated, so restricted channels were visible but silent.
- Split channel access into mayReadChannel/mayWriteChannel: history
  replay, catalog listing, and event fan-out use the read level
  (access_level); Command_RoomChannelSay uses the write level
  (write_access_level, falling back to the read level when unset).
- Plumb write_access_level and banner_message through both room sources
  (SQL rooms_channels table and config-file channels via writeaccess
  and banner keys).
- Schema 38 -> 39: add write_access_level (NULL means inherit the read
  level) and banner_message columns; also widen access_level to the
  full six-value privilege enum to match the 37 -> 38 migration.

* Fixup: evaluate channel access per user flag instead of ladder

User flags are independent bits: IsDeveloper does not imply
IsModerator/IsAdmin, so developer-only users must not reach
moderator/admin channels. Replace the userPrivilegeLevel ladder with
hasChannelPrivilege: moderator channels need IsModerator (admins hold
it alongside IsAdmin), admin channels need IsAdmin, developer channels
need IsDeveloper or IsAdmin, judge channels admit IsJudge/IsModerator.
Covers catalog filtering, history replay, write gating and fan-out,
and corrects the ini example and proto docs.

* [Server] Make channel privilege helpers static per review

* [Server] Fail closed on unknown channel access levels per review

- roomChannelAccessFromKey returns nullopt for unknown keywords
  instead of silently falling back to Public
- skip channels with unknown read/write levels with qWarning, empty
  read defaults to Public, empty write means no override
- mayWriteChannel now also requires read access so write-below-read
  configs cannot post into invisible channels

* [Server] Remove stray conflict marker in channel replay comment

* [Server] Fix servatrice.cpp formatting per clang-format-18

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
Co-authored-by: BruebachL <bruebachl@cockatrice.example>
2026-10-16 22:46:38 +02:00
BruebachL
17d2e8d86e
[Server] Add room chat channels with per-channel fan-out and history (#7476)
* [Server] Add room chat channels with per-channel fan-out and history

* Fixup: redact all channel histories on message removal

removeSaidMessages only redacted the main history, so banned/warned
content stayed visible to late joiners on every other channel.
Drop the unused channelId parameter and redact up to amount messages
per channel across the whole channelChatHistory map.

* [Server] Validate room channel ids and fix catalog schema per review

- servatrice.sql: expand rooms_channels access_level enum to full set
  to match 0037->0038 migration
- servatrice.cpp: skip empty, reserved (__main__) and duplicate channel
  ids when loading from ini and DB with qWarning
- server_protocolhandler.cpp: clarify catalog replay comment (filtering
  happens in moderator stack)
- server_room: drop duplicate channelIndex, linear search preserves
  catalog order

* [Server] Fix server_room.cpp formatting per clang-format-18

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
Co-authored-by: BruebachL <bruebachl@cockatrice.example>
2026-10-16 22:46:38 +02:00
BruebachL
be38e8d429
[Protocol] Add room chat channel extensions and catalog payload (#7475)
* [Protocol] Add room chat channel extensions and catalog payload

* [Protocol] Add per-channel write level and banner to room channel catalog

Adds two optional fields to ServerInfo_RoomChannel:

- write_access_level: minimum privilege level required to send to the
  channel. When unset, writing requires the same level as reading
  (access_level), preserving the previous single-level behavior. This
  allows read-mostly channels such as a developer news feed: readable
  by everyone, writable only by developers.
- banner_message: static notice shown at the top of the channel,
  analogous to the room join message on Main. Empty means none.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-16 22:46:37 +02:00
BruebachL
6c660d6b21
[Installer][Client] Back up user data before old uninstallers can delete it (#7503)
* [Installer][Client] Back up user data before old uninstallers can delete it

* [Installer][Client] Fix stale backup resurrection, batch session backups, extend coverage

NSIS: clear stale %TEMP% backup and only arm restore when something was copied, directory-existence checks, back up loose .dec decks, drop downloaded pics but keep pics\CUSTOM.

Helper: single session dir per run (ms timestamps), same-basename disambiguation, partial copies still report their location.

* [Installer][Client] Fix review findings: throttle backups, NSIS old dir, portable messaging

* [Installer][Client] Fix review findings: backup markers, NSIS relocate, size cap

* [Installer][Client] Fix review findings: sounds, session coverage, NSIS wording

Client: back up data/sounds in portable mode and mention it in the
warning; reuse a recent session only when complete and covering the
current sources (source manifest), estimate size only for fresh copies,
normalize dedup via isInsideDir.

Helper: source manifest per session with sessionCoversSources(), clear
out-params on all failure paths.

NSIS: document defensive portable branch, merge backup over surviving
data/ to repair partial deletes, copy (not move) wording in relocate,
note .txt exclusion in deck notice.

* [Installer][Client] Fix review findings: stale backup, empty reuse, relocate, portable warning

NSIS: wipe TEMP backup once per run to avoid resurrecting deleted files,
check data dir existence in relocate so dir-only trees are moved.

Client: never reuse a recent session for an empty source set, only fall
back to a covering session on copy failure, warn fresh portable installs
instead of staying silent.

* [Installer][Client] Fix review findings: usable session reuse, loose decks, NSIS pics, symlinks

* [Installer][Client] Fix portable backup exclusions in offender merge

* [Client] Split at-risk data warning into focused helpers

Ricky's nit on the 200+ line warnAboutDataPathsInProgramDir: extract
collection (portable/installed), backup, message building and dialog
into named helpers. No behavior change, all tr() strings untouched.

* [Client] Read path settings inside isPortableBackupExcluded

* [Client] Make stateless at-risk helpers static

* [Client] Extract at-risk data warning out of MainWindow

* [Installer][Client] Fix review findings: subdir backup, portable warning, backup API

NSIS:
- Back up unknown top-level install subdirs (e.g. <installdir>\decks),
  not just loose deck files, so the 3.1.1 -> new upgrade no longer loses
  data folders the old RMDir /r deletes before the client ever runs.
  Payload dirs (plugins/translations/sounds/themes) stay in place to avoid
  restoring stale DLLs; restore + relocate handle the new backup set and
  the one-time notice covers both decks and folders.
- Restart the %TEMP% backup when the 32/64-bit passes point at different
  dirs instead of merging (which restored one dir's files into the other);
  document that only the last pass relocates and the %TEMP% copy is not
  a durable second copy.

Client:
- Drop the portable at-risk warning: portable installs never shipped an
  uninstaller and portable updates preserve data/, so every portable user
  got a spurious dialog plus daily host-machine copies.
- AtRiskDataWarning is now a plain class with Q_DECLARE_TR_FUNCTIONS
  instead of QObject+Q_OBJECT; logging category renamed to
  at_risk_data_warning; MainWindow wrapper inlined into startupConfigCheck.
- Lower the synchronous startup backup cap 500 -> 200 MB.

Utility/tests:
- Remove test-only backupAtRiskPath()/findRecentBackupSession() and the
  no-marker/no-manifest legacy paths; isBackupSessionComplete() now
  requires the complete marker; tests updated to the plural API.

* [Client] Restore collectInstalledAtRiskPaths per review

a23e02b5 removed the definition along with the portable helpers,
breaking the build (undefined reference at link time, and
isInsideAnyDir unused under -Werror on Arch/Debian 13). Restores
the 202c0d65 version (pathsInsideDir plus loose top-level deck
files) as suggested.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-16 22:05:42 +02:00
BruebachL
711386574d
[Client] Tell the user when their playmat art is blocked (#7533)
* [Client] Tell the user when their playmat art is blocked

A server DENY rule silently drops banned playmat art: SetPlaymat fails
with no handler and DeckSelect strips the mat while answering RespOk.

Surface both cases with the same critical message box the banner card
art flow uses. DeckSelect strips are detected by comparing the
requested mat, passed via PendingCommand extraData, against the
server-echoed deck. Notices dedupe per card and reset on success.

* [Client] Gate playmat blocked notice on RespFunctionNotAllowed per review

Only SetPlaymat with RespFunctionNotAllowed signals a card art DENY
rule. Older servers answer RespInvalidCommand and strip the mat from
DeckSelect, so drop the DeckSelect comparison to avoid false
blacklisted popups on pre-playmat servers.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
Co-authored-by: BruebachL <bruebachl@users.noreply.github.com>
2026-10-16 19:50:57 +02:00
BruebachL
b11b096828
[Server] Apply card art rules to playmats (#7532)
* [Server] Apply card art rules to playmats

Card art DENY rules were only enforced for user card art
(SetCardArtParams) while playmats using the same art bypassed them.

Move the rule lookup to Servatrice_DatabaseInterface::isCardArtAllowed
with a default-allow base on Server_DatabaseInterface, delegate the
session check to it, reject DENY-listed Command_SetPlaymat with
RespFunctionNotAllowed, and strip DENY-listed deck playmats on
DeckSelect so banned art is never broadcast.

* [Server] Address review nits on playmat card art rules

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-16 19:50:43 +02:00
BruebachL
bbe438b09a
[Completer] Highlight popup rows without rewriting the line edit (#7502)
* [Completer] Highlight popup rows without rewriting the line edit

Selecting a popup row with ClearAndSelect emits selectionChanged, which
QCompleter turns into highlighted() and QLineEdit turns into setText().
Since be2c16eca5 (#7466) that rewrites the typed text on every completion
reset / arrow-key press (e.g. typing "A" becomes "Axe" and cannot be
edited), regressing the 514014dc00 (#7447) fix that used NoUpdate.

Add selectWithoutRewrite(), which blocks the selection-model signals while
applying ClearAndSelect so the highlight stays visible without touching
the line edit, then syncs the preview manually. Route both the
above-popup auto-select and Up/Down navigation through it, and handle
Up/Down for below-popups too instead of falling through to Qt's
rewriting default. Enter/click acceptance via activated() is unchanged.

* [Completer] Repaint popup highlight after silent selection change

Blocking the selection model's signals also suppresses the view's own
selectionChanged/currentChanged repaints, so force a viewport update
after restoring signals. Use EnsureVisible to match Qt's normal scroll
behaviour.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-16 19:01:38 +02:00
BruebachL
456d9d9e70
[Server] Inform client of login/registration rate limit timeout (#7468)
Some checks are pending
CodeQL / Analyze (cpp) (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
Build Desktop / Configure (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 cover (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 26 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Desktop / Prune caches (push) Waiting to run
Build Docker / Servatrice (arm) (push) Waiting to run
Build Docker / Servatrice (x86) (push) Waiting to run
Build Docker / Publish multi-platform Servatrice image (push) Blocked by required conditions
* [Security] Add per-address rate limiting for auth endpoints

Introduce a thread-safe RateLimiter that tracks attempts per key
(IP address) within a sliding time window, and wire it into the
authentication endpoints:

- Login: failed login attempts from an address are counted; once the
  configured maximum is exceeded within the window, further logins
  from that address are rejected with RespTooManyRequests. A
  successful login clears the failed attempts for that address.
- Registration: implement the previously stubbed
  tooManyRegistrationAttempts, limiting how many accounts can be
  created per address per window.
- Forgot-password: throttle both the email-request and the
  email-challenge paths per address.

New [security] settings with defaults:
  max_login_attempts_per_ip=5 / login_attempt_window_seconds=900
  max_registrations_per_ip=2 / registration_window_seconds=3600
  max_forgot_password_requests_per_ip=3 / forgot_password_window_seconds=3600

Adds unit tests for the RateLimiter (window limit, over-limit
blocking, clearing, per-key independence).

Took 3 minutes

* [Server] Fix rate limiter: disable semantics, draining window, bounded keys, pre-work login gate

- recordAttempt()/isBlocked() return false when maxAttempts <= 0 or windowSeconds <= 0, so the documented 'set to 0 to disable' actually disables instead of blocking every address permanently
- Keys at their limit are rejected without recording, so repeated attempts at the attacker's pace cannot keep sliding the window and holding the lockout open; a block now clears once the recorded attempts age out of windowSeconds
- Attempts map is bounded: an opportunistic prune (every 60s) erases keys whose attempts have fully aged out
- Login is throttled before the work: isLoginRateLimited() (a new virtual on Server, backed by isBlocked()) gates loginUser(), so a locked-out address no longer burns a database round trip and password verification per attempt
- recordAttemptAt()/isBlockedAt() time seams make the sliding-window behaviour deterministically testable; tests rewritten for the real semantics (no reliance on the maxAttempts=0 bug, no sleeps)

* [Server] Inform client of login/registration rate limit timeout

- Add retryAfterSeconds/retryAfterSecondsAt to RateLimiter to compute
  seconds until oldest attempt exits the sliding window
- Expose loginRateLimitRemainingSeconds via Server/Servatrice
- Include denied_end_time (absolute epoch) in RespTooManyRequests for
  login pre-auth and when transitioning to lockout after failed attempt
- Include denied_end_time in registration rate limit response
- Client: show retry time for login RespTooManyRequests
- Client: simplify registration RespTooManyRequests message to show
  retry time when provided
- Add test for retryAfterSecondsAt

* [Client] Restore registration guidance and drop duplicated rate limit reason

The registration RespTooManyRequests case now only shows the retry-after
copy when the server actually sent a denied_end_time; the per-email account
cap path (no end time) falls back to the previous guidance about already
having an account and contacting the server operator.

Drop the canned denied_reason_str from the new login rate limit branches so
the client no longer prints the same sentence twice.

* [Server] Exempt trusted_sources from auth rate limits

Addresses in security/trusted_sources bypass per-address limits, matching
the existing max_users_per_address bypass in initSession. Without this,
Webatrice behind local nginx (all clients as 127.0.0.1) would lock out
every web user after 5 mistyped passwords, and cap the whole server at
2 registrations / 3 forgot-password requests per window.

Adds Servatrice::isRateLimitExempt helper; recordFailedLogin,
isLoginRateLimited, recordRegistrationAttempt and
recordForgotPasswordAttempt all check it.

# Conflicts:
#	servatrice/src/servatrice.cpp

* [Server] Key login rate limit by (address, username)

A successful login cleared the whole per-IP failure history, so 4 bad
guesses plus 1 self-login (or guest UnknownUser login) from the same
address bypassed the limiter. Failures are now bucketed per
(address, lowercase username) and a success clears only its own pair.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-16 05:47:43 +02:00
BruebachL
213ce691dd
[Completer] Minimum size popup, fall back to compact design if not possible. (#7111)
Some checks are pending
CodeQL / Analyze (cpp) (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
Build Desktop / Configure (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 cover (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 26 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Desktop / Prune caches (push) Waiting to run
Build Docker / Servatrice (arm) (push) Waiting to run
Build Docker / Servatrice (x86) (push) Waiting to run
Build Docker / Publish multi-platform Servatrice image (push) Blocked by required conditions
* [Completer] Minimum size popup, fall back to compact design if not possible.

Took 15 minutes

Took 6 seconds


Took 5 minutes

* [Completer] Fix popup going offscreen by clamping in screen coordinates

The position clamping in applyPopupWidth() was comparing popup->pos()
(parent coordinates) against screen->availableGeometry() (screen
coordinates).  When the parent widget is not at the screen origin the
clamping computed a position in the wrong coordinate space, placing the
popup offscreen.  Convert to global coordinates first, clamp, then map
back to parent coordinates.

* [Completer] Clamp popup from geometry() in screen coordinates

setMinimumWidth() already widens the popup synchronously, so the old
width() < minWidth guard never ran and the popup grew past the right
screen edge. The popup is also a top-level Qt::Popup window, so move()
takes global coordinates and the mapFromGlobal round-trip moved it to
the top-left of the screen. Clamp x from geometry() after
setMinimumWidth() and move(QPoint(clampedX, popup->y())) directly.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-15 18:10:07 +02:00
BruebachL
96b0888a73
[Oracle] Don't report a cancelled card database update as successful (#7082)
Some checks are pending
CodeQL / Analyze (cpp) (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
Build Desktop / Configure (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 cover (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 26 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Desktop / Prune caches (push) Waiting to run
Build Docker / Servatrice (arm) (push) Waiting to run
Build Docker / Servatrice (x86) (push) Waiting to run
Build Docker / Publish multi-platform Servatrice image (push) Blocked by required conditions
* [Oracle] Don't report a cancelled card database update as successful (#3470)

Took 3 minutes

* [Oracle] Only report cancel when nothing was saved yet

Rejecting the wizard after SaveSetsPage or a download page already
wrote the database to disk still replaced cards.xml on disk, but
wasCancelled() forced exit code 1 so Cockatrice skipped the reload.
Track successful saves with OracleWizard::markDatabaseSaved() and only
treat a cancel as a failure when nothing was saved.

* [Oracle] Restore updater stderr in card update error dialog

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-15 15:21:07 +02:00
BruebachL
bbf0425655
[Style] Confirm broken Windows styles with a dialog offering Fusion (#7507)
* [Style] Confirm broken Windows styles with a dialog offering Fusion

* [Style] Address review nits on broken style dialog

Extract Fusion index lookup into findFusionIndex helper and add all
message box buttons before choosing the default button.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-15 12:41:01 +02:00
BruebachL
fec693ca2a
[Tests] Scope loader matcher settings to the process to fix parallel-run flakes (#7504)
* [Tests] Scope loader matcher settings to the process to fix parallel-run flakes

* [Tests] Clean up per-process settings sandbox after loader matcher run

* [Tests] Skip loader matcher sandbox cleanup during gtest discovery

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-15 12:21:40 +02:00
BruebachL
677692500b
[Game] Raise rubber band above count label and collapse multi-selection on click (#7451)
* [Game] Raise rubber band above count label and collapse multi-selection on click

Raise rubber band on start and collapse multi-selection to clicked card when left-clicking an already-selected card with no modifiers.

* [Game] Collapse multi-selection on release instead of press

Collapsing at press time dropped the selection before the drag started,
which broke dragging multiple cards. Keep the selection on press and
collapse it on release when the pointer stayed within the drag
distance.

* [Game] Defer selection collapse until the double-click window passes

A release without drag collapsed the multi-selection immediately, so a
double-click that followed (playSelected acts on scene()->selectedItems())
only ever saw the single clicked card. When the card handles
double-clicks on the selection, arm the collapse on a single-shot timer
for QApplication::doubleClickInterval() instead and cancel it from the
double-click handler; a new press disarms the other cards and a
selection-count guard covers rubber-band/ctrl changes meanwhile.

* [Game] Keep sideboard multi-selection on double-click

DeckViewCard overrides doubleClickActsOnSelection to preserve the
selection, restoring master behavior where double-clicking moves all
selected cards between main and side. DeckView cancels any pending
collapse before reading the selection.

* [Game] Remove duplicated inline selection-collapse declarations

* [Game] Fix DeckView collapse call visibility and multi-selection collapse

Make cancelPendingSelectionCollapse() public so DeckView can call it on
selected DeckViewCards. Always arm the deferred collapse on release
instead of skipping cards whose double-click acts on the selection;
the double-click-interval timer plus the cancel calls in
CardItem::mouseDoubleClickEvent and DeckView::mouseDoubleClickEvent
already preserve the selection for double-clicks.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-15 11:35:03 +02:00
BruebachL
fea12a74e2
[CI] Build Windows releases with Ninja (#7380)
Some checks are pending
CodeQL / Analyze (cpp) (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
Build Desktop / Configure (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 cover (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 26 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Desktop / Prune caches (push) Waiting to run
Build Docker / Servatrice (arm) (push) Waiting to run
Build Docker / Servatrice (x86) (push) Waiting to run
Build Docker / Publish multi-platform Servatrice image (push) Blocked by required conditions
The A/B run measured Ninja against the Visual Studio generator across
ten builds: Ninja medians sit at ~17 min against ~21 for MSBuild, and
more importantly Ninja already matches the Linux and macOS legs, runs
the compiler itself and parallelizes the whole graph instead of one
MSBuild node at a time. Single runs on shared 4-core hosts swing by
minutes either way (VS and Ninja legs even ran in different Azure
regions inside one matrix), so this switches on medians and
engineering coherence, not on any one timing.

The Ninja leg is now the release leg: it keeps the -Win10 suffix and
the vcpkg scope (named after the vs2026 toolchain, whose cl.exe is
unchanged, so the warm archives keep restoring), uploads PDBs from the
single-config layout and ships installers to releases. The experiment
scaffolding goes away with the generator it measured.
2026-10-14 19:13:52 +02:00
BruebachL
0c10798ef4
[CI] Build the Windows solution on more than one MSBuild node (#7379)
Some checks are pending
CodeQL / Analyze (cpp) (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
Build Desktop / Configure (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 cover (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 26 Debug (push) Blocked by required conditions
Build Desktop / Prune caches (push) Waiting to run
Build Docker / Servatrice (arm) (push) Waiting to run
Build Docker / Servatrice (x86) (push) Waiting to run
Build Docker / Publish multi-platform Servatrice image (push) Blocked by required conditions
cmake --build only passes a job count to MSBuild when it is asked for
one, and .ci/compile.sh never asked, so every Visual Studio generator
build ran single-node: projects were built one after another and MTT
only ever parallelized the files of whichever project happened to be
building. Ask for one node per core, overridable with
BUILD_PARALLEL_LEVEL, and fall back to a single node when the core
count is not exposed.

Alongside it set EnforceProcessCountAcrossBuilds and CL_MPCount, the
knobs Microsoft's build throughput write-up pairs with MTT: without
them the node count and MTT multiply into more cl.exe processes than
the runner has cores for.

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-14 16:15:01 +02:00
BruebachL
eefe0bd672
[CI] Collect an MSBuild performance summary and binary log on request (#7378)
The Windows job spends about 28 minutes inside one "Build project"
group, with nothing in it that says where the time goes: MSBuild runs
single-node because no --parallel is passed, so projects build one
after another and it is not visible which projects, tasks or phases are
responsible.

Add a --profile flag that makes the Windows build write
-flp:PerformanceSummary and -bl, repeat the summary in the job log so
the numbers need no download, and report the core count, which decides
how much parallelism is worth asking for. The binary log is uploaded as
an artifact only on the runs that asked for it.

The workflow drives it from a build_profile matrix key that is empty by
default, so no build changes behaviour until someone sets it.

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-14 14:27:58 +02:00
BruebachL
25c85cf0bb
[CI] Key the vcpkg binary cache per ABI and let pull requests heal it (#7377)
Some checks are pending
CodeQL / Analyze (cpp) (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
Build Desktop / Configure (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 cover (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 26 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Desktop / Prune caches (push) Waiting to run
Build Docker / Servatrice (arm) (push) Waiting to run
Build Docker / Servatrice (x86) (push) Waiting to run
Build Docker / Publish multi-platform Servatrice image (push) Blocked by required conditions
* [CI] Key the vcpkg binary cache per ABI and let pull requests heal it

The cache had no prefix, and TAServers/vcpkg-cache restores every entry it
finds under the prefix rather than only the hashes vcpkg asks for. With all
legs sharing the single vcpkg/ namespace, each of the five of them downloads
all 66 archives of all five on every run. Each restore also refreshes the
entry's last access date, so a dead ABI generation stays fresh forever and
GitHub's least recently used eviction can never reach it.

Name the scope in the key after the platform, target and toolchain it belongs
to, so a leg restores only its own archives. macOS 14, 15 and 26 on the same
Xcode build identical binaries when they share the lowest deployment target,
so point all three at 14 and let them share one scope instead of three.

A pull request cannot write the shared cache either. Its entries are scoped to
refs/pull/<n>/merge, so nothing it saves is ever readable again, and letting
it write anyway only burns the repository's quota, which is what the previous
attempt at this did. Keep the build read-only and hand the archives it had to
build itself to a new workflow running in the default branch scope, which
saves them where master can restore them. Forks stay read-only, they are the
one case whose archives must not be trusted.

Because the prefix changes every key, add a job that deletes the entries of
retired scopes, which is the one-time clean up of the current flat namespace,
and the per-run ones pull requests leave behind, which age based eviction
also never reaches.

Also pin the Linux ccache cache to its own subdirectory, since the directory
is shared with the docker build context, and drop the clang-format install
from the build images, which format nothing at runtime.

* [CI] Create the vcpkg cache directory when a scope is still empty

* [CI] Let a pull request write the vcpkg archives it builds

* [CI] Upload the vcpkg handover as one archive instead of loose files

* [CI] Hand the vcpkg cache over from one leg per scope

* [CI] Gate vcpkg cache writes to master pushes

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-14 08:14:16 +02:00
592 changed files with 156484 additions and 52231 deletions

View file

@ -3,12 +3,13 @@ FROM debian:12
RUN apt-get update && \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
build-essential \
ca-certificates \
ccache \
clang-format \
cmake \
file \
g++ \
git \
libcurl4-openssl-dev \
libgl-dev \
liblzma-dev \
libmariadb-dev-compat \

View file

@ -5,11 +5,11 @@ RUN apt-get update && \
build-essential \
ca-certificates \
ccache \
clang-format \
cmake \
file \
g++ \
git \
libcurl4-openssl-dev \
libgl-dev \
liblzma-dev \
libmariadb-dev-compat \

View file

@ -1,10 +1,12 @@
FROM fedora:43
RUN dnf install -y \
ca-certificates \
ccache \
cmake \
gcc-c++ \
git \
libcurl-devel \
mariadb-devel \
ninja-build \
openssl-devel \

View file

@ -1,10 +1,12 @@
FROM fedora:44
RUN dnf install -y \
ca-certificates \
ccache \
cmake \
gcc-c++ \
git \
libcurl-devel \
mariadb-devel \
ninja-build \
openssl-devel \

View file

@ -4,7 +4,6 @@ RUN apt-get update && \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
build-essential \
ccache \
clang-format \
cmake \
file \
g++ \

View file

@ -3,12 +3,13 @@ FROM ubuntu:24.04
RUN apt-get update && \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
build-essential \
ca-certificates \
ccache \
clang-format \
cmake \
file \
g++ \
git \
libcurl4-openssl-dev \
libgl-dev \
liblzma-dev \
libmariadb-dev-compat \

View file

@ -5,12 +5,12 @@ RUN apt-get update && \
build-essential \
ca-certificates \
ccache \
clang-format \
cmake \
file \
g++ \
gcovr \
git \
libcurl4-openssl-dev \
libgl-dev \
liblzma-dev \
libmariadb-dev-compat \

View file

@ -17,8 +17,9 @@
# --dir <dir> sets the name of the build dir, default is "build"
# --cmake-generator <generator> sets CMAKE_GENERATOR as used by cmake
# --target-macos-version <version> sets the min os version - only used for macOS builds
# uses env: BUILDTYPE MAKE_INSTALL MAKE_PACKAGE PACKAGE_TYPE PACKAGE_SUFFIX MAKE_SERVER MAKE_NO_CLIENT MAKE_TEST USE_CCACHE CCACHE_SIZE CCACHE_EVICTION_AGE BUILD_DIR CMAKE_GENERATOR TARGET_MACOS_VERSION MAKE_SANITIZE MAKE_COVERAGE
# (correspond to args: --debug/--release --install --package <package type> --suffix <suffix> --server --test --sanitize --coverage --ccache <ccache_size> --dir <dir>)
# --profile writes an MSBuild performance summary and binary log, Windows only
# uses env: BUILDTYPE MAKE_INSTALL MAKE_PACKAGE PACKAGE_TYPE PACKAGE_SUFFIX MAKE_SERVER MAKE_NO_CLIENT MAKE_TEST USE_CCACHE CCACHE_SIZE CCACHE_EVICTION_AGE BUILD_DIR CMAKE_GENERATOR TARGET_MACOS_VERSION MAKE_SANITIZE MAKE_COVERAGE VCPKG_TARGET_MACOS_VERSION BUILD_PARALLEL_LEVEL BUILD_PROFILE
# (correspond to args: --debug/--release --install --package <package type> --suffix <suffix> --server --test --sanitize --coverage --ccache <ccache_size> --dir <dir> --profile)
# exitcode: 1 for failure, 3 for invalid arguments
# Read arguments
@ -115,6 +116,10 @@ while [[ $# != 0 ]]; do
export CMAKE_GENERATOR=$1
shift
;;
'--profile')
BUILD_PROFILE=1
shift
;;
'--target-macos-version')
shift
if [[ $# == 0 ]]; then
@ -195,6 +200,20 @@ if [[ $USE_VCPKG ]]; then
flags+=("-DUSE_VCPKG=1")
flags+=("-DVCPKG_INSTALL_OPTIONS=--x-abi-tools-use-exact-versions")
fi
if [[ ${WITH_CRASH_REPORTING:-} == "ON" ]]; then
# Release CI opts into the sentry-native SDK (default is OFF so from-source, distro and
# offline package builds never fetch it). Debug builds stay SDK-free so crashes from
# developer builds never reach Sentry.
flags+=("-DWITH_CRASH_REPORTING=ON")
fi
if [[ ${BUILDTYPE:-Release} == "Release" && -n ${COCKATRICE_SENTRY_DSN:-} ]]; then
# Release-only: Debug builds stay DSN-free so crashes from developer builds
# never reach Sentry. Empty (local builds, forks without the secret, and non-tag
# CI builds, which never receive the secret) keeps today's mock fallback via the
# compiled-in default. CI passes the value from the SENTRY_DSN secret on tag
# Release builds only, so pre-release test artifacts never report to Sentry.
flags+=("-DCOCKATRICE_SENTRY_DSN=$COCKATRICE_SENTRY_DSN")
fi
# Add cmake --build flags
buildflags=(--config "$BUILDTYPE")
@ -209,6 +228,22 @@ function ccachestatsverbose() {
fi
}
function cpuinfo() {
# The number of cores decides how much build parallelism is useful; the hosted
# runners are not all sized alike, so read it off the machine instead of assuming.
case "$RUNNER_OS" in
Windows)
echo "cores: ${NUMBER_OF_PROCESSORS:-unknown}"
;;
macOS)
echo "cores: $(sysctl -n hw.ncpu)"
;;
*)
echo "cores: $(nproc 2>/dev/null || echo unknown)"
;;
esac
}
# Compile
if [[ $RUNNER_OS == macOS ]]; then
# QTDIR is needed for macOS since we actually only use the cached thin Qt binaries instead of the install-qt-action,
@ -243,8 +278,14 @@ if [[ $RUNNER_OS == macOS ]]; then
triplet_source="../vcpkg/triplets/community/$arch-osx.cmake"
fi
cp "$triplet_source" "$triplet_file"
echo "set(VCPKG_CMAKE_SYSTEM_VERSION $TARGET_MACOS_VERSION)" >>"$triplet_file"
echo "set(VCPKG_OSX_DEPLOYMENT_TARGET $TARGET_MACOS_VERSION)" >>"$triplet_file"
# The triplet is what vcpkg hashes into the ABI hash of every port, so two legs that
# agree here reuse each other's binary cache. Targets 14, 15 and 26 on the same Xcode
# produce identical binaries when they share the lowest deployment target, so they all
# build against 14 to keep one vcpkg cache scope instead of three.
# Keep in sync with "vcpkg_scope" and "vcpkg_deployment_target" in desktop-build.yml.
vcpkg_target="${VCPKG_TARGET_MACOS_VERSION:-$TARGET_MACOS_VERSION}"
echo "set(VCPKG_CMAKE_SYSTEM_VERSION $vcpkg_target)" >>"$triplet_file"
echo "set(VCPKG_OSX_DEPLOYMENT_TARGET $vcpkg_target)" >>"$triplet_file"
flags+=("-DVCPKG_OVERLAY_TRIPLETS=$triplets_dir")
flags+=("-DVCPKG_HOST_TRIPLET=$triplet_version")
flags+=("-DVCPKG_TARGET_TRIPLET=$triplet_version")
@ -270,16 +311,24 @@ if [[ $RUNNER_OS == macOS ]]; then
if [[ $MAKE_PACKAGE ]]; then
# Workaround https://github.com/actions/runner-images/issues/7522
# have hdiutil repeat the command 10 times in hope of success
# have hdiutil repeat the command 10 times in hope of success.
# The wrapper has to exit non-zero once it gives up: CPack reads that status to
# decide whether the disk image was created, so a zero exit turns a failed hdiutil
# into a much later, misleading "Problem copying the package" error that hides
# hdiutil's own message.
hdiutil_script="/tmp/hdiutil.sh"
# shellcheck disable=SC2016
echo '#!/bin/bash
attempts=10
i=0
while ! hdiutil "$@"; do
if (( ++i >= 10 )); then
echo "Error: hdiutil failed $i times!" >&2
break
while true; do
hdiutil "$@" && exit 0
rc=$?
if (( ++i >= attempts )); then
echo "::error::hdiutil $1 failed $i times (last status $rc), giving up" >&2
exit "$rc"
fi
echo "hdiutil $1 attempt $i/$attempts failed (status $rc), retrying" >&2
sleep 1
done' >"$hdiutil_script"
chmod +x "$hdiutil_script"
@ -287,9 +336,34 @@ if [[ $RUNNER_OS == macOS ]]; then
fi
elif [[ $RUNNER_OS == Windows ]]; then
# Enable MTT, see https://devblogs.microsoft.com/cppblog/improved-parallelism-in-msbuild/
# and https://devblogs.microsoft.com/cppblog/cpp-build-throughput-investigation-and-tune-up/#multitooltask-mtt
buildflags+=(-- -p:UseMultiToolTask=true -p:EnableClServerMode=true)
if [[ $CMAKE_GENERATOR == "Visual Studio"* ]]; then
# cmake only hands MSBuild a job count when one is asked for, and without it
# MSBuild builds a single node: one project at a time, whatever MTT does inside
# it. Ask for a node per core.
if [[ ! $BUILD_PARALLEL_LEVEL ]]; then
BUILD_PARALLEL_LEVEL="${NUMBER_OF_PROCESSORS:-1}"
fi
buildflags+=(--parallel "$BUILD_PARALLEL_LEVEL")
# Enable MTT, see https://devblogs.microsoft.com/cppblog/improved-parallelism-in-msbuild/
# and https://devblogs.microsoft.com/cppblog/cpp-build-throughput-investigation-and-tune-up/#multitooltask-mtt
# EnforceProcessCountAcrossBuilds makes the CL_MPCount ceiling apply across all
# the projects the nodes are building, instead of each node running its own
# cl.exe processes and /m and MTT multiplying into an oversubscribed machine.
buildflags+=(-- -p:UseMultiToolTask=true -p:EnableClServerMode=true -p:EnforceProcessCountAcrossBuilds=true -p:CL_MPCount="$BUILD_PARALLEL_LEVEL")
if [[ $BUILD_PROFILE ]]; then
# A performance summary says which projects and tasks cost what, the binary log
# keeps the full timeline; both are far too noisy to always produce. The files
# land next to the solution, i.e. in the build dir the workflow uploads. These
# join the MTT options above, which already carried the "--" separator.
buildflags+=("-flp:PerformanceSummary;v=q;LogFile=msbuild-perf.log" -bl:msbuild.binlog)
fi
else
# Ninja runs the compiler itself, so it neither takes the MSBuild switches
# (it rejects -p: options outright) nor a job count: it sizes its own pool
# for the cores it finds.
echo "ninja $(ninja --version)"
fi
fi
if [[ $USE_CCACHE ]]; then
@ -305,10 +379,19 @@ cmake .. "${flags[@]}"
echo "::endgroup::"
echo "::group::Build project"
cpuinfo
echo "Running cmake --build with flags: ${buildflags[*]}"
cmake --build . "${buildflags[@]}"
echo "::endgroup::"
if [[ $BUILD_PROFILE && $RUNNER_OS == Windows && -f msbuild-perf.log ]]; then
# Repeat the summary in the job log so the numbers are readable without
# downloading the binary log, which is hundreds of megabytes.
echo "::group::MSBuild performance summary"
cat msbuild-perf.log
echo "::endgroup::"
fi
if [[ $USE_CCACHE ]]; then
if [[ $CCACHE_EVICTION_AGE ]]; then
echo "::group::evict ccache files older than $CCACHE_EVICTION_AGE"
@ -384,6 +467,206 @@ if [[ $MAKE_COVERAGE ]]; then
echo "::endgroup::"
fi
if [[ $RUNNER_OS == Windows && ! $MAKE_NO_CLIENT ]]; then
echo "::group::Probe the installer's holder lookup"
# The working directory is still the build dir here: the package steps below are what
# leave and re-enter it, and that is what made a $BUILD_DIR-prefixed path name
# build/build. Both paths are relative to the dir the probe was built into.
probe="./installer-holder-probe.exe"
if [[ ! -f $probe ]]; then
echo "::error file=$0::installer-holder-probe.exe was not built"
exit 1
fi
# The probe exists to name a holder that a process-and-module scan cannot see: one
# that has a file open without having loaded it. Holding a temporary DLL with
# FileShare.None is exactly that case, so this asserts the reason the probe was
# added instead of only that the Restart Manager answers at all. It needs no NSIS
# and no packaging, so it runs on every Windows build that gets this far.
# The holder is a process of its own: the probe never names the process that started it,
# which is what keeps an in-place uninstaller from being named as a holder of the
# directory it is emptying. The same DLL held by the script that starts the probe
# therefore has to come back as nothing, and that is the second case below.
probe_check="./holder-probe-check.ps1"
cat >"$probe_check" <<'POWERSHELL'
$ErrorActionPreference = "Stop"
$dir = Join-Path ([IO.Path]::GetTempPath()) "cockatrice-holder-probe-$PID"
Remove-Item -Recurse -Force $dir -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Force $dir | Out-Null
$probe = (Resolve-Path -LiteralPath $env:PROBE_EXE).Path
function Invoke-Probe([string] $guardDir) {
$start = New-Object System.Diagnostics.ProcessStartInfo
$start.FileName = $probe
$start.UseShellExecute = $false
$start.RedirectStandardOutput = $true
$start.EnvironmentVariables["COCKATRICE_GUARD_DIR"] = $guardDir
$process = [System.Diagnostics.Process]::Start($start)
$read = $process.StandardOutput.ReadToEndAsync()
if (-not $process.WaitForExit(20000)) {
try { $process.Kill() } catch { }
throw "the holder probe did not finish within 20 s for $guardDir"
}
$output = $read.Result
$code = $process.ExitCode
$process.Dispose()
return @{ Code = $code; Output = $output }
}
function Wait-UntilLocked([string] $path) {
$deadline = (Get-Date).AddSeconds(15)
while ((Get-Date) -lt $deadline) {
try {
$open = [IO.File]::Open($path, "Open", "ReadWrite", "None")
$open.Dispose()
} catch {
return
}
Start-Sleep -Milliseconds 100
}
throw "the holder never locked $path"
}
try {
$file = Join-Path $dir "locked.dll"
[IO.File]::WriteAllText($file, "x")
# The holder is a process of its own, not this script: the probe never names the process
# that started it (the case below asserts that), so a file held here would be left out on
# purpose and the check would pass for the wrong reason.
$holderScript = Join-Path $dir "hold.ps1"
Set-Content -LiteralPath $holderScript -Value @'
param([string] $Path)
$handle = [IO.File]::Open($Path, "Open", "ReadWrite", "None")
Start-Sleep -Seconds 120
'@
$powershell = Join-Path $env:SystemRoot "System32\WindowsPowerShell\v1.0\powershell.exe"
$holder = Start-Process -FilePath $powershell -PassThru -ArgumentList @(
"-NoProfile", "-ExecutionPolicy", "Bypass", "-File", "`"$holderScript`"", "-Path", "`"$file`"")
try {
Wait-UntilLocked $file
$locked = Invoke-Probe $dir
if ($locked.Code -ne 1) { throw "expected exit 1 for a locked file, got $($locked.Code) : $($locked.Output)" }
if ($locked.Output -notmatch "pid\s+$($holder.Id)\b") {
throw "the lookup did not name the process holding the file: $($locked.Output)"
}
} finally {
try { $holder.Kill() } catch { }
try { $holder.WaitForExit(5000) | Out-Null } catch { }
}
# The probe is started by the installer, or by an uninstaller that runs in place from the
# directory it is emptying, so the asking process holds files there itself. Naming it would
# ask the user to close the program that is asking - a question neither the close nor the
# end offer can answer, so the guard repeated it until the update was cancelled. A holder
# that is only the asking process has to come back as nothing.
$handle = [IO.File]::Open($file, "Open", "ReadWrite", "None")
try {
$self = Invoke-Probe $dir
} finally {
$handle.Dispose()
}
if ($self.Code -ne 0) { throw "expected exit 0 when the asking process is the only holder, got $($self.Code) : $($self.Output)" }
if ($self.Output -match "pid\s+$PID\b") { throw "the lookup named the process that started it: $($self.Output)" }
# A payload executable that refuses to be replaced is running, whether or not this probe
# may name it: the Restart Manager does not report every mapping, and the image path of a
# process under another account cannot be read from here, so both process lookups can
# come back empty while the program is still there. Held here by the asking process, so
# that it cannot be named - the answer has to be "could not name it", never "nothing is
# holding it", which is what sent an update into an uninstall that could only fail
# halfway. Exit 3 is the code the guard answers by asking the user to close it and retry.
$image = Join-Path $dir "oracle.exe"
[IO.File]::WriteAllText($image, "x")
$handle = [IO.File]::Open($image, "Open", "ReadWrite", "None")
try {
$unnameable = Invoke-Probe $dir
} finally {
$handle.Dispose()
}
if ($unnameable.Code -ne 3) {
throw "expected exit 3 for a payload executable that is in use but cannot be named, got $($unnameable.Code) : $($unnameable.Output)"
}
if ($unnameable.Output -notmatch "oracle\.exe is in use") {
throw "the answer did not name the executable it could not replace: $($unnameable.Output)"
}
$empty = Join-Path $dir "empty"
New-Item -ItemType Directory -Force $empty | Out-Null
$nothing = Invoke-Probe $empty
if ($nothing.Code -ne 0) { throw "expected exit 0 for a directory with nothing open, got $($nothing.Code) : $($nothing.Output)" }
# A process running from the directory is a holder even when it has no file open: an
# instance holds its payload as mapped images, not as handles, and that is the state
# the guard used to miss - it passed, the purge failed, and the install ended in a
# warning with no offer to close and try again. The image is a copy of a native
# Windows program, so it starts without its usual directory next to it, and it carries
# one of the payload's own names because that is what the lookup names.
$copy = Join-Path $dir "cockatrice.exe"
Copy-Item -LiteralPath (Join-Path $env:SystemRoot "System32\cmd.exe") -Destination $copy
$running = Start-Process -FilePath $copy -ArgumentList "/c", "ping -n 20 127.0.0.1 >nul" -PassThru
try {
Start-Sleep -Milliseconds 1500
$byImage = Invoke-Probe $dir
} finally {
try { $running.Kill() } catch { }
# Wait for the image to be released, or the cleanup below leaves the copy behind.
try { $running.WaitForExit(5000) | Out-Null } catch { }
}
if ($byImage.Code -ne 1) { throw "expected exit 1 for a process running from the directory, got $($byImage.Code) : $($byImage.Output)" }
if ($byImage.Output -notmatch "cockatrice\.exe \(pid\s+$($running.Id)") {
throw "the lookup did not name the process running from the directory: $($byImage.Output)"
}
# A program in a folder below the directory is not an instance of the installation, even
# when it carries a payload program's name: the install directory can be a folder the
# user keeps other applications in, each in its own subfolder, and naming one of those
# asked for a close the probe never sends and an end it refuses to perform, so the guard
# could only abort the update. A program that really does hold a payload file is still
# named, by the Restart Manager lookup exercised above.
$nested = Join-Path $dir "other"
New-Item -ItemType Directory -Force $nested | Out-Null
$nestedImage = Join-Path $nested "cockatrice.exe"
Copy-Item -LiteralPath (Join-Path $env:SystemRoot "System32\cmd.exe") -Destination $nestedImage
$nestedRunning = Start-Process -FilePath $nestedImage -ArgumentList "/c", "ping -n 20 127.0.0.1 >nul" -PassThru
try {
Start-Sleep -Milliseconds 1500
$inSubfolder = Invoke-Probe $dir
} finally {
try { $nestedRunning.Kill() } catch { }
try { $nestedRunning.WaitForExit(5000) | Out-Null } catch { }
}
if ($inSubfolder.Code -ne 0) {
throw "expected exit 0 for a program in a folder below the directory, got $($inSubfolder.Code) : $($inSubfolder.Output)"
}
# A program the user keeps in the directory itself is not a payload program either: the
# guard ends only what the payload installs, and naming anything else asks for an answer
# that cannot free a single file.
$foreign = Join-Path $dir "tool.exe"
Copy-Item -LiteralPath (Join-Path $env:SystemRoot "System32\cmd.exe") -Destination $foreign
$foreignRunning = Start-Process -FilePath $foreign -ArgumentList "/c", "ping -n 20 127.0.0.1 >nul" -PassThru
try {
Start-Sleep -Milliseconds 1500
$ignored = Invoke-Probe $dir
} finally {
try { $foreignRunning.Kill() } catch { }
try { $foreignRunning.WaitForExit(5000) | Out-Null } catch { }
}
if ($ignored.Code -ne 0) {
throw "expected exit 0 for a program the payload does not install, got $($ignored.Code) : $($ignored.Output)"
}
Write-Host "holder lookup named pid $($holder.Id) for a file another process holds, named nothing for a file only the asking process holds, reported a payload executable it could not name as in use instead of free, named nothing for an empty directory, named pid $($running.Id) for a payload program running from the directory, and named nothing for a payload program in a folder below it or for a program the payload does not install"
} finally {
Remove-Item -Recurse -Force $dir -ErrorAction SilentlyContinue
}
POWERSHELL
if ! PROBE_EXE="$probe" powershell -NoProfile -File "$probe_check"; then
rm -f "$probe_check"
exit 1
fi
rm -f "$probe_check"
echo "::endgroup::"
fi
if [[ $MAKE_INSTALL ]]; then
echo "::group::Install"
cmake --build . --target install --config "$BUILDTYPE"

View file

@ -175,6 +175,12 @@ function RUN ()
args+=(--mount "type=bind,source=$GITHUB_OUTPUT,target=/gh_output")
args+=(--env "GITHUB_OUTPUT=/gh_output")
fi
# The container does not inherit host env vars: without this, WITH_CRASH_REPORTING=ON set in
# the workflow never reaches compile.sh inside the container, so the Linux packages silently
# build with crash reporting OFF while the libcurl packages in the Dockerfiles go unused.
if [[ ${WITH_CRASH_REPORTING:-} ]]; then
args+=(--env "WITH_CRASH_REPORTING=${WITH_CRASH_REPORTING}")
fi
# shellcheck disable=2086
docker run "${args[@]}" $RUN_ARGS "$IMAGE_NAME" bash "$BUILD_SCRIPT" $RUN_OPTS "$@"
return $?

View file

@ -60,8 +60,10 @@ jobs:
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
ca-certificates \
cmake \
g++ \
libcurl4-openssl-dev \
libgl-dev \
liblzma-dev \
libmariadb-dev-compat \

View file

@ -46,6 +46,11 @@ jobs:
outputs:
tag: ${{ steps.configure.outputs.tag }}
sha: ${{ steps.configure.outputs.sha }}
# MAJ.MIN.PATCH[-beta[.N]] of a tag build; empty otherwise. Mirrors what
# cmake/getversion.cmake derives as PROJECT_VERSION, which the client bakes as its
# Sentry release (cmake/CrashReporting.cmake) — symbol uploads must use the same
# string so stack traces de-symbolicate against this build's binaries.
sentry_release: ${{ steps.configure.outputs.sentry_release }}
steps:
- name: "Configure"
@ -56,6 +61,9 @@ jobs:
run: |
if [[ "$GITHUB_REF_TYPE" == 'tag' ]]; then # release
echo "tag=$GITHUB_REF_NAME" >> "$GITHUB_OUTPUT"
# Tag format is YYYY-MM-DD-{Release|Development}-MAJ.MIN.PATCH[-beta[.N]];
# PROJECT_VERSION is the tail after the type (see cmake/getversion.cmake).
echo "sentry_release=$(sed -E 's/^[0-9]{4}-[0-9]{2}-[0-9]{2}-(Release|Development)-//' <<< "$GITHUB_REF_NAME")" >> "$GITHUB_OUTPUT"
fi
echo "sha=$RESOLVED_SHA" >> "$GITHUB_OUTPUT"
@ -93,6 +101,36 @@ jobs:
--title "$release_name" \
--notes-file "$body_path"
vcpkg-baseline:
name: vcpkg baseline
runs-on: ubuntu-slim
steps:
- name: "Checkout"
uses: actions/checkout@v7
# vcpkg-tool reads versions/baseline.json out of the commit named by
# `builtin-baseline`, through the `vcpkg` submodule's git history. A submodule
# pointer that lags the manifest - e.g. a stale `vcpkg/` worktree captured by
# `git add` in a PR - makes every vcpkg leg fail in configure with "failed to
# `git show` versions/baseline.json". That reads like a baseline or network
# problem, when the pointer is what has to move. Check the two agree here in
# seconds instead of on five build legs.
- name: "Check the submodule pointer against the manifest baseline"
shell: bash
run: |
pointer="$(git rev-parse HEAD:vcpkg 2>/dev/null || true)"
baseline="$(sed -nE 's/.*"builtin-baseline"[[:space:]]*:[[:space:]]*"([0-9a-f]{40})".*/\1/p' vcpkg.json)"
if [[ -z "$pointer" || -z "$baseline" ]]; then
echo "::error file=vcpkg.json::could not read the vcpkg submodule pointer or the builtin-baseline"
exit 1
fi
if [[ "$pointer" != "$baseline" ]]; then
echo "::error file=vcpkg.json::vcpkg submodule is at $pointer but builtin-baseline is $baseline"
echo "Move them together: git -C vcpkg checkout $baseline && git add vcpkg"
exit 1
fi
echo "vcpkg submodule and builtin-baseline agree on $baseline"
build-linux:
strategy:
fail-fast: false
@ -184,7 +222,7 @@ jobs:
uses: actions/cache/restore@v6
with:
key: ccache-${{ matrix.distro }}${{ matrix.version }}${{ matrix.variant }}
path: ${{ env.CACHE }}
path: ${{ env.CACHE }}/.ccache
- name: "Build ${{ matrix.distro }} ${{ matrix.version }} Docker image"
shell: bash
@ -225,7 +263,7 @@ jobs:
- name: "Upload coverage artifact"
if: matrix.coverage == 'yes'
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: coverage-report
path: coverage/
@ -239,6 +277,12 @@ jobs:
SUFFIX: '-${{ matrix.distro }}${{ matrix.version }}'
package: '${{ matrix.package }}'
server_only: '${{ matrix.server_only }}'
WITH_CRASH_REPORTING: 'ON'
# Tag builds only: artifacts from master pushes and PR branches must not report to
# Sentry. Forks get no secrets on pull_request, so they keep the mock fallback either way.
# 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 || '' }}
run: |
source .ci/docker.sh
args=()
@ -247,9 +291,58 @@ jobs:
args+=(--ccache "$CCACHE_SIZE")
args+=(--cmake-generator "$CMAKE_GENERATOR")
args+=(--suffix "$SUFFIX")
# The container inherits no environment, so the DSN rides in explicitly. -e without
# =value passes it through (possibly empty); compile.sh only consumes it for Release
# configures, and empty keeps the mock fallback.
export RUN_ARGS="${RUN_ARGS:-} -e COCKATRICE_SENTRY_DSN"
RUN --server --release --package "$package" "${args[@]}"
- name: "Install sentry-cli"
if: matrix.package != 'skip' && needs.configure.outputs.tag != null
# Downloads from Sentry's release registry: a Sentry outage or a broken install under
# Git Bash must not take down the release assets below.
continue-on-error: true
shell: bash
env:
SENTRY_CLI_VERSION: '3.8.0'
run: |
export INSTALL_DIR="$GITHUB_WORKSPACE/.sentry-cli"
curl -sL https://sentry.io/get-cli/ | bash
echo "$GITHUB_WORKSPACE/.sentry-cli" >> "$GITHUB_PATH"
- name: "Upload debug symbols to Sentry"
# Like the DSN above, this runs for every tag — beta (Development) tags included.
if: matrix.package != 'skip' && needs.configure.outputs.tag != null
# A Sentry outage or rotated token must not take down the release assets below.
continue-on-error: true
shell: bash
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
run: |
paths=()
# The binaries only, not the directories: a directory makes sentry-cli walk the
# whole build tree, where it finds (and uploads) object files and other fragments
# that can never match a stack frame. The packaged copy is split and stripped by
# cpack, but keeps this binary's GNU build-id, which is how Sentry matches it.
for app in cockatrice oracle servatrice; do
binary="build/$app/$app"
[[ -f $binary ]] && paths+=("$binary")
done
if [[ ${#paths[@]} == 0 ]]; then
# Fail loudly instead of skipping: see the Windows/macOS leg below — this step
# is the only way symbols reach Sentry and `continue-on-error` keeps the job
# green either way, so a silent skip looks exactly like a successful upload.
echo "::error::No debug symbol files found; nothing would be uploaded"
exit 1
fi
# Project slug on Sentry; must match the project the baked DSN points at.
# No --release here: `debug-files upload` has no such option (sentry-cli errors on
# the unknown flag and uploads nothing), and debug information files are matched to
# events by debug ID via the event's debug_meta, not by release. The release grouping
# comes from the client event itself (COCKATRICE_SENTRY_RELEASE).
sentry-cli debug-files upload --org cockatrice --project cockatrice-desktop "${paths[@]}"
# 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
@ -267,7 +360,7 @@ jobs:
uses: actions/cache/save@v6
with:
key: ${{ steps.restore_ccache.outputs.cache-primary-key }}
path: ${{ env.CACHE }}
path: ${{ env.CACHE }}/.ccache
- name: "Upload artifact"
id: upload_artifact
@ -325,6 +418,8 @@ jobs:
soc: Intel
type: Release
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"
- os: macOS
@ -341,6 +436,9 @@ jobs:
soc: Apple
type: Release
use_ccache: 1
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
@ -357,6 +455,8 @@ jobs:
soc: Apple
type: Release
use_ccache: 1
vcpkg_deployment_target: 14
vcpkg_scope: 'vcpkg/macos14-arm64-xcode26.6/'
xcode: "26.6"
- os: macOS
@ -370,22 +470,33 @@ jobs:
soc: Apple
type: Debug
use_ccache: 1
vcpkg_deployment_target: 14
vcpkg_scope: 'vcpkg/macos14-arm64-xcode26.6/'
xcode: "26.6"
- os: Windows
target: 10
runner: windows-2025 # https://github.com/actions/runner-images/blob/main/images/windows/Windows2025-VS2026-Readme.md
cmake_generator: "Visual Studio 18 2026"
cmake_generator_platform: x64
# Ninja builds the whole dependency graph at once instead of one
# MSBuild node at a time, runs the compiler itself, and matches the
# Linux and macOS legs. Its median build phase measured ~17 min
# against ~21 for the Visual Studio generator (shared 4-core hosts
# swing either by minutes, so read medians, not single runs).
cmake_generator: Ninja
make_package: 1
package_suffix: "-Win10"
qt_version: 6.11.*
qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets
type: Release
# Scope is named after the vs2026 toolchain, not the generator: the
# ABI hash covers cl.exe, which is identical, so this leg keeps
# restoring the warm archives. Renaming would orphan them.
vcpkg_scope: 'vcpkg/windows10-x64-vs2026/'
vcpkg_upload: 1 # only one leg per vcpkg_scope hands its archives over
name: ${{ matrix.os }} ${{ matrix.target }}${{ matrix.soc == 'Intel' && ' Intel' || '' }}${{ matrix.type == 'Debug' && ' Debug' || '' }}
needs: configure
needs: [configure, vcpkg-baseline]
runs-on: ${{ matrix.runner }}
timeout-minutes: 100
env:
@ -476,12 +587,45 @@ jobs:
shell: bash
run: choco install nsis
# Ninja invokes cl.exe directly and needs the MSVC environment on PATH.
- name: "[Windows] Set up MSVC environment"
if: matrix.os == 'Windows'
uses: TheMrMilchmann/setup-msvc-dev@368ef7d1ee4d1171b31d4a7f67f4d954f903f5a9 # v4.1.0
with:
arch: x64
- name: "Setup vcpkg cache"
id: vcpkg-cache
uses: TAServers/vcpkg-cache@v3
with:
# Every entry is keyed by vcpkg ABI hash, and one hash set exists per platform,
# target and toolchain. The action restores every entry it finds under the
# prefix, not just the hashes vcpkg asks for, so with one shared vcpkg/
# namespace every leg downloads all 66 archives of all 5 legs on every run.
# Each of those restores also refreshes the entry's last access date, which
# is why a dead ABI generation is never evicted by age. Naming the scope is
# what makes those generations identifiable, and prune-caches below able to
# delete them.
prefix: ${{ matrix.vcpkg_scope }}
token: ${{ secrets.GITHUB_TOKEN }}
# The action only creates its directory when the prefix matched at least one
# entry, and it reads that directory unguarded when it saves at the end of the
# job. A scope nobody has written to yet therefore leaves the directory missing
# and fails the job, which is exactly the state every scope starts in. Make the
# directory up front so an empty scope is a cache miss instead of an error.
# always() covers the cache step itself failing: the action's post save still runs
# then, and it should report the real error instead of ENOENT. It cannot rescue a
# failure before the cache step, since a post step only runs for an action whose
# main step did. The fallback keeps this guard from failing when the cache step was
# skipped and left no path output.
- name: "Create vcpkg cache directory"
if: always()
shell: bash
env:
CACHE_DIR: '${{ steps.vcpkg-cache.outputs.path }}'
run: mkdir -p "${CACHE_DIR:-$GITHUB_WORKSPACE/.vcpkg-cache}"
# Uses environment variables, see compile.sh for more details
- name: "Build Cockatrice"
id: build
@ -490,7 +634,11 @@ jobs:
BUILDTYPE: '${{ matrix.type }}'
CCACHE_EVICTION_AGE: ${{ matrix.ccache_eviction_age }}
CMAKE_GENERATOR: ${{ matrix.cmake_generator }}
CMAKE_GENERATOR_PLATFORM: ${{ matrix.cmake_generator_platform }}
# Tag builds only: artifacts from master pushes and PR branches must not report to
# Sentry. Forks get no secrets on pull_request, so they keep the mock fallback either way.
# 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 }}
@ -501,11 +649,138 @@ jobs:
PACKAGE_SUFFIX: '${{ matrix.package_suffix }}'
TARGET_MACOS_VERSION: ${{ matrix.override_target }}
USE_CCACHE: ${{ matrix.use_ccache }}
# Only pushes to the default branch may write the shared binary cache: the
# archives a run compiles are only as trustworthy as the code they were
# built from, and only merged code may populate the scope that master and
# release builds restore and link. Pull requests (same-repository ones
# included) restore the shared cache but never write it; with the source
# left at read the collect step below finds nothing on PRs, so no
# pull-request-scoped entry is ever saved that no later run could restore.
# Forks pushing to their own default branch resolve github.ref against
# their own repository, so they keep a working cache of their own there.
VCPKG_BINARY_SOURCES: "clear;files,${{ steps.vcpkg-cache.outputs.path }},${{ case(github.ref == 'refs/heads/master', 'readwrite', 'read') }}"
VCPKG_DISABLE_METRICS: 1
VCPKG_FEATURE_FLAGS: dependencygraph
VCPKG_TARGET_MACOS_VERSION: ${{ matrix.vcpkg_deployment_target }}
# Release legs compile the sentry-native SDK; Debug legs stay SDK-free.
WITH_CRASH_REPORTING: ${{ matrix.type == 'Release' && 'ON' || '' }}
run: .ci/compile.sh --server --test --vcpkg
- name: "Install sentry-cli"
if: needs.configure.outputs.tag != null && matrix.make_package == '1'
# Same as the Linux leg above: the install downloads from Sentry's registry, so a
# failure here must not block the release upload below.
continue-on-error: true
shell: bash
env:
SENTRY_CLI_VERSION: '3.8.0'
run: |
export INSTALL_DIR="$GITHUB_WORKSPACE/.sentry-cli"
curl -sL https://sentry.io/get-cli/ | bash
echo "$GITHUB_WORKSPACE/.sentry-cli" >> "$GITHUB_PATH"
- name: "Upload debug symbols to Sentry"
# Like the DSN above, this runs for every tag — beta (Development) tags included.
if: needs.configure.outputs.tag != null && matrix.make_package == '1'
# A Sentry outage or rotated token must not take down the release assets below.
continue-on-error: true
shell: bash
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
run: |
paths=()
if [[ '${{ matrix.os }}' == 'Windows' ]]; then
# Top-level CMakeLists.txt links /DEBUG even in release, so the linker PDBs
# land next to the binaries. Ninja is single-config, so that is
# build/<target>/ and NOT build/<target>/Release/ — the same globs the symbol
# artifact step below uses.
#
# The executables are uploaded as well, in the same invocation. On 64-bit
# Windows the Call Frame Information (.pdata/.xdata) lives in the PE and is
# not copied into the PDB, so a PDB on its own leaves Sentry reporting
# "Stack Unwinding: Missing" for the image. sentry-cli also needs to see the
# PE and its PDB together: only then does it associate the PE's code
# identifier with the PDB's debug identifier.
shopt -s nullglob
for target in cockatrice oracle servatrice; do
paths+=("build/$target"/*.exe "build/$target"/*.pdb)
done
shopt -u nullglob
# Qt's own runtime DLLs, taken from required-runtime.txt — the manifest
# .ci/compile.sh asserts the installer against — so the set uploaded is
# the set an install actually carries. The PE is the only carrier of
# unwind information for a 64-bit image, and sending it in the same
# invocation as a PDB is what lets sentry-cli associate the PE's code
# identifier with the PDB's debug identifier. Qt ships no PDBs with the
# SDK, so Qt frames resolve through the export table: names, no lines.
qt_dir=
qt_core_dir=
# Existence guards matter: the step runs under `bash -eo pipefail`, so a
# redirection-less sed over a missing file would abort the step outright
# and continue-on-error would hide it.
if [[ -f build/CMakeCache.txt ]]; then
qt_core_dir=$(sed -n 's/^Qt6Core_DIR:PATH=//p' build/CMakeCache.txt | head -1)
fi
if [[ -n $qt_core_dir ]]; then
qt_dir=$(dirname "$(dirname "$(dirname "$qt_core_dir")")")
fi
if [[ -z $qt_dir || ! -d $qt_dir ]]; then
# Warnings, not failures: the upload of our own binaries is what this
# step is for, and continue-on-error already means an upload failure
# cannot be told apart from a successful one.
echo "::warning::Qt runtime directory not resolved from build/CMakeCache.txt; Qt images get no unwind information"
elif [[ ! -f build/required-runtime.txt ]]; then
echo "::warning::build/required-runtime.txt is missing; cannot tell which Qt DLLs ship"
else
while IFS= read -r runtime; do
name=${runtime##*/}
if [[ $runtime == */* ]]; then
# A Qt plugin: the install root keeps it as plugins/<type>/<name>.
# `|| true`: an unmatched glob makes ls exit non-zero, and pipefail
# turns that into a step abort.
runtime_path=$(ls "$qt_dir/plugins"/*/"$name" 2>/dev/null | head -1) || true
else
runtime_path="$qt_dir/bin/$name"
fi
if [[ -f ${runtime_path:-} ]]; then
paths+=("$runtime_path")
else
echo "::warning::shipped Qt runtime $runtime not found under $qt_dir"
fi
done < <(grep -E '^(Qt6[^/]*\.dll|Plugins/)' build/required-runtime.txt)
fi
else
# Release builds carry DWARF (-g, kept by CrashReporting.cmake); macOS keeps its
# debug info in dSYM bundles, so materialize them before uploading.
for app in cockatrice oracle servatrice; do
binary="build/$app/$app.app/Contents/MacOS/$app"
if [[ -f $binary ]]; then
if [[ '${{ matrix.os }}' == 'macOS' ]]; then
dsymutil "$binary" -o "$binary.dSYM"
paths+=("$binary.dSYM")
# The dSYM holds the debug information, but the unwind information
# Sentry needs to walk a minidump stack lives in the Mach-O itself.
paths+=("$binary")
else
paths+=("$binary")
fi
fi
done
fi
if [[ ${#paths[@]} == 0 ]]; then
# Fail loudly instead of skipping: this step is the only way symbols reach
# Sentry, and `continue-on-error` keeps the job green either way, so a
# silent skip is indistinguishable from a successful upload. It also hid a
# wrong Windows path (build/<target>/Release, which Ninja never creates).
echo "::error::No debug symbol files found; nothing would be uploaded"
exit 1
fi
# Project slug on Sentry; must match the project the baked DSN points at.
# No --release: see the Linux leg above — the flag does not exist on
# `debug-files upload` and DIFs are matched by debug ID, not release.
sentry-cli debug-files upload --org cockatrice --project cockatrice-desktop "${paths[@]}"
# CPack reports a failed makensis run as "Problem running NSIS command"
# plus a path to NSISOutput.log, and that log lives only on the runner, so
# the actual compile error is otherwise nowhere in the workflow output.
@ -517,6 +792,63 @@ jobs:
shell: bash
run: find . -name NSISOutput.log -exec cat {} +
# A master run hands over the archives it had to build itself so that
# vcpkg-cache-import.yml, which runs in the default branch scope, can save them
# where later runs can restore them. Pull requests hand over nothing: their cache
# entries would be scoped to refs/pull/<n>/merge and stay unreadable for every
# other run, so writing them only burns the repository's quota.
# vcpkg_upload keeps this to one leg per scope: macOS 14, 15 and 26 share a scope
# by design, and all three rebuild the same archives, so uploading from each of them
# would publish three identical 269 MB artifacts under one artifact name and make
# the importer spawn a save job per copy.
- name: "Collect new vcpkg entries"
id: vcpkg_new
if: github.ref == 'refs/heads/master' && matrix.vcpkg_upload == 1
shell: bash
env:
CACHE_DIR: '${{ steps.vcpkg-cache.outputs.path }}'
GH_TOKEN: ${{ github.token }}
VCPKG_SCOPE: '${{ matrix.vcpkg_scope }}'
run: |
# The archive name is the ABI hash, so a missing key is exactly what vcpkg had
# to compile from source in this job.
gh api --paginate \
"repos/$GITHUB_REPOSITORY/actions/caches?ref=refs/heads/master&key=$VCPKG_SCOPE&per_page=100" \
--jq '.actions_caches[].key' | sort -u >"$RUNNER_TEMP/master-keys.txt"
find "$CACHE_DIR" -type f -name '*.zip' -exec basename {} .zip \; |
sed "s|^|$VCPKG_SCOPE|" | sort -u >"$RUNNER_TEMP/local-keys.txt"
comm -23 "$RUNNER_TEMP/local-keys.txt" "$RUNNER_TEMP/master-keys.txt" >"$RUNNER_TEMP/new-keys.txt"
mkdir -p "$RUNNER_TEMP/vcpkg-new"
: >"$RUNNER_TEMP/vcpkg-new/keys.txt"
while read -r key; do
hash="${key##*/}"
archive="$(find "$CACHE_DIR" -type f -name "$hash.zip" -print -quit)"
dir="$RUNNER_TEMP/vcpkg-new/${hash:0:2}" # vcpkg's own two character fanout
mkdir -p "$dir"
cp "$archive" "$dir/"
echo "$key ${hash:0:2}/$hash.zip" >>"$RUNNER_TEMP/vcpkg-new/keys.txt"
done <"$RUNNER_TEMP/new-keys.txt"
count="$(wc -l <"$RUNNER_TEMP/new-keys.txt")"
echo "vcpkg scope $VCPKG_SCOPE: $count entries to import"
echo "count=$count" >>"$GITHUB_OUTPUT"
echo "slug=$(sed -e 's|^vcpkg/||' -e 's|/$||' -e 's|/|-|g' <<<"$VCPKG_SCOPE")" >>"$GITHUB_OUTPUT"
# archive: false accepts a single file and nothing else, and a scope hands over one
# archive per ABI hash vcpkg had to rebuild, which is 23 files on a cold runner.
# Zipping the directory keeps the fanout at the artifact root, which is where
# vcpkg-cache-import.yml looks for keys.txt once download-artifact unpacks it.
- name: "Upload new vcpkg entries"
# An empty count means the step above did not run at all, on master and on forks
if: steps.vcpkg_new.outputs.count != '' && steps.vcpkg_new.outputs.count != '0'
uses: actions/upload-artifact@v7
with:
if-no-files-found: error # a count above zero with nothing staged is a bug
name: vcpkg-new-${{ steps.vcpkg_new.outputs.slug }}
path: ${{ runner.temp }}/vcpkg-new
retention-days: 1
# 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
@ -592,17 +924,6 @@ jobs:
if-no-files-found: error
path: ${{ steps.build.outputs.path }}
- name: "[Windows] Upload PDBs (Program Databases)"
if: matrix.os == 'Windows' && github.ref_type != 'tag'
uses: actions/upload-artifact@v7
with:
if-no-files-found: error
name: ${{ steps.build.outputs.name }}-PDBs
path: |
build/cockatrice/Release/*.pdb
build/oracle/Release/*.pdb
build/servatrice/Release/*.pdb
- name: "Upload to release"
if: needs.configure.outputs.tag != null && matrix.make_package == '1'
id: upload_release
@ -614,6 +935,33 @@ jobs:
tag_name: ${{ needs.configure.outputs.tag }}
run: gh release upload "$tag_name" "$asset_path#$asset_name"
# Ninja is single-config: binaries and their PDBs land directly in the
# target directories instead of a Release subdirectory.
#
# Archived for tag builds only, and ordered after the release upload so
# a missing PDB can never block a release from being published. On a tag
# build this artifact is the only durable copy of the symbols: the Sentry
# upload is continue-on-error, so without it a failed upload would lose
# the ability to symbolicate that release permanently. Other builds upload
# no symbols at all, so archiving them there would only cost storage.
#
# The executables travel with their PDBs: re-uploading the pair together is
# what lets sentry-cli associate the PE's code identifier with the PDB's
# debug identifier, and the PE is what carries the unwind information.
- name: "[Windows] Upload debug symbols (exe + pdb)"
if: matrix.os == 'Windows' && needs.configure.outputs.tag != null
uses: actions/upload-artifact@v7
with:
if-no-files-found: error
name: ${{ steps.build.outputs.name }}-Symbols
path: |
build/cockatrice/*.exe
build/cockatrice/*.pdb
build/oracle/*.exe
build/oracle/*.pdb
build/servatrice/*.exe
build/servatrice/*.pdb
- name: "Attest binary provenance"
if: steps.upload_release.outcome == 'success'
id: attestation
@ -629,3 +977,67 @@ jobs:
BUILD_PATH: ${{ steps.build.outputs.path }}
GH_TOKEN: ${{ github.token }}
run: gh attestation verify "$BUILD_PATH" --repo Cockatrice/Cockatrice
prune-caches:
name: Prune caches
# Only master writes caches at all, so only master may clean them up too
if: github.ref == 'refs/heads/master'
runs-on: ubuntu-slim # https://github.com/actions/runner-images/blob/main/images/ubuntu-slim/ubuntu-slim-Readme.md
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.
- name: "Delete unusable cache entries"
shell: bash
env:
GH_TOKEN: ${{ github.token }}
# Must list every vcpkg_scope of the build-vcpkg matrix 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/'
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} "
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
case "$key" in
vcpkg/* | ccache-* | Qt*) ;;
*) continue ;;
esac
reason=""
if [[ $ref != refs/heads/master ]]; then
reason="scoped to $ref"
elif [[ $key == vcpkg/* ]]; then
# "<scope>/<abi hash>", cut the hash off the end to get the scope
scope="vcpkg/${key#vcpkg/}"
scope="${scope%/*}/"
if [[ $live != *" $scope "* ]]; then
reason="no matrix leg uses $scope"
fi
fi
if [[ -z $reason ]]; then
stamp="$(date -u -d "$accessed" +%s 2>/dev/null || echo 0)"
if [[ $stamp -ne 0 && $stamp -lt $cutoff ]]; then
reason="unused since $accessed"
fi
fi
if [[ -n $reason ]]; then
echo "::notice::deleting $key, $reason"
gh api -X DELETE "repos/$GITHUB_REPOSITORY/actions/caches/$id"
deleted=$((deleted + 1))
fi
done <"$RUNNER_TEMP/caches.tsv"
echo "::notice::deleted $deleted of $(wc -l <"$RUNNER_TEMP/caches.tsv") cache entries"

121
.github/workflows/vcpkg-cache-import.yml vendored Normal file
View file

@ -0,0 +1,121 @@
name: Import vcpkg cache
# After a runner image update changes every port's ABI hash, the first master run
# rebuilds protobuf, openssl and abseil from source and saves them into the shared
# cache, where every later run can restore them.
#
# Only master pushes are imported here: the collect step, keys.txt and the zip
# contents all come from the workflow run being imported, so importing from an
# unmerged pull request would let anyone who can push a branch — or a bot actor
# such as Dependabot, whose PRs are same-repo — promote arbitrary binaries into
# the scope that master and tag builds restore and link into signed releases.
# Forks build untrusted code and must never reach that scope either.
#
# repository_dispatch is not used on purpose: it would also resolve to the default branch,
# but it counts as a low-trust trigger and may only write there when it declares a
# write-capable cache-mode, which puts it in the same position as workflow_run.
permissions:
actions: write # needed to save entries into the default branch's cache scope
contents: read
on:
workflow_run:
workflows: ["Build Desktop"]
types: [completed]
# Every import writes the same scopes, so let them queue instead of racing each other
concurrency:
group: vcpkg-cache-import
cancel-in-progress: false
jobs:
list:
name: List new vcpkg entries
# Only a successful push to the default branch has archives worth importing.
# Only code that landed on master may populate the scope master runs restore.
# Bot actors are excluded on top: their runs must never promote binaries into
# the shared scope even if they somehow target the default branch.
if: >-
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push' &&
github.event.workflow_run.head_branch == 'master' &&
github.event.workflow_run.head_repository.full_name == github.repository &&
!endsWith(github.event.workflow_run.actor.login, '[bot]') &&
!endsWith(github.event.workflow_run.triggering_actor.login, '[bot]')
runs-on: ubuntu-slim # https://github.com/actions/runner-images/blob/main/images/ubuntu-slim/ubuntu-slim-Readme.md
# workflow_run is a low-trust trigger, so cache access is read-only by default even
# though this run resolves to the default branch. Assert below that this took effect,
# because a save under read-only access only warns and would import nothing silently.
cache-mode: write
outputs:
entries: ${{ steps.scan.outputs.entries }}
steps:
- name: "Download new vcpkg entries"
uses: actions/download-artifact@v8
with:
pattern: vcpkg-new-*
path: incoming
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: "Assert write access"
shell: bash
run: |
if [[ $ACTIONS_CACHE_MODE != *write* ]]; then
echo "::error::expected a write-capable cache, got '$ACTIONS_CACHE_MODE'"
exit 1
fi
- name: "List cache entries"
id: scan
shell: bash
working-directory: incoming
run: |
entries="$(find . -name keys.txt -type f -exec sh -c '
for manifest; do
artifact="$(basename "$(dirname "$manifest")")"
while read -r key path; do
printf "{\"artifact\": \"%s\", \"key\": \"%s\", \"path\": \"%s\"}\n" \
"$artifact" "$key" "$path"
done <"$manifest"
done
' _ {} +)"
[[ -n $entries ]] || exit 0
# Compact, because a multi line value cannot be written to GITHUB_OUTPUT
entries="$(jq -cs '.' <<<"$entries")"
echo "entries=$entries" >>"$GITHUB_OUTPUT"
echo "::notice::importing $(jq length <<<"$entries") vcpkg entries"
save:
name: "Save ${{ matrix.entry.key }}"
needs: list
if: needs.list.outputs.entries
runs-on: ubuntu-slim
cache-mode: write
strategy:
# One job per archive, they run in parallel and each only uploads a single file
fail-fast: false
max-parallel: 20
matrix:
entry: ${{ fromJSON(needs.list.outputs.entries) }}
steps:
- name: "Download new vcpkg entries"
uses: actions/download-artifact@v8
with:
pattern: ${{ matrix.entry.artifact }}
path: incoming
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: "Save vcpkg entry"
uses: actions/cache/save@v6
with:
# The payload is a Windows or macOS archive while the upload runs on Linux, and
# without this the entry would be stamped as a Linux cache that neither of them
# is allowed to restore.
enableCrossOsArchive: true
key: ${{ matrix.entry.key }}
path: incoming/${{ matrix.entry.path }}

View file

@ -0,0 +1,16 @@
# Integration Docs: https://help.transifex.com/en/articles/6265125-github-installation-and-configuration#h_de29ef5cec
# Config Parameter: https://help.transifex.com/en/articles/6265125-github-installation-and-configuration#h_a734c3e219
# Supported Files: https://help.transifex.com/en/collections/3519116-file-formats
filters:
- filter_type: file
file_format: QT
source_language: en
source_file: cockatrice/cockatrice_en@source.ts
translation_files_expression: cockatrice/translations/cockatrice_<lang>.ts
- filter_type: file
file_format: QT
source_language: en
source_file: oracle/oracle_en@source.ts
translation_files_expression: oracle/translations/oracle_<lang>.ts

View file

@ -90,7 +90,7 @@ endif()
# A project name is needed for CPack
# Version can be overriden by git tags, see cmake/getversion.cmake
project("Cockatrice" VERSION 3.1.2)
project("Cockatrice" VERSION 3.2.0)
# Set release name if not provided via env/cmake var
if(NOT DEFINED GIT_TAG_RELEASENAME)
@ -220,6 +220,7 @@ endif()
# when ENABLE_SANITIZERS or ENABLE_COVERAGE is on.
include(Sanitizers)
include(Coverage)
include(CrashReporting)
# GNU systems need to define the Mersenne exponent for the RNG to compile w/o warning
if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")
@ -249,7 +250,22 @@ message(STATUS "Update Translations: ${UPDATE_TRANSLATIONS}")
include(FindQtRuntime)
# Reject APIs that were deprecated before the oldest Qt release we support
# (6.4). Qt 6.5 renamed the switch, so use whichever spelling the found
# version understands while still compiling everywhere.
if(Qt6_VERSION VERSION_GREATER_EQUAL 6.5)
add_compile_definitions(QT_DISABLE_DEPRECATED_UP_TO=0x060400)
else()
add_compile_definitions(QT_DISABLE_DEPRECATED_BEFORE=0x060400)
endif()
# Set once here for every target: these variables are inherited by the
# subdirectories, so the per-directory copies were redundant. Only the
# variables are set; qt_standard_project_setup would also change the runtime
# output directories and the install RPATH.
set(CMAKE_AUTOMOC TRUE)
set(CMAKE_AUTOUIC TRUE)
set(CMAKE_AUTORCC TRUE)
# Find other needed libraries
find_package(Protobuf CONFIG)
@ -327,7 +343,7 @@ if(UNIX)
endif()
elseif(WIN32)
set(CPACK_GENERATOR NSIS ${CPACK_GENERATOR})
if("${CMAKE_GENERATOR_PLATFORM}" MATCHES "(x64)")
if(CMAKE_SIZEOF_VOID_P EQUAL 8)
set(TRICE_IS_64_BIT 1)
else()
set(TRICE_IS_64_BIT 0)
@ -336,6 +352,15 @@ elseif(WIN32)
# Configure file with custom definitions for NSIS.
configure_file("${COCKATRICE_CMAKE_PATH}/NSIS.definitions.nsh.in" "${PROJECT_BINARY_DIR}/NSIS.definitions.nsh")
# Installer-log sender for the AbortInstall Sentry hook (values parsed out of COCKATRICE_SENTRY_DSN in
# cmake/CrashReporting.cmake). Generated only when a DSN was parsed: cmake/NSIS.template.in embeds it under
# the same CPACK_SENTRY_ENABLED check, so a build without reporting never references or carries the helper.
if(CPACK_SENTRY_ENABLED)
configure_file(
"${COCKATRICE_CMAKE_PATH}/SendInstallerLog.ps1.in" "${PROJECT_BINARY_DIR}/SendInstallerLog.ps1" @ONLY
)
endif()
# Every runtime DLL a Windows install has to contain, as paths relative to the
# install root. .ci/compile.sh reads this and fails the build when the packaged
# installer is missing one of them, or carries a runtime it does not know about.
@ -410,6 +435,9 @@ if(WITH_ORACLE OR WITH_CLIENT)
add_subdirectory(${CMAKE_SOURCE_DIR}/libcockatrice_models ${CMAKE_BINARY_DIR}/libcockatrice_models)
add_subdirectory(${CMAKE_SOURCE_DIR}/libcockatrice_filters ${CMAKE_BINARY_DIR}/libcockatrice_filters)
endif()
if(WITH_CLIENT)
add_subdirectory(${CMAKE_SOURCE_DIR}/libcockatrice_game ${CMAKE_BINARY_DIR}/libcockatrice_game)
endif()
if(WITH_SERVER)
add_subdirectory(servatrice)
@ -421,6 +449,13 @@ if(WITH_CLIENT)
set(CPACK_INSTALL_CMAKE_PROJECTS "Cockatrice;Cockatrice;ALL;/" ${CPACK_INSTALL_CMAKE_PROJECTS})
endif()
if(WIN32 AND WITH_CLIENT)
# Runs from the installer's $PLUGINSDIR when a purge fails, and is never installed
# (see LogProcessesUsingInstallDir in cmake/NSIS.template.in), so it has no install
# rules and is not part of the client's staged tree.
add_subdirectory(${CMAKE_SOURCE_DIR}/tools/installer-holder-probe ${CMAKE_BINARY_DIR}/tools/installer-holder-probe)
endif()
if(WITH_ORACLE)
add_subdirectory(oracle)
set(CPACK_INSTALL_CMAKE_PROJECTS "Oracle;Oracle;ALL;/" ${CPACK_INSTALL_CMAKE_PROJECTS})
@ -433,5 +468,5 @@ endif()
if(Qt6_FOUND AND Qt6_VERSION_MINOR GREATER_EQUAL 3)
# Qt6.3+ requires project finalization to support translations
qt6_finalize_project()
qt_finalize_project()
endif()

View file

@ -15,9 +15,10 @@
---
<br><pre><p align='center'>
<b>To get started with Cockatrice &#8674; [view our webpage](https://cockatrice.github.io/)</b><br>
<b>To get support, or suggest changes to the app &#8674; [file an issue](https://github.com/Cockatrice/Cockatrice/issues) ([How?](https://github.com/Cockatrice/Cockatrice/wiki/How-to-Create-a-GitHub-Ticket-Regarding-Cockatrice))</b>
<b>To help with development &#8674; learn [how to contribute](#contribute-)</b>
<b>To get started with Cockatrice &#8674; [view our webpage](https://cockatrice.github.io/)</b>
<b>To get support, or suggest changes to the app &#8674; [file an issue](https://github.com/Cockatrice/Cockatrice/issues) ([How?](https://github.com/Cockatrice/Cockatrice/wiki/How-to-Create-a-GitHub-Ticket-Regarding-Cockatrice))</b><br>
<b>To learn about the app and its code &#8674; [read our code documentation](https://cockatrice.github.io/docs/)</b>
<b>To help with development &#8674; learn [how to contribute](#contribute)</b>
</pre><p><br>
@ -83,7 +84,7 @@ Join our [Discord community](https://discord.gg/3Z9yzmA) to connect with other p
### Code
To contribute code to the project, please review our [guidelines](https://github.com/Cockatrice/Cockatrice/blob/master/.github/CONTRIBUTING.md) first.<br>
To commit code to the project, please review our [Contribution Guidelines](https://github.com/Cockatrice/Cockatrice/blob/master/.github/CONTRIBUTING.md) first.<br>
We maintain two tags for contributors to easier find issues to potentially work on:
- [![Good first issue](https://img.shields.io/github/issues/cockatrice/cockatrice/Good%20First%20Issue)](https://github.com/Cockatrice/Cockatrice/issues?utf8=%E2%9C%93&q=is%3Aopen%20is%3Aissue%20label%3A%22Good%20first%20issue%22%20)<br>
Issues tagged in this way provide a simple way to get started. They don't require much experience to be worked on.
@ -95,7 +96,7 @@ We'll happily advice on how best to implement a feature, or we can show you wher
You can also have a look at our `Todo List` in our [Code Documentation](https://cockatrice.github.io/docs) or search the repo for [`\todo` comments](https://github.com/search?q=repo%3ACockatrice%2FCockatrice%20%5Ctodo&type=code).
### Documentation [![CI Docs](https://github.com/Cockatrice/Cockatrice/actions/workflows/documentation-build.yml/badge.svg?event=push)](https://github.com/Cockatrice/Cockatrice/actions/workflows/documentation-build.yml?query=event%3Apush)
### Documentation [![CI Docs](https://github.com/Cockatrice/Cockatrice/actions/workflows/documentation-build.yml/badge.svg?event=push)](https://github.com/Cockatrice/Cockatrice/actions/workflows/documentation-build.yml?query=event%3Arelease+event%3Aworkflow_dispatch)
There are various places where useful information for different needs are maintained:
- [Official Code Documentation](https://cockatrice.github.io/docs/)
@ -104,7 +105,8 @@ There are various places where useful information for different needs are mainta
- [Official README](https://github.com/Cockatrice/Cockatrice/blob/master/README.md) `This file`
- [Official CONTRIBUTING](https://github.com/Cockatrice/Cockatrice/blob/master/.github/CONTRIBUTING.md)
Cockatrice tries to use the [Google Developer Documentation Style Guide](https://developers.google.com/style/) to ensure consistent documentation. We encourage you to improve the documentation by suggesting edits based on this guide.
Cockatrice tries to use the [Google Developer Documentation Style Guide](https://developers.google.com/style/) to ensure consistent documentation.
We encourage everybody to improve the documentation by suggesting edits based on this guide.
### Translation [![Transifex Project](https://img.shields.io/badge/translate-on%20transifex-brightgreen)](https://explore.transifex.com/cockatrice/cockatrice/)

212
cmake/CrashReporting.cmake Normal file
View file

@ -0,0 +1,212 @@
# Crash reporting via sentry-native (Sentry protocol, MIT).
# Included once from the root CMakeLists.txt, right after the sanitizer/coverage helpers.
#
# Inputs:
# WITH_CRASH_REPORTING -- build the client with the sentry-native SDK (default OFF, our release
# CI passes ON; packagers and from-source builds stay SDK-free until this
# ships something real, so offline package builds never fetch the SDK)
# COCKATRICE_SENTRY_DSN -- Sentry DSN baked into the build (default empty = SDK init skipped at runtime)
# COCKATRICE_SENTRY_TAG -- sentry-native release tag to vendor via FetchContent
# ENABLE_SANITIZERS -- forces the sentry backend to "none" (crashpad vs ASan conflict)
# Defines: cockatrice_target_enable_crash_reporting(<target>)
option(WITH_CRASH_REPORTING "Build with crash reporting via sentry-native" OFF)
set(COCKATRICE_SENTRY_DSN
""
CACHE STRING "Sentry DSN baked into the client; empty disables crash-report upload at runtime"
)
# Pinned stable release; tag verified at https://github.com/getsentry/sentry-native/tags.
set(COCKATRICE_SENTRY_TAG
"0.17.3"
CACHE STRING "sentry-native release tag to vendor via FetchContent"
)
# Wire the SDK (or the no-op fallback) into a client target. Always callable: with the
# option OFF the compile definitions below make crash_reporter.cpp compile to no-ops.
function(cockatrice_target_enable_crash_reporting _target)
if(WITH_CRASH_REPORTING AND TARGET sentry::sentry)
target_link_libraries(${_target} PRIVATE sentry::sentry)
target_compile_definitions(${_target} PRIVATE COCKATRICE_WITH_CRASH_REPORTING=1)
else()
target_compile_definitions(${_target} PRIVATE COCKATRICE_WITH_CRASH_REPORTING=0)
endif()
# Always baked in (possibly empty): main() prefers the COCKATRICE_SENTRY_DSN env var at runtime.
target_compile_definitions(${_target} PRIVATE "COCKATRICE_SENTRY_DSN=\"${COCKATRICE_SENTRY_DSN}\"")
# Sentry release for events from this target: PROJECT_VERSION (getversion.cmake ran before this
# file was included). Deliberately not PROJECT_VERSION_FRIENDLY / VERSION_STRING — that carries
# a build date, so every build of the same version would land in a different Sentry release.
# PROJECT_VERSION matches what the installer reports in its own log and what CPack derives the
# package version from, so client and installer events group under one release.
target_compile_definitions(${_target} PRIVATE "COCKATRICE_SENTRY_RELEASE=\"${PROJECT_VERSION}\"")
endfunction()
if(WITH_CRASH_REPORTING AND WITH_CLIENT)
include(FetchContent)
if(ENABLE_SANITIZERS)
# The crash handler cannot run under ASan/UBSan, so there is no backend to capture with.
set(_sentry_backend "none")
else()
# Breakpad on every OS (increment 3 probe, 2026-10-14): the crashpad sources
# do vendor friction-free (FetchContent inits all submodules recursively by
# default, verified populated in a local build tree), but the crashpad backend
# would need its out-of-process crashpad_handler binary installed next to the
# exe, into the macOS bundle, and through NSIS, plus handler-path wiring and
# notarization care. Breakpad is in-process, needs no extra binary and no
# packaging on any OS sentry-native builds, so simplicity wins.
set(_sentry_backend "breakpad")
endif()
# FORCE so toggling ENABLE_SANITIZERS on a reused build dir cannot leave a stale backend behind.
set(SENTRY_BACKEND
"${_sentry_backend}"
CACHE STRING "Crash handler backend for sentry-native" FORCE
)
unset(_sentry_backend)
if(NOT MSVC)
# Symbol upload needs debug info in Release binaries: the top-level flags build GCC
# Release as -s -O2 (stripped, no DWARF) and Clang/Apple Release as plain -O2 (no DWARF),
# both of which upload nothing useful. Keep -g and drop the strip flag while the SDK is
# on; the OFF default leaves local, distro and Debug builds untouched. MSVC already
# links /DEBUG with PDBs in release (see top-level CMakeLists.txt), so it needs nothing.
string(REGEX REPLACE "(^| )-s( |$)" "\\1\\2" CMAKE_CXX_FLAGS_RELEASE "${CMAKE_CXX_FLAGS_RELEASE}")
if(NOT CMAKE_CXX_FLAGS_RELEASE MATCHES "(^| )-g( |$)")
set(CMAKE_CXX_FLAGS_RELEASE "${CMAKE_CXX_FLAGS_RELEASE} -g")
endif()
endif()
# No Qt integration: it runs find_package(Qt6) inside the vendored build, which
# cannot see our thin/aqt Qt layouts (macOS CI) under foreign toolchains, and its
# breadcrumbs could carry unredacted log lines into native crash events that bypass
# before_send. Our attached redacted log preview already carries the log context.
set(SENTRY_INTEGRATION_QT
OFF
CACHE BOOL "Qt log-message breadcrumbs for sentry-native (off: discovery + secret surface)" FORCE
)
# The transport stays at the SDK default (curl on Apple/Linux, winhttp on Windows).
set(SENTRY_BUILD_TESTS
OFF
CACHE BOOL "Build sentry-native tests" FORCE
)
set(SENTRY_BUILD_EXAMPLES
OFF
CACHE BOOL "Build sentry-native examples" FORCE
)
fetchcontent_declare(
sentry
GIT_REPOSITORY https://github.com/getsentry/sentry-native.git
GIT_TAG ${COCKATRICE_SENTRY_TAG}
GIT_SHALLOW TRUE
)
fetchcontent_makeavailable(sentry)
# Vendored sentry-native (and its breakpad/crashpad trees) warn under our -Wall -Wextra
# (e.g. breakpad's -Wmissing-field-initializers), and WARNING_AS_ERROR would fail the build
# on code we cannot fix. Those findings are not actionable for us, so opt the whole vendored
# tree out of warnings-as-errors while keeping the warnings themselves visible; our own
# targets keep -Werror. Interface libraries compile nothing, so they are skipped outright:
# an INTERFACE -Wno-error would propagate to our consumers and weaken their build.
function(_cockatrice_sentry_opt_out_of_werror _dir)
get_property(
_sentry_targets
DIRECTORY "${_dir}"
PROPERTY BUILDSYSTEM_TARGETS
)
foreach(_sentry_target IN LISTS _sentry_targets)
get_target_property(_sentry_type ${_sentry_target} TYPE)
if(_sentry_type STREQUAL "INTERFACE_LIBRARY")
continue()
elseif(_sentry_type MATCHES "LIBRARY|EXECUTABLE")
get_target_property(_sentry_imported ${_sentry_target} IMPORTED)
if(NOT _sentry_imported)
target_compile_options(${_sentry_target} PRIVATE -Wno-error)
endif()
endif()
endforeach()
get_property(
_sentry_subdirs
DIRECTORY "${_dir}"
PROPERTY SUBDIRECTORIES
)
foreach(_sentry_subdir IN LISTS _sentry_subdirs)
_cockatrice_sentry_opt_out_of_werror("${_sentry_subdir}")
endforeach()
endfunction()
if(NOT MSVC)
_cockatrice_sentry_opt_out_of_werror("${sentry_SOURCE_DIR}")
endif()
message(STATUS "Crash reporting: sentry-native ${COCKATRICE_SENTRY_TAG} (backend: ${SENTRY_BACKEND})")
else()
message(STATUS "Crash reporting: SDK not vendored "
"(WITH_CRASH_REPORTING=${WITH_CRASH_REPORTING}, WITH_CLIENT=${WITH_CLIENT})"
)
endif()
# Packaged release binaries must not ship the DWARF that the symbol upload needs: it is
# 80-90% of an unstripped binary and nobody downloads a 500 MB installer to get it. The
# build tree keeps -g (CI uploads symbols from there); the *packaged copy* is split at
# install time by a CPack install script, which extracts the debug info next to the staging
# dir and strips the binary. Only ELF (Linux/BSD) needs this: Windows links /DEBUG with
# separate PDBs, and macOS keeps its dSYM extracted from the bundle.
option(COCKATRICE_SPLIT_DEBUG_SYMBOLS "Strip DWARF out of packaged release binaries" ON)
set(COCKATRICE_DEBUG_SYMBOL_DIR
"${CMAKE_BINARY_DIR}/debug-symbols"
CACHE PATH "Where the packaged binaries' split debug information is written (kept out of the package)"
)
if(WITH_CRASH_REPORTING
AND WITH_CLIENT
AND COCKATRICE_SPLIT_DEBUG_SYMBOLS
AND CMAKE_BUILD_TYPE STREQUAL "Release"
AND UNIX
AND NOT APPLE
)
find_program(COCKATRICE_OBJCOPY NAMES objcopy)
find_program(COCKATRICE_STRIP_TOOL NAMES strip)
if(COCKATRICE_OBJCOPY AND COCKATRICE_STRIP_TOOL)
configure_file(
"${CMAKE_CURRENT_LIST_DIR}/SplitDebugSymbols.cmake.in" "${CMAKE_BINARY_DIR}/cmake/SplitDebugSymbols.cmake" @ONLY
)
# PRE_BUILD runs after CPack stages the install tree and before the package is produced.
# CPACK_INSTALL_SCRIPTS would run too early (staging is still empty); the script walks
# CPACK_TEMPORARY_DIRECTORY, never CMAKE_INSTALL_PREFIX, and refuses to run otherwise.
list(APPEND CPACK_PRE_BUILD_SCRIPTS "${CMAKE_BINARY_DIR}/cmake/SplitDebugSymbols.cmake")
message(STATUS "Crash reporting: packaged binaries stripped, debug info split to ${COCKATRICE_DEBUG_SYMBOL_DIR}")
else()
message(WARNING "COCKATRICE_SPLIT_DEBUG_SYMBOLS is ON but objcopy/strip were not found; "
"packaged release binaries will keep their debug information"
)
endif()
endif()
# Installer failure reporting (increment 4): parse COCKATRICE_SENTRY_DSN into the parts the Windows installer
# needs. These are CPACK_* so CPack snapshots them at include(CPack) time and substitutes them into
# cmake/NSIS.template.in at package time; on non-Windows they are inert (only the NSIS template consumes them).
# Set from COCKATRICE_SENTRY_DSN on every configure, so a changed or removed DSN can never leave a stale value
# behind. Plain variables, not CACHE: they only need to be in scope when include(CPack) later snapshots every
# CPACK_* variable into CPackConfig.cmake.
set(CPACK_SENTRY_ENABLED "0")
set(CPACK_SENTRY_KEY "")
set(CPACK_SENTRY_ENVELOPE_URL "")
set(CPACK_SENTRY_DSN_FULL "")
# Deliberately PROJECT_VERSION: that is what the client bakes as its Sentry release and what CI passes to
# `sentry-cli debug-files upload`, so installer and client events group under one release. It is not the
# CPACK_PACKAGE_VERSION the installer logs in its own log line — on Development tags PROJECT_VERSION carries a
# "-<label>" suffix (getversion.cmake) while CPACK_PACKAGE_VERSION is only MAJOR.MINOR.PATCH.
set(CPACK_SENTRY_RELEASE "${PROJECT_VERSION}")
# Canonical <scheme>://<key>@<host>/<project-id>. Deliberately unanchored, so a trailing slash still matches.
# A self-hosted DSN with a path prefix (e.g. GlitchTip behind a subpath) does not match and falls through to the
# disabled-and-warn branch instead of deriving a wrong endpoint from the tail of the path.
if(COCKATRICE_SENTRY_DSN MATCHES "(https?)://([^@]+)@([^/]+)/([0-9]+)")
set(CPACK_SENTRY_ENABLED "1")
set(CPACK_SENTRY_KEY "${CMAKE_MATCH_2}")
# Scheme comes from the DSN, not an assumption: self-hosted (GlitchTip) DSNs may be plain http.
set(CPACK_SENTRY_ENVELOPE_URL "${CMAKE_MATCH_1}://${CMAKE_MATCH_3}/api/${CMAKE_MATCH_4}/envelope/")
set(CPACK_SENTRY_DSN_FULL "${COCKATRICE_SENTRY_DSN}")
elseif(NOT COCKATRICE_SENTRY_DSN STREQUAL "")
message(WARNING "COCKATRICE_SENTRY_DSN is set but malformed "
"(expected <scheme>://<key>@<host>/<project-id>); installer log reporting disabled"
)
endif()

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,146 @@
# Sends the NSIS installer log to Sentry as an envelope carrying one error
# event plus the log as an attachment.
#
# Generated at configure time from this template by CMake configure_file: the
# CPACK_SENTRY_* placeholders are baked in then, so the real values only ever
# exist in the build directory and the packaged installer, never in the repo.
# Runs on stock Windows PowerShell 5.1 with no extra modules.
param(
[Parameter(Mandatory = $true)][string]$LogPath
)
$ErrorActionPreference = 'Stop'
$EnvelopeUrl = '@CPACK_SENTRY_ENVELOPE_URL@'
$SentryKey = '@CPACK_SENTRY_KEY@'
$Release = '@CPACK_SENTRY_RELEASE@'
$Dsn = '@CPACK_SENTRY_DSN_FULL@'
try {
# The envelope endpoint is TLS-only; stock powershell.exe on older systems
# still negotiates TLS 1.0 otherwise, so opt into 1.2 explicitly.
try {
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
} catch {
}
# --- Log payload, tail-capped at 128 KiB ---------------------------------
$MaxLogBytes = 128 * 1024
$TruncNotice = '[log truncated to last 128 KB]'
if (Test-Path -LiteralPath $LogPath) {
$raw = [System.IO.File]::ReadAllBytes($LogPath)
} else {
$raw = [System.Text.Encoding]::UTF8.GetBytes('(installer log not found: ' + $LogPath + ')')
}
if ($raw.Length -gt $MaxLogBytes) {
$tail = New-Object byte[] $MaxLogBytes
[System.Buffer]::BlockCopy($raw, $raw.Length - $MaxLogBytes, $tail, 0, $MaxLogBytes)
$prefix = [System.Text.Encoding]::UTF8.GetBytes($TruncNotice + "`r`n")
$logBytes = New-Object byte[] ($prefix.Length + $tail.Length)
[System.Buffer]::BlockCopy($prefix, 0, $logBytes, 0, $prefix.Length)
[System.Buffer]::BlockCopy($tail, 0, $logBytes, $prefix.Length, $tail.Length)
} else {
$logBytes = $raw
}
# --- Abort reason, read from the log itself --------------------------------
# AbortInstall logs "ABORT: ..." first, so the reason travels inside the file
# and nothing reason-shaped crosses the command line (no quoting hazards).
# Select-String yields the matching line alone, so the reason is already one
# physical line for the message; anything else stays in the attached log.
# Non-ASCII paths may garble here; the attachment is byte-exact regardless.
$Reason = 'unknown (no ABORT line in log)'
if (Test-Path -LiteralPath $LogPath) {
$abortLine = Select-String -LiteralPath $LogPath -Pattern '^ABORT: ' | Select-Object -Last 1
if ($abortLine) {
$Reason = ($abortLine.Line -replace '^ABORT:\s*', '')
}
}
# --- What was holding the files, read from the log as well -------------------
# The guard and the close attempt each log one line naming every holder and what
# happened to it. Carrying the last of each as event data keeps an abort caused by
# a running instance actionable without opening the attachment. Kept out of the
# message so the issue still groups on the reason alone.
$Holders = ''
$CloseResult = ''
$EndResult = ''
if (Test-Path -LiteralPath $LogPath) {
$holderLine = Select-String -LiteralPath $LogPath -Pattern 'guard: still in use by (.*)$' | Select-Object -Last 1
if ($holderLine) {
$Holders = $holderLine.Matches[0].Groups[1].Value
}
$closeLine = Select-String -LiteralPath $LogPath -Pattern '(close-holders: .*)$' | Select-Object -Last 1
if ($closeLine) {
$CloseResult = $closeLine.Matches[0].Groups[1].Value
}
$endLine = Select-String -LiteralPath $LogPath -Pattern '(end-holders: .*)$' | Select-Object -Last 1
if ($endLine) {
$EndResult = $endLine.Matches[0].Groups[1].Value
}
}
# --- Event ---------------------------------------------------------------
$eventId = [guid]::NewGuid().ToString('N')
$now = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
# $Event is an automatic variable (Microsoft.PowerShell.Utility\Register-ObjectEvent),
# so the local is named $sentryEvent to avoid shadowing it.
$sentryEvent = [ordered]@{
event_id = $eventId
timestamp = $now
platform = 'other'
level = 'error'
logger = 'cockatrice-installer'
release = $Release
environment = 'installer'
sdk = @{ name = 'cockatrice.installer'; version = $Release }
message = @{ formatted = 'Installer aborted: ' + $Reason }
extra = @{
reason = $Reason
os = [System.Environment]::OSVersion.VersionString
holders = $Holders
close_result = $CloseResult
end_result = $EndResult
}
}
$eventJson = $sentryEvent | ConvertTo-Json -Compress -Depth 6
$eventBytes = [System.Text.Encoding]::UTF8.GetBytes($eventJson)
# --- Envelope, assembled as bytes (the log is opaque binary) -------------
$sentAt = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
$headerLine = '{"event_id":"' + $eventId + '","sent_at":"' + $sentAt + '","dsn":"' + $Dsn + '"}'
$eventHeader = '{"type":"event","length":' + $eventBytes.Length + '}'
$attachHeader = '{"type":"attachment","filename":"Cockatrice-installer.log","content_type":"text/plain","length":' + $logBytes.Length + '}'
$enc = [System.Text.Encoding]::UTF8
$lf = [byte[]]@(10)
$body = New-Object System.IO.MemoryStream
foreach ($chunk in @($enc.GetBytes($headerLine), $lf, $enc.GetBytes($eventHeader), $lf, $eventBytes, $lf, $enc.GetBytes($attachHeader), $lf, $logBytes)) {
$body.Write($chunk, 0, $chunk.Length)
}
$bodyBytes = $body.ToArray()
$body.Close()
# --- Send ----------------------------------------------------------------
# The key travels in the auth header only; stdout below never contains it.
$auth = 'Sentry sentry_version=7, sentry_client=cockatrice.installer/' + $Release + ', sentry_key=' + $SentryKey
Invoke-RestMethod -Uri $EnvelopeUrl -Method Post -TimeoutSec 45 -ContentType 'application/x-sentry-envelope' -Headers @{ 'X-Sentry-Auth' = $auth } -Body $bodyBytes | Out-Null
Write-Output ('SENT ' + $eventId)
exit 0
} catch {
# Scrub the key/DSN out of the failure text in case a transport error echoes them.
$msg = $_.Exception.Message
if ($msg -and $SentryKey) {
$msg = $msg.Replace($SentryKey, '<redacted>')
}
if ($msg -and $Dsn) {
$msg = $msg.Replace($Dsn, '<redacted>')
}
# Flatten to one line: this text is echoed into the installer log, which is
# itself uploaded as an attachment, so a multi-line transport error must not
# be able to forge extra lines in the artifact we send.
if ($msg) {
$msg = ($msg -replace '\s+', ' ').Trim()
}
Write-Output ('FAILED: ' + $msg)
exit 1
}

View file

@ -0,0 +1,80 @@
# Splits DWARF out of the packaged ELF binaries, then strips them.
#
# This is a CPack pre-build script (CPACK_PRE_BUILD_SCRIPTS, wired in CrashReporting.cmake):
# CPack runs it after staging the install tree and before producing the package, so it only
# ever touches the copy that becomes the release artifact. The build tree is left alone: -g
# stays on, and CI uploads symbols to Sentry straight from build/.
#
# For every ELF file under CPACK_TEMPORARY_DIRECTORY the script extracts the debug information
# to COCKATRICE_DEBUG_SYMBOL_DIR and removes the debug sections from the packaged binary.
# COCKATRICE_DEBUG_SYMBOL_DIR is deliberately outside the staging tree, so sidecars never end
# up in the package; CI can archive/upload them if wanted (it normally uploads straight from
# the build tree). The only thing that must survive stripping is .eh_frame (allocatable, used
# for unwinding), which neither --only-keep-debug nor --strip-debug removes.
#
# Tool paths, the symbol directory and the tree to walk are baked in / provided at configure /
# run time; the guards below refuse to run on anything that is not a CPack staging directory,
# so a missing variable can never turn the glob into a filesystem-wide walk.
set(_objcopy "@COCKATRICE_OBJCOPY@")
set(_strip "@COCKATRICE_STRIP_TOOL@")
set(_symbolDir "@COCKATRICE_DEBUG_SYMBOL_DIR@")
if(_objcopy STREQUAL "" OR _strip STREQUAL "")
message(WARNING "split debug symbols: objcopy/strip not found at configure time, packaging unstripped binaries")
return()
endif()
if(_symbolDir STREQUAL "")
message(FATAL_ERROR "split debug symbols: COCKATRICE_DEBUG_SYMBOL_DIR is empty")
endif()
# CPack sets CPACK_TEMPORARY_DIRECTORY to the staging tree; CMAKE_INSTALL_PREFIX is still the
# configured /usr prefix here, so globbing it would walk system directories. Refuse to run
# unless the path exists and really is a CPack staging tree.
if(NOT CPACK_TEMPORARY_DIRECTORY OR NOT IS_DIRECTORY "${CPACK_TEMPORARY_DIRECTORY}")
message(STATUS "split debug symbols: no CPack staging directory, skipping")
return()
endif()
if(NOT CPACK_TEMPORARY_DIRECTORY MATCHES "_CPack_Packages")
message(WARNING "split debug symbols: ${CPACK_TEMPORARY_DIRECTORY} is not a CPack staging tree, skipping")
return()
endif()
file(MAKE_DIRECTORY "${_symbolDir}")
file(
GLOB_RECURSE _entries
LIST_DIRECTORIES true
"${CPACK_TEMPORARY_DIRECTORY}/*"
)
set(_processed 0)
foreach(_file IN LISTS _entries)
if(IS_DIRECTORY "${_file}" OR IS_SYMLINK "${_file}")
continue()
endif()
# Only ELF: objcopy would fail on the icons, translations and Qt resources next to it.
file(
READ "${_file}" _magic
OFFSET 0
LIMIT 4
HEX
)
if(NOT _magic STREQUAL "7f454c46")
continue()
endif()
get_filename_component(_name "${_file}" NAME)
set(_debug "${_symbolDir}/${_name}.debug")
execute_process(COMMAND "${_objcopy}" --only-keep-debug "${_file}" "${_debug}" RESULT_VARIABLE _rc)
if(NOT _rc EQUAL 0)
message(WARNING "split debug symbols: objcopy --only-keep-debug failed for ${_file}")
continue()
endif()
execute_process(COMMAND "${_strip}" --strip-debug --strip-unneeded "${_file}" RESULT_VARIABLE _rc)
if(NOT _rc EQUAL 0)
message(WARNING "split debug symbols: strip failed for ${_file}")
continue()
endif()
math(EXPR _processed "${_processed} + 1")
endforeach()
message(STATUS "Split debug symbols for ${_processed} packaged ELF file(s); sidecars in ${_symbolDir}")

View file

@ -15,6 +15,7 @@ set(cockatrice_SOURCES
src/client/network/update/client/client_update_checker.cpp
src/client/network/update/client/release_channel.cpp
src/client/network/update/card_spoiler/spoiler_background_updater.cpp
src/client/crash/crash_reporter.cpp
src/client/latency_graph_widget.cpp
src/client/latency_status_widget.cpp
src/client/lag_monitor.cpp
@ -24,12 +25,11 @@ set(cockatrice_SOURCES
src/client/settings/shortcut_treeview.cpp
src/client/settings/shortcuts_settings.cpp
src/interface/deck_loader/card_node_function.cpp
src/interface/deck_loader/deck_file_format.cpp
src/interface/deck_loader/deck_loader.cpp
src/interface/deck_loader/loaded_deck.cpp
src/interface/widgets/dialogs/dlg_connect.cpp
src/interface/widgets/dialogs/dlg_convert_deck_to_cod_format.cpp
src/interface/widgets/dialogs/dlg_create_game.cpp
src/interface/widgets/dialogs/dlg_crash_report.cpp
src/interface/widgets/dialogs/dlg_default_tags_editor.cpp
src/interface/widgets/dialogs/dlg_edit_avatar.cpp
src/interface/widgets/dialogs/dlg_edit_password.cpp
@ -60,6 +60,7 @@ set(cockatrice_SOURCES
src/interface/widgets/dialogs/dlg_view_log.cpp
src/interface/widgets/dialogs/override_printing_warning.cpp
src/interface/widgets/dialogs/path_safety_warning.cpp
src/interface/widgets/dialogs/settings_view.cpp
src/interface/widgets/dialogs/tip_of_the_day.cpp
src/interface/widgets/deck_share/deck_share_utils.cpp
src/interface/widgets/deck_share/shared_deck_preview_widget.cpp
@ -68,36 +69,27 @@ set(cockatrice_SOURCES
src/filters/filter_builder.cpp
src/filters/filter_tree_model.cpp
src/filters/syntax_help.cpp
src/game/abstract_game.cpp
src/game/arrow_registry.cpp
src/game_graphics/board/abstract_card_drag_item.cpp
src/game_graphics/board/abstract_card_item.cpp
src/game_graphics/board/abstract_counter.cpp
src/game/board/arrow_data.cpp
src/game_graphics/board/arrow_item.cpp
src/game_graphics/board/arrow_registry.cpp
src/game_graphics/board/arrow_target.cpp
src/game_graphics/board/card_drag_item.cpp
src/game_graphics/board/card_item.cpp
src/game/board/card_list.cpp
src/game/board/card_state.cpp
src/game_graphics/board/counter_general.cpp
src/game/board/counter_state.cpp
src/game_graphics/board/translate_counter_name.cpp
src/game_graphics/deckview/deck_view.cpp
src/game_graphics/deckview/deck_view_container.cpp
src/game_graphics/deckview/tabbed_deck_view_container.cpp
src/game_graphics/dialogs/dlg_annotation.cpp
src/game_graphics/dialogs/dlg_create_token.cpp
src/game_graphics/dialogs/dlg_move_top_cards_until.cpp
src/game_graphics/dialogs/dlg_roll_dice.cpp
src/game/game.cpp
src/game/game_event_handler.cpp
src/game/game_meta_info.cpp
src/game_graphics/game_scene.cpp
src/game/game_state.cpp
src/game_graphics/game_view.cpp
src/game_graphics/hand_counter.cpp
src/game_graphics/log/message_log_widget.cpp
src/game/phase.cpp
src/game_graphics/phases_toolbar.cpp
src/game_graphics/player/menu/card_menu.cpp
src/game_graphics/player/menu/custom_zone_menu.cpp
@ -115,23 +107,11 @@ set(cockatrice_SOURCES
src/game_graphics/tally/stats_tally.cpp
src/game_graphics/tally/subtype_tally.cpp
src/game_graphics/tally/tally.cpp
src/game/player/player_actions.cpp
src/game_graphics/player/player_area.cpp
src/game_graphics/player/player_dialogs.cpp
src/game/player/player_event_handler.cpp
src/game_graphics/player/player_graphics_item.cpp
src/game/player/player_info.cpp
src/game_graphics/player/player_list_widget.cpp
src/game/player/player_logic.cpp
src/game/player/player_manager.cpp
src/game_graphics/player/player_target.cpp
src/game/replay.cpp
src/game/zones/card_zone_logic.cpp
src/game/zones/hand_zone_logic.cpp
src/game/zones/pile_zone_logic.cpp
src/game/zones/stack_zone_logic.cpp
src/game/zones/table_zone_logic.cpp
src/game/zones/view_zone_logic.cpp
src/game_graphics/zones/card_zone.cpp
src/game_graphics/zones/hand_zone.cpp
src/game_graphics/zones/pile_zone.cpp
@ -141,6 +121,8 @@ set(cockatrice_SOURCES
src/game_graphics/zones/view_zone.cpp
src/game_graphics/zones/view_zone_widget.cpp
src/game_graphics/board/abstract_graphics_item.cpp
src/interface/at_risk_data_warning.cpp
src/interface/at_risk_data_warning.h
src/interface/card_database_update/card_database_update_status_bar.cpp
src/interface/card_database_update/card_database_update_status_bar.h
src/interface/card_database_update/card_update_progress.cpp
@ -189,6 +171,7 @@ set(cockatrice_SOURCES
src/interface/widgets/cards/card_info_picture_widget.cpp
src/interface/widgets/cards/card_info_picture_with_text_overlay_widget.cpp
src/interface/widgets/cards/card_info_text_widget.cpp
src/interface/widgets/cards/card_picture_delegate.cpp
src/interface/widgets/cards/card_size_widget.cpp
src/interface/widgets/cards/deck_card_zone_display_widget.cpp
src/interface/widgets/cards/deck_preview_card_picture_widget.cpp
@ -308,6 +291,7 @@ set(cockatrice_SOURCES
src/interface/widgets/utility/sequence_edit.cpp
src/interface/widgets/utility/visibility_change_listener.cpp
src/interface/widgets/utility/visibility_change_listener.h
src/interface/widgets/visual_database_display/card_database_visual_view.cpp
src/interface/widgets/visual_database_display/visual_database_display_color_filter_widget.cpp
src/interface/widgets/visual_database_display/visual_database_display_filter_save_load_widget.cpp
src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.cpp
@ -340,6 +324,7 @@ set(cockatrice_SOURCES
src/interface/widgets/visual_deck_storage/visual_deck_storage_tag_filter_widget.cpp
src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp
src/interface/window_main.cpp
src/installer_close_server.cpp
src/main.cpp
src/single_instance_manager.cpp
src/interface/widgets/tabs/abstract_tab_deck_editor.cpp
@ -445,6 +430,7 @@ set(cockatrice_SOURCES
src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_budget_navigation_widget.h
src/interface/widgets/utility/compact_push_button.cpp
src/interface/widgets/utility/compact_push_button.h
src/installer_close_server.h
src/single_instance_manager.h
src/client/url_scheme_event_filter.h
src/interface/intents/intent_connect_to_server.cpp
@ -506,7 +492,7 @@ if(APPLE)
set(cockatrice_SOURCES ${cockatrice_SOURCES} ${CMAKE_CURRENT_SOURCE_DIR}/resources/appicon.icns)
endif(APPLE)
qt6_add_resources(cockatrice_RESOURCES_RCC ${cockatrice_RESOURCES})
qt_add_resources(cockatrice_RESOURCES_RCC ${cockatrice_RESOURCES})
# Declare path variables
set(ICONDIR
@ -527,7 +513,7 @@ set(COCKATRICE_MAC_QM_INSTALL_DIR "cockatrice.app/Contents/Resources/translation
set(COCKATRICE_UNIX_QM_INSTALL_DIR "share/cockatrice/translations")
set(COCKATRICE_WIN32_QM_INSTALL_DIR "translations")
qt6_add_executable(
qt_add_executable(
cockatrice
WIN32
MACOSX_BUNDLE
@ -539,7 +525,7 @@ qt6_add_executable(
target_precompile_headers(cockatrice PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtwidgets_pch.h")
qt6_add_shaders(
qt_add_shaders(
cockatrice
"onboarding_shaders"
PREFIX
@ -551,7 +537,7 @@ qt6_add_shaders(
src/interface/widgets/onboarding/shaders/brand_plate.frag
)
qt6_add_resources(
qt_add_resources(
cockatrice
"onboarding_qml"
PREFIX
@ -567,6 +553,7 @@ target_link_libraries(
PUBLIC libcockatrice_card
libcockatrice_deck_list
libcockatrice_filters
libcockatrice_game
libcockatrice_utility
libcockatrice_network
libcockatrice_models
@ -575,6 +562,8 @@ target_link_libraries(
${COCKATRICE_QT_MODULES}
)
cockatrice_target_enable_crash_reporting(cockatrice)
if(UNIX)
if(APPLE)
set(MACOSX_BUNDLE_INFO_STRING "${PROJECT_NAME}")
@ -781,7 +770,7 @@ endif()
if(Qt6LinguistTools_FOUND)
#Qt6 Translations happen after the executable is built up
if(UPDATE_TRANSLATIONS)
qt6_add_translations(
qt_add_translations(
cockatrice
TS_FILES
${cockatrice_TS}
@ -791,7 +780,7 @@ if(Qt6LinguistTools_FOUND)
cockatrice_QM
)
else()
qt6_add_translations(cockatrice TS_FILES ${cockatrice_TS} QM_FILES_OUTPUT_VARIABLE cockatrice_QM)
qt_add_translations(cockatrice TS_FILES ${cockatrice_TS} QM_FILES_OUTPUT_VARIABLE cockatrice_QM)
endif()
if(UNIX)
@ -817,4 +806,4 @@ if(Qt6LinguistTools_FOUND)
endforeach()
endif()
qt6_finalize_target(cockatrice)
qt_finalize_target(cockatrice)

View file

@ -0,0 +1,616 @@
#include "crash_reporter.h"
#if COCKATRICE_WITH_CRASH_REPORTING
#include <sentry.h>
#endif
#include <QByteArray>
#include <QCoreApplication>
#include <QDir>
#include <QFile>
#include <QJsonDocument>
#include <QJsonObject>
#include <QLockFile>
#include <QLoggingCategory>
#include <QRegularExpression>
#include <QThread>
#include <QUrl>
#include <cstring>
#include <libcockatrice/settings/personal_settings.h>
#include <string>
Q_LOGGING_CATEGORY(CrashReportLog, "crash_report");
#if COCKATRICE_WITH_CRASH_REPORTING
namespace
{
constexpr char CockatriceUrlScheme[] = "cockatrice://";
/**
* @brief Qt-free cockatrice:// secret scrubber for the before_send hook.
*
* before_send does not run in async-signal context on our backends (breakpad invokes it on the
* crashing thread outside the signal handler, and crashpad/native crashes bypass the hook
* entirely), but it still avoids QString/QRegularExpression so it stays safe to call from the
* crash-handler thread. Truncates each URL at the first terminator, keeping scheme://host like
* redactCockatriceUrls does. (std::string allocates, so this is not strictly signal-safe.)
*/
std::string scrubCockatriceUrls(const char *input)
{
std::string out;
const char *pos = input;
const char *scheme = CockatriceUrlScheme;
const size_t schemeLen = std::strlen(scheme);
while (*pos != '\0') {
const char *found = std::strstr(pos, scheme);
if (found == nullptr) {
out.append(pos);
break;
}
out.append(pos, found);
const char *hostEnd = found + schemeLen;
while (*hostEnd != '\0' && *hostEnd != '/' && *hostEnd != '?' && *hostEnd != '#' && *hostEnd != ' ' &&
*hostEnd != '\t' && *hostEnd != '\n' && *hostEnd != '\'' && *hostEnd != '"' && *hostEnd != '<' &&
*hostEnd != '>') {
++hostEnd;
}
out.append(found, hostEnd);
pos = hostEnd;
}
return out;
}
void scrubStringValue(sentry_value_t holder, const char *key)
{
sentry_value_t current = sentry_value_get_by_key(holder, key);
if (sentry_value_get_type(current) != SENTRY_VALUE_TYPE_STRING) {
return;
}
const char *raw = sentry_value_as_string(current);
if (raw == nullptr || std::strstr(raw, CockatriceUrlScheme) == nullptr) {
return;
}
sentry_value_set_by_key(holder, key, sentry_value_new_string(scrubCockatriceUrls(raw).c_str()));
}
/**
* @brief Scrub trivially reachable string values inside a breadcrumb data object.
*/
int scrubDataEntry(const char *key, sentry_value_t value, void *userdata)
{
if (sentry_value_get_type(value) != SENTRY_VALUE_TYPE_STRING) {
return 0;
}
const char *raw = sentry_value_as_string(value);
if (raw == nullptr || std::strstr(raw, CockatriceUrlScheme) == nullptr) {
return 0;
}
auto *holder = static_cast<sentry_value_t *>(userdata);
sentry_value_set_by_key(*holder, key, sentry_value_new_string(scrubCockatriceUrls(raw).c_str()));
return 0;
}
void scrubDataObject(sentry_value_t data)
{
if (sentry_value_get_type(data) != SENTRY_VALUE_TYPE_OBJECT) {
return;
}
sentry_value_foreach_key_value(data, scrubDataEntry, &data);
}
void scrubCrumbList(sentry_value_t crumbs)
{
const size_t count = sentry_value_get_length(crumbs);
for (size_t i = 0; i < count; ++i) {
sentry_value_t crumb = sentry_value_get_by_index(crumbs, i);
if (sentry_value_get_type(crumb) != SENTRY_VALUE_TYPE_OBJECT) {
continue;
}
scrubStringValue(crumb, "message");
scrubDataObject(sentry_value_get_by_key(crumb, "data"));
}
}
void scrubBreadcrumbs(sentry_value_t event)
{
// The SDK merges scope breadcrumbs onto the event as a plain list (sentry__value_merge_breadcrumbs
// in sentry_scope.c); the { "values": [...] } envelope shape only exists on the wire. Accept both
// so the scrubbing runs on the actual merged form.
sentry_value_t breadcrumbs = sentry_value_get_by_key(event, "breadcrumbs");
if (sentry_value_get_type(breadcrumbs) == SENTRY_VALUE_TYPE_LIST) {
scrubCrumbList(breadcrumbs);
return;
}
if (sentry_value_get_type(breadcrumbs) == SENTRY_VALUE_TYPE_OBJECT) {
sentry_value_t values = sentry_value_get_by_key(breadcrumbs, "values");
if (sentry_value_get_type(values) == SENTRY_VALUE_TYPE_LIST) {
scrubCrumbList(values);
}
}
}
/**
* @brief before_send hook: scrub cockatrice:// secrets from the event.
*
* Defense in depth — payloads we build are pre-redacted. No breadcrumbs are recorded today (the Qt
* integration stays off), so this currently covers the top-level message and logentry.formatted,
* plus any breadcrumb messages/data that reach the merged event in the future. Native crash
* minidumps bypass this hook regardless of backend: breakpad captures them in-process without
* invoking before_send, and fast-fail crashes on Windows skip the local handler, so before_send is
* never invoked for those.
*/
sentry_value_t scrubEventBeforeSend(sentry_value_t event, sentry_hint_t *hint, void *userData)
{
Q_UNUSED(hint);
Q_UNUSED(userData);
scrubStringValue(event, "message");
sentry_value_t logentry = sentry_value_get_by_key(event, "logentry");
if (sentry_value_get_type(logentry) == SENTRY_VALUE_TYPE_OBJECT) {
scrubStringValue(logentry, "formatted");
}
scrubBreadcrumbs(event);
return event;
}
} // namespace
#endif
namespace
{
/**
* @brief DSN matcher shared by isDsnWellFormed() and describeDsn().
*
* Kept unanchored on purpose, mirroring the MATCHES in cmake/CrashReporting.cmake (CMake searches
* rather than anchoring), so the client never rejects a DSN the build itself baked in. Compiled once
* because QRegularExpression construction is not free and both helpers are called per pending
* envelope.
*/
const QRegularExpression &dsnPattern()
{
static const QRegularExpression pattern(QStringLiteral("(https?)://([^@]+)@([^/]+)/([0-9]+)"));
return pattern;
}
} // namespace
bool CrashReporter::isDsnWellFormed(const QString &dsn)
{
return dsnPattern().match(dsn).hasMatch();
}
QString CrashReporter::describeDsn(const QString &dsn)
{
const QRegularExpressionMatch match = dsnPattern().match(dsn);
if (!match.hasMatch()) {
return QString();
}
// Host and project id only: the key in group 2 must never reach a log line.
return QStringLiteral("%1 (project %2)").arg(match.captured(3), match.captured(4));
}
QString CrashReporter::describeOriginMismatch(const QString &envelopePath, const QString &currentDsn)
{
const QString destination = describeDsn(currentDsn);
if (destination.isEmpty()) {
return QString();
}
QFile file(envelopePath);
if (!file.open(QIODevice::ReadOnly)) {
return QString();
}
// The envelope header is the first line and is small; the cap only keeps a corrupt file from
// being read in full.
const QByteArray headerLine = file.readLine(4096).trimmed();
if (headerLine.isEmpty() || !headerLine.startsWith('{')) {
return QString();
}
const QString originDsn = QJsonDocument::fromJson(headerLine).object().value(QStringLiteral("dsn")).toString();
const QString origin = describeDsn(originDsn);
// Equal destinations are not a mismatch: a different key for the same host and project still
// uploads where the crash was captured.
if (origin.isEmpty() || origin == destination) {
return QString();
}
return QStringLiteral("was captured for %1 but this session uploads with %2").arg(origin, destination);
}
bool CrashReporter::isReportingEnabled(const QString &dsn, PersonalSettings::CrashReportingMode consentMode)
{
return !dsn.isEmpty() && consentMode != PersonalSettings::CrashReportingMode::NeverSend;
}
void CrashReporter::init(const QString &dsn,
const QString &release,
const QString &databasePath,
PersonalSettings::CrashReportingMode consentMode)
{
shutdown();
sentryDatabasePath = databasePath;
if (!isReportingEnabled(dsn, consentMode)) {
if (!dsn.isEmpty()) {
// A deliberate choice rather than a misconfiguration, so it is not a warning. The
// no-DSN case is reported by the caller, which is the only place that knows whether
// the environment or the build supplied the DSN.
qCInfo(CrashReportLog) << "crash reporting disabled by preference (Never send)";
}
return;
}
// Only created when the SDK will actually run: no-DSN launches stay side-effect free.
#if COCKATRICE_WITH_CRASH_REPORTING
// A crash captured by an earlier run is uploaded by THIS session's transport, which posts every
// envelope with the DSN it was armed with (sentry-native keeps the DSN in the transport state,
// and envelopes restored from disk are raw, so their own "dsn" header is never read). When the
// two disagree the report lands in a project nobody chose, which no other log line reveals.
const QStringList pendingEnvelopes = pendingEnvelopePaths(sentryDatabasePath);
for (const QString &envelopePath : pendingEnvelopes) {
const QString mismatch = describeOriginMismatch(envelopePath, dsn);
if (!mismatch.isEmpty()) {
qCWarning(CrashReportLog) << "pending crash envelope" << envelopePath << mismatch;
}
}
qCInfo(CrashReportLog) << "crash reporting armed for" << describeDsn(dsn);
QDir().mkpath(sentryDatabasePath);
if (consentMode != PersonalSettings::CrashReportingMode::AlwaysSend) {
// sentry-native persists consent to <database>/user-consent and reloads it in sentry_init
// before old runs are processed, so a stale "given" from an earlier AlwaysSend run (or an
// earlier Send in Ask mode) would upload the next crash before the dialog asks. Our
// CrashReportingMode is the source of truth, so clear the persisted state first; asking
// afterwards re-establishes it via giveConsent()/revokeConsent().
QFile::remove(sentryDatabasePath + QStringLiteral("/user-consent"));
}
sentry_options_t *options = sentry_options_new();
sentry_options_set_dsn(options, dsn.toUtf8().constData());
sentry_options_set_release(options, release.toUtf8().constData());
sentry_options_set_database_path(options, sentryDatabasePath.toUtf8().constData());
// Consent-gated uploads: crashes are captured into the local database but
// nothing leaves the machine until giveConsent(). cache_keep holds the previous run's
// crash envelope in <database>/cache instead of dropping it at init, so the Ask dialog
// decides its fate: giveConsent() flushes it via the transport retry, revokeConsent()
// deletes it below. This also covers the first crash after switching to AlwaysSend,
// which giveConsent() (running after init) would otherwise miss. http_retry is required
// for the flush: without it the held envelope is written with a plain filename the retry
// scanner skips, and giveConsent()'s sentry_transport_retry() sends nothing.
sentry_options_set_require_user_consent(options, 1);
sentry_options_set_cache_keep(options, SENTRY_CACHE_KEEP_OFFLINE);
sentry_options_set_http_retry(options, 1);
sentry_options_set_before_send(options, scrubEventBeforeSend, nullptr);
ready = (sentry_init(options) == 0);
#else
Q_UNUSED(release);
// Not a misconfiguration like an empty DSN: this build cannot report whatever the DSN says, so
// a tester forcing a crash needs to know the difference.
qCInfo(CrashReportLog) << "crash reporting unavailable: built without Sentry support";
ready = false;
#endif
}
bool CrashReporter::isReady() const
{
return ready;
}
void CrashReporter::shutdown()
{
#if COCKATRICE_WITH_CRASH_REPORTING
if (ready) {
sentry_close();
}
#endif
ready = false;
}
CrashReporter::~CrashReporter() = default;
CrashReporter::CrashReporter() = default;
bool CrashReporter::beginSession(const QString &dir)
{
// Restartable: releasing first keeps begin->end->begin round-trips clean.
endSession();
QDir().mkpath(dir);
const QDir sessionDir(dir);
const QString selfName = QStringLiteral("session-%1").arg(QCoreApplication::applicationPid());
bool unclean = false;
const QStringList leftovers = sessionDir.entryList({QStringLiteral("session-*")}, QDir::Files);
for (const QString &name : leftovers) {
if (name == selfName) {
continue;
}
const QString path = sessionDir.filePath(name);
QLockFile probe(path);
// Long-lived session lock: stale means "owner dead", regardless of file age, so a
// crash followed by an immediate relaunch (younger than the 30 s default) is still
// detected. tryLock() only deletes the file when it finds it stale, so a live
// concurrent instance's lock is never touched here. (The static
// QLockFile::removeStaleLockFile(path) form does not exist in Qt5/Qt6, and the
// member removeStaleLockFile() force-removes without a staleness check, so neither
// is usable for this scan.)
probe.setStaleLockTime(0);
if (probe.tryLock(0)) {
unclean = true;
probe.unlock(); // deletes the stale file
continue;
}
// tryLock failed: either a live concurrent instance owns the file, or it is
// unreadable (garbage/empty) and therefore has no live owner. Only the latter is
// cleaned up; a live instance is left alone. On Unix QLockFile creates the file
// before writing the pid, so a second instance starting at the same moment can
// briefly observe an empty (or half-written) file for a live owner: re-read once
// after a beat so a live owner's fresh lock is never mistaken for garbage and deleted.
qint64 pid = 0;
QString hostname, appname;
if (!probe.getLockInfo(&pid, &hostname, &appname)) {
QThread::msleep(100);
if (!probe.getLockInfo(&pid, &hostname, &appname)) {
QFile::remove(path);
unclean = true;
}
}
}
sessionLockPath = sessionDir.filePath(selfName);
sessionLock = std::make_unique<QLockFile>(sessionLockPath);
sessionLock->setStaleLockTime(0);
if (!sessionLock->tryLock(0)) {
qCWarning(CrashReportLog) << "Failed to claim session lock at" << sessionLockPath;
}
return unclean;
}
void CrashReporter::endSession()
{
if (sessionLock) {
// unlock() removes the lock file; nothing left to clean up.
sessionLock->unlock();
sessionLock.reset();
} else if (!sessionLockPath.isEmpty()) {
// The lock was never claimed (a live concurrent instance owns its file): only our
// own unclaimed file, if tryLock left one behind, is removed.
QFile::remove(sessionLockPath);
}
sessionLockPath.clear();
}
void CrashReporter::giveConsent()
{
#if COCKATRICE_WITH_CRASH_REPORTING
if (ready) {
sentry_user_consent_give();
}
#endif
}
void CrashReporter::revokeConsent()
{
#if COCKATRICE_WITH_CRASH_REPORTING
// Revoke (not reset): an explicit refusal blocks uploads; reset would
// return to unknown, which blocks uploads too but loses the decision.
if (ready) {
sentry_user_consent_revoke();
// Drop the held crash envelope: without this it would sit in <database>/cache and
// upload on the next give (e.g. after switching to AlwaysSend), after the user
// explicitly refused it. The SDK recreates the directory as needed.
QDir(sentryDatabasePath + QStringLiteral("/cache")).removeRecursively();
}
#endif
}
bool CrashReporter::sendReport(const QString &redactedPreview, const QString &userNote)
{
#if COCKATRICE_WITH_CRASH_REPORTING
if (!ready) {
return false;
}
// Local scope (not the global one): the log preview rides only on this event. A global
// sentry_attach_bytes would linger and ride along on every later event in the session,
// including a real native crash later on — and with the crashpad backend byte attachments
// are written to disk for exactly that. In-memory bytes also mean no report files land in
// the Sentry DB dir, so there is no disk growth and no same-second filename collision.
sentry_scope_t *scope = sentry_scope_new();
if (scope == nullptr) {
qCWarning(CrashReportLog) << "Could not create scope for crash report";
return false;
}
const QByteArray previewBytes = redactedPreview.toUtf8();
const sentry_uuid_t attachmentId = sentry_scope_attach_bytes(
scope, previewBytes.constData(), static_cast<size_t>(previewBytes.size()), "crash-log.txt");
const bool attachOk = sentry_uuid_is_nil(&attachmentId) == 0;
if (!attachOk) {
qCWarning(CrashReportLog) << "Could not attach crash log to crash report";
}
bool noteAttachOk = true;
if (!userNote.isEmpty()) {
const QByteArray noteBytes = userNote.toUtf8();
const sentry_uuid_t noteId = sentry_scope_attach_bytes(scope, noteBytes.constData(),
static_cast<size_t>(noteBytes.size()), "user-note.txt");
noteAttachOk = sentry_uuid_is_nil(&noteId) == 0;
if (!noteAttachOk) {
qCWarning(CrashReportLog) << "Could not attach user note to crash report";
}
}
// Release is attached automatically from the init options. Deliberately no
// sentry_set_user call: the report must carry no PII. The dialog collects
// no contact info, so the message event + attachments are the whole payload
// (sentry_value_new_feedback/sentry_capture_feedback exist but stay unused).
sentry_value_t event = sentry_value_new_message_event(SENTRY_LEVEL_ERROR, "crash-report", "Crash report");
const sentry_uuid_t eventId = sentry_scope_capture_event(scope, event, nullptr);
sentry_scope_free(scope);
if (sentry_uuid_is_nil(&eventId)) {
qCWarning(CrashReportLog) << "Failed to capture crash report event";
return false;
}
char eventIdStr[37] = {};
sentry_uuid_as_string(&eventId, eventIdStr);
// Capture only queues the envelope to the background transport: a network failure stays
// invisible here, so true means "queued", not "delivered". The Save fallback is offered only
// when the local capture itself fails.
qCInfo(CrashReportLog) << "Crash report queued, event id:" << eventIdStr;
// A failed attachment still captures the event above, but reports failure
// so callers offer the local-save fallback instead of silent success.
return attachOk && noteAttachOk;
#else
Q_UNUSED(redactedPreview);
Q_UNUSED(userNote);
return false;
#endif
}
bool CrashReporter::appendAttachmentToEnvelope(const QString &envelopePath,
const QString &filename,
const QByteArray &payload)
{
// QIODevice::ReadWrite creates a missing file; appending to a file that was never
// written by the SDK would fabricate an envelope, so require it to exist.
if (!QFile::exists(envelopePath)) {
return false;
}
QFile file(envelopePath);
if (!file.open(QIODevice::ReadWrite)) {
return false;
}
// Item boundaries are single newlines: make sure the header starts on a fresh line.
if (file.size() > 0) {
if (!file.seek(file.size() - 1)) {
return false;
}
char lastByte = 0;
if (file.read(&lastByte, 1) != 1) {
return false;
}
if (lastByte != '\n' && file.write("\n", 1) != 1) {
return false;
}
}
// Item shape mirrors sentry__envelope_add_attachment in the SDK. `length` is what the
// SDK's envelope re-parser uses to frame the payload (a missing length ends the item at
// the next newline, which would truncate a multi-line log and make the rest of the file
// fail to parse); `attachment_length` is the logical attachment size the SDK also emits.
// The filename is a compile-time ASCII constant from the callers, so no JSON escaping is
// needed.
const QByteArray header = QByteArrayLiteral("{\"type\":\"attachment\",\"length\":") +
QByteArray::number(payload.size()) + QByteArrayLiteral(",\"attachment_length\":") +
QByteArray::number(payload.size()) + QByteArrayLiteral(",\"filename\":\"") +
filename.toUtf8() + QByteArrayLiteral("\",\"content_type\":\"text/plain\"}") + '\n';
if (file.write(header) != header.size()) {
return false;
}
return file.write(payload) == payload.size();
}
QStringList CrashReporter::pendingEnvelopePaths(const QString &databasePath)
{
static const auto isUuid = [](const QString &id) { return id.size() == 36 && id.count(QLatin1Char('-')) == 4; };
static const auto isEnvelopeName = [](const QString &name) {
if (name == QStringLiteral("__sentry-crash.envelope")) {
return false;
}
const QString stem = name.left(name.size() - 9); // strip ".envelope"
if (isUuid(stem)) {
return true;
}
// Retry cache filename: "<ts>-<count>-<uuid>".
const qsizetype firstSep = stem.indexOf(QLatin1Char('-'));
const qsizetype secondSep = firstSep > 0 ? stem.indexOf(QLatin1Char('-'), firstSep + 1) : -1;
return firstSep > 0 && secondSep > 0 && isUuid(stem.mid(secondSep + 1)) && stem.left(firstSep).toLongLong() > 0;
};
QStringList result;
const QDir dbDir(databasePath);
if (!dbDir.exists()) {
return result;
}
// Consent-held envelopes (Ask mode: the SDK cached them at crash time instead
// of sending, see sentry__submit_envelope). Bare "<uuid>.envelope" with
// cache_keep only, "<ts>-<count>-<uuid>.envelope" with http_retry.
const QDir cacheDir(databasePath + QStringLiteral("/cache"));
const QStringList cacheNames = cacheDir.entryList({QStringLiteral("*.envelope")}, QDir::Files, QDir::Name);
for (const QString &name : cacheNames) {
if (isEnvelopeName(name)) {
result.append(cacheDir.filePath(name));
}
}
// In-flight envelopes dumped to the crashed run's own directory (AlwaysSend
// mode: consent was already given, so the transport dump wrote them there and
// sentry_init's process_old_runs replays them at startup — attach before init).
const QStringList runDirs =
dbDir.entryList({QStringLiteral("*.run")}, QDir::Dirs | QDir::NoDotAndDotDot, QDir::Name);
for (const QString &runDir : runDirs) {
const QDir dir(dbDir.filePath(runDir));
const QStringList names = dir.entryList({QStringLiteral("*.envelope")}, QDir::Files, QDir::Name);
for (const QString &name : names) {
if (isEnvelopeName(name)) {
result.append(dir.filePath(name));
}
}
}
return result;
}
int CrashReporter::attachToPendingCrashEnvelopes(const QString &redactedLogText, const QString &userNoteText)
{
return attachToPendingCrashEnvelopes(sentryDatabasePath, redactedLogText, userNoteText);
}
int CrashReporter::attachToPendingCrashEnvelopes(const QString &databasePath,
const QString &redactedLogText,
const QString &userNoteText)
{
if (databasePath.isEmpty()) {
return 0;
}
const QByteArray logBytes = redactedLogText.toUtf8();
const QByteArray noteBytes = userNoteText.toUtf8();
int attached = 0;
const QStringList envelopes = pendingEnvelopePaths(databasePath);
for (const QString &path : envelopes) {
bool ok = appendAttachmentToEnvelope(path, QStringLiteral("crash-log.txt"), logBytes);
if (ok && !noteBytes.isEmpty()) {
ok = appendAttachmentToEnvelope(path, QStringLiteral("user-note.txt"), noteBytes);
}
if (ok) {
++attached;
} else {
qCWarning(CrashReportLog) << "Failed to attach crash log to pending envelope" << path;
}
}
if (attached > 0) {
qCInfo(CrashReportLog) << "Attached crash log and user note to" << attached << "pending crash envelope(s)";
}
return attached;
}
QString CrashReporter::sanitizeLogLine(const QString &line)
{
static const auto colorEscapeCodePattern = QRegularExpression(QStringLiteral("\033\\[[0-9;]*m"));
QString sanitized = line;
sanitized.replace(colorEscapeCodePattern, "");
return sanitized;
}
QString CrashReporter::redactCockatriceUrls(const QString &line)
{
static auto urlPattern = QRegularExpression(QStringLiteral("cockatrice://\\S+"));
// Single pass over the original line: rescanning the replaced text would match a redacted
// secret-free URL again and loop forever.
QString redacted;
redacted.reserve(line.size());
qsizetype pos = 0;
QRegularExpressionMatchIterator it = urlPattern.globalMatch(line);
while (it.hasNext()) {
QRegularExpressionMatch match = it.next();
redacted.append(line.mid(pos, match.capturedStart() - pos));
// The pattern only matches cockatrice:// URLs, so the check is folded into the parse:
// scheme://host, secrets dropped.
const QUrl parsed(match.captured(0));
redacted.append(parsed.scheme() + QStringLiteral("://") + parsed.host());
pos = match.capturedEnd();
}
redacted.append(line.mid(pos));
return redacted;
}

View file

@ -0,0 +1,177 @@
/**
* @file crash_reporter.h
* @ingroup Core
*/
#ifndef CRASH_REPORTER_H
#define CRASH_REPORTER_H
#include <QByteArray>
#include <QLoggingCategory>
#include <QString>
#include <QStringList>
#include <libcockatrice/settings/personal_settings.h>
#include <memory>
#ifndef COCKATRICE_WITH_CRASH_REPORTING
#define COCKATRICE_WITH_CRASH_REPORTING 0
#endif
#ifndef COCKATRICE_SENTRY_DSN
#define COCKATRICE_SENTRY_DSN ""
#endif
#ifndef COCKATRICE_SENTRY_RELEASE
#define COCKATRICE_SENTRY_RELEASE ""
#endif
class QLockFile;
Q_DECLARE_LOGGING_CATEGORY(CrashReportLog);
/**
* @brief Sentry-backed crash-report facade, owned by main().
*
* An empty DSN (or Never consent) skips the SDK entirely, so the mock dialog
* fallback behaves exactly as before. All SDK calls are guarded so OFF builds
* compile to no-ops.
*
* @par Consent gating
* init() arms require-user-consent and before_send scrubbing. Stale persisted
* consent from a previous AlwaysSend run is cleared before init when the mode is
* not AlwaysSend, and cached crash envelopes are held for the dialog instead of
* being dropped. giveConsent()/revokeConsent() gate uploads.
*
* @par Envelope and log helpers
* attachToPendingCrashEnvelopes() folds the redacted log and the optional user
* note into the pending native crash event(s) before consent releases them, so
* dump, stack, log and note arrive as ONE Sentry event. sendReport() is the
* fallback for unclean exits that produced no native crash event, capturing a
* redacted message event with the log preview attached. The log redaction
* helpers are static and SDK-independent.
*
* @par Session tracking
* beginSession()/endSession() are SDK-independent: a QLockFile per process in
* the database dir detects unclean exits cross-platform, replacing the legacy
* Windows-only .dmp scan. The held lock makes the reporter non-copyable and
* non-movable.
*/
class CrashReporter
{
public:
CrashReporter();
CrashReporter(const CrashReporter &) = delete;
CrashReporter &operator=(const CrashReporter &) = delete;
CrashReporter(CrashReporter &&) = delete;
CrashReporter &operator=(CrashReporter &&) = delete;
~CrashReporter();
void init(const QString &dsn,
const QString &release,
const QString &databasePath,
PersonalSettings::CrashReportingMode consentMode);
[[nodiscard]] bool isReady() const;
void shutdown();
void giveConsent();
void revokeConsent();
bool sendReport(const QString &redactedPreview, const QString &userNote = QString());
/**
* @brief Append the redacted log (and optional user note) to every pending
* native crash envelope in the local database, so the crashed event uploads
* with dump, stack, log and note together instead of the log arriving as a
* separate event that has to be correlated by hand. Both storage locations
* are covered: consent-held envelopes in <database>/cache (Ask mode) and
* in-flight envelopes dumped into <database>/<run>.run (AlwaysSend mode).
*
* For the AlwaysSend variant run this BEFORE init() (static overload): the
* startup replay of *.run envelopes happens inside sentry_init. SDK-independent
* plain-file surgery on the Sentry envelope format, so it stays unit-testable
* in OFF builds.
*
* @return the number of envelopes that received the attachments.
*/
int attachToPendingCrashEnvelopes(const QString &redactedLogText, const QString &userNoteText);
static int attachToPendingCrashEnvelopes(const QString &databasePath,
const QString &redactedLogText,
const QString &userNoteText);
/**
* @brief Append one attachment item to a cached Sentry envelope file.
*
* An envelope is a newline-separated stream of item-header-line + payload
* pairs. The retry transport replays cached envelopes byte-for-byte
* (sentry-native 0.17.3 reads them raw and serializes raw envelopes back
* verbatim), so appending well-formed items associates them with the event
* already in the file; the first line (event id header) is never touched.
*
* @param filename ASCII-only item filename, embedded verbatim in the header.
*/
static bool
appendAttachmentToEnvelope(const QString &envelopePath, const QString &filename, const QByteArray &payload);
/**
* @brief Pending crash envelopes in <database>: the consent-held files in
* <database>/cache and the in-flight files in <database>/<run>.run.
*
* Matches the cache filename conventions sentry-native accepts (see
* sentry__parse_cache_filename): bare "<uuid>.envelope" and retry
* "<ts>-<count>-<uuid>.envelope"; run dirs accept plain "<uuid>.envelope"
* minus the SDK's own "__sentry-crash.envelope" marker.
*/
static QStringList pendingEnvelopePaths(const QString &databasePath);
/**
* @brief SDK-independent gating decision, unit-testable in OFF builds.
*/
static bool isReportingEnabled(const QString &dsn, PersonalSettings::CrashReportingMode consentMode);
/**
* @brief Whether a DSN has the shape the build and the SDK require:
* <scheme>://<key>@<host>/<project-id>.
*
* Deliberately the same unanchored pattern cmake/CrashReporting.cmake matches (CMake's MATCHES
* searches rather than anchoring), so a DSN this build accepted is never rejected here. The SDK
* refuses to even build a request for a DSN it considers invalid, so an unchecked typo disables
* uploading with no error anywhere.
*/
static bool isDsnWellFormed(const QString &dsn);
/**
* @brief Human-readable destination of a DSN, e.g. "o0.ingest.sentry.io (project 1234567)".
*
* Safe to log: the public key is never part of the result. Empty for a malformed DSN.
*/
static QString describeDsn(const QString &dsn);
/**
* @brief Describe the captured-for DSN of a pending envelope when it points at a different
* destination than the DSN this session would upload it with; empty when they agree.
*
* sentry-native posts every envelope with the DSN held in the transport state of the session
* doing the sending (envelopes restored from disk are raw and their own "dsn" header is never
* read), so a cached crash envelope captured for one project can silently be uploaded to
* another - the exact way a tester's report lands in the wrong place. Compares destinations
* (host and project id), not raw strings, so a different key for the same project stays quiet.
*/
static QString describeOriginMismatch(const QString &envelopePath, const QString &currentDsn);
/**
* @brief Claim this process's session lock, reporting a previous unclean exit.
* @return true if a leftover session lock from a dead process (or an unreadable
* garbage file) was found and cleaned; live concurrent instances are ignored.
*/
bool beginSession(const QString &dir);
void endSession();
static QString sanitizeLogLine(const QString &line);
static QString redactCockatriceUrls(const QString &line);
private:
bool ready = false;
QString sentryDatabasePath;
QString sessionLockPath;
std::unique_ptr<QLockFile> sessionLock;
};
#endif

View file

@ -14,6 +14,7 @@
#include <QObject>
#include <QPoint>
#include <QVBoxLayout>
#include <libcockatrice/utility/qt_utils.h>
#include <qnamespace.h>
LatencyStatusWidget::LatencyStatusWidget(QWidget *parent) : QWidget(parent)
@ -24,9 +25,7 @@ LatencyStatusWidget::LatencyStatusWidget(QWidget *parent) : QWidget(parent)
latencyGraph = new LatencyGraphWidget(this);
latencyGraph->setFixedSize(90, 14);
auto *layout = new QHBoxLayout(this);
layout->setContentsMargins(0, 0, 0, 0);
layout->setSpacing(4);
auto *layout = QtUtils::makeLayout<QHBoxLayout>(this, 4);
layout->addWidget(latencyGraph);
layout->addWidget(pingLabel);

View file

@ -3,6 +3,7 @@
#include "../../../interface/pixel_map_generator.h"
#include "../../settings/cache_settings.h"
#include "../interface/widgets/dialogs/dlg_connect.h"
#include "../interface/widgets/dialogs/dlg_edit_password.h"
#include "../interface/widgets/dialogs/dlg_forgot_password_challenge.h"
#include "../interface/widgets/dialogs/dlg_forgot_password_request.h"
#include "../interface/widgets/dialogs/dlg_forgot_password_reset.h"
@ -24,6 +25,7 @@
#include <libcockatrice/network/client/remote/remote_client.h>
#include <libcockatrice/protocol/pb/response.pb.h>
#include <libcockatrice/settings/servers_settings.h>
#include <libcockatrice/utility/qt_utils.h>
#include <qnamespace.h>
class QWidget;
@ -95,6 +97,13 @@ void ConnectionController::wireClientSignals()
connect(remoteClient, &RemoteClient::sigPromptForForgotPasswordChallenge, this,
&ConnectionController::onPromptForgotPasswordChallenge);
connect(remoteClient, &RemoteClient::sigPasswordVerifierReady, this,
&ConnectionController::onPasswordVerifierReady);
connect(remoteClient, &RemoteClient::sigPasswordChangeSuccess, this,
&ConnectionController::onPasswordChangeSuccess);
connect(remoteClient, &RemoteClient::sigPasswordChangeError, this, &ConnectionController::onPasswordChangeError);
}
void ConnectionController::connectToServer()
@ -103,16 +112,32 @@ void ConnectionController::connectToServer()
connect(dlgConnect, &DlgConnect::sigStartForgotPasswordRequest, this, &ConnectionController::forgotPasswordRequest);
if (dlgConnect->exec()) {
pendingSaveName = dlgConnect->getSaveName();
pendingSavePassword = dlgConnect->getSavePassword();
remoteClient->setStoredVerifier(dlgConnect->getStoredVerifier());
remoteClient->connectToServer(dlgConnect->getHost(), static_cast<unsigned int>(dlgConnect->getPort()),
dlgConnect->getPlayerName(), dlgConnect->getPassword());
}
}
void ConnectionController::onPasswordVerifierReady(const QString &verifier)
{
if (pendingSavePassword) {
SettingsCache::instance().servers().setServerPassword(pendingSaveName, verifier);
}
}
void ConnectionController::connectToServerDirect(const QString &host,
unsigned int port,
const QString &playerName,
const QString &password)
const QString &password,
const QString &storedVerifier,
const QString &saveName,
bool savePassword)
{
pendingSaveName = saveName;
pendingSavePassword = savePassword;
remoteClient->setStoredVerifier(storedVerifier);
remoteClient->connectToServer(host, port, playerName, password);
}
@ -224,21 +249,42 @@ void ConnectionController::onLoginError(int r,
{
switch (static_cast<Response::ResponseCode>(r)) {
case Response::RespClientUpdateRequired: {
QString formatted = "Missing Features: ";
for (int i = 0; i < missingFeatures.size(); ++i) {
formatted.append(QString("\n %1").arg(QChar(0x2022)) + " " + missingFeatures.value(i));
}
QMessageBox msgBox(dialogParent);
msgBox.setIcon(QMessageBox::Critical);
msgBox.setWindowTitle(tr("Failed Login"));
msgBox.setText(tr("Your client seems to be missing features this server requires for connection.") +
"\n\n" + tr("To update your client, go to 'Help -> Check for Client Updates'."));
msgBox.setDetailedText(formatted);
if (missingFeatures.isEmpty()) {
// Without a feature list this is either a login the client stopped itself or a
// strict server asking for a password; the reason string already says which and
// what to do. No client update can fix any of those causes, so show it instead of
// the update advice.
msgBox.setText(reasonStr.isEmpty() ? tr("The login did not complete.") : reasonStr);
} else {
QString formatted = tr("Missing Features:");
for (const QString &feature : missingFeatures) {
formatted.append(QString("\n %1 ").arg(QChar(0x2022)) + feature);
}
msgBox.setText(tr("Your client seems to be missing features this server requires for connection.") +
"\n\n" + tr("To update your client, go to 'Help -> Check for Client Updates'."));
msgBox.setDetailedText(formatted);
}
msgBox.exec();
break;
}
case Response::RespTooManyRequests: {
QString bannedStr = endTime ? tr("Too many login attempts. Try again after %1.")
.arg(QDateTime::fromSecsSinceEpoch(endTime).toString())
: tr("Too many login attempts. Please try again later.");
if (!reasonStr.isEmpty()) {
bannedStr.append("\n\n" + reasonStr);
}
QMessageBox::critical(dialogParent, tr("Error"), bannedStr);
break;
}
case Response::RespWrongPassword: {
QMessageBox::critical(dialogParent, tr("Error"),
tr("Incorrect username or password. "
@ -270,9 +316,8 @@ void ConnectionController::onLoginError(int r,
}
case Response::RespRegistrationRequired: {
if (QMessageBox::question(dialogParent, tr("Error"),
tr("This server requires user registration. Do you want to register now?"),
QMessageBox::Yes | QMessageBox::No) == QMessageBox::Yes) {
if (QtUtils::confirm(dialogParent, tr("Error"),
tr("This server requires user registration. Do you want to register now?"))) {
registerToServer();
}
return; // don't re-prompt connect
@ -311,10 +356,16 @@ void ConnectionController::onLoginError(int r,
}
case Response::RespPasswordChangeRequired: {
QMessageBox::information(
dialogParent, tr("Password Change Required"),
tr("An administrator has reset your password. Please contact your server administrator to obtain "
"your temporary password, then log in and change it via Account -> Change Password."));
// The credentials were right, but the account may not be used until a new password
// is chosen. Let the user complete the change here: sending them off to "ask the
// administrator for your temporary password" sends them in circles, since they just
// logged in with it and the session ends without a way to comply.
DlgEditPassword dlg(dialogParent);
dlg.setWindowTitle(tr("Password Change Required"));
if (dlg.exec()) {
remoteClient->changePasswordToServer(dlg.getOldPassword(), dlg.getNewPassword());
return; // onPasswordChangeSuccess/onPasswordChangeError decides the next step
}
remoteClient->disconnectFromServer();
return;
}
@ -365,12 +416,15 @@ void ConnectionController::onRegisterError(int r, QString reasonStr, quint32 end
break;
}
case Response::RespTooManyRequests: {
QMessageBox::critical(dialogParent, tr("Registration denied"),
tr("It appears you are attempting to register a new account on this server yet you "
"already have an account registered with the email provided. This server "
"restricts the number of accounts a user can register per address. Please "
"contact the server operator for further assistance or to obtain your "
"credential information."));
QString deniedStr =
endTime ? tr("Too many registration attempts. Try again after %1.")
.arg(QDateTime::fromSecsSinceEpoch(endTime).toString())
: tr("It appears you are attempting to register a new account on this server yet you "
"already have an account registered with the email provided. This server "
"restricts the number of accounts a user can register per address. Please "
"contact the server operator for further assistance or to obtain your "
"credential information.");
QMessageBox::critical(dialogParent, tr("Registration denied"), deniedStr);
break;
}
case Response::RespPasswordTooShort: {
@ -474,6 +528,43 @@ void ConnectionController::onNotifyUserAboutUpdate()
"To update your client, go to Help -> Check for Updates."));
}
void ConnectionController::onPasswordChangeSuccess()
{
// The credential saved for this profile is stale now: drop it so the next login asks for
// the new password instead of replaying the old verifier.
if (!pendingSaveName.isEmpty()) {
SettingsCache::instance().servers().setServerPassword(pendingSaveName, "");
}
QMessageBox::information(dialogParent, tr("Password Changed"),
tr("Your password has been changed. Please log in again with your new password."));
remoteClient->disconnectFromServer();
connectToServer();
}
void ConnectionController::onPasswordChangeError(int responseCode)
{
QString reason;
switch (static_cast<Response::ResponseCode>(responseCode)) {
case Response::RespWrongPassword:
reason = tr("The current password is incorrect.");
break;
case Response::RespPasswordTooShort:
reason = tr("The new password is too short.");
break;
case Response::RespClientUpdateRequired:
reason = tr("The server does not accept the new password format.");
break;
default:
reason = tr("The password could not be changed (error %1).").arg(responseCode);
break;
}
QMessageBox::critical(dialogParent, tr("Password Change Failed"), reason);
remoteClient->disconnectFromServer();
connectToServer();
}
void ConnectionController::onForgotPasswordSuccess()
{
QMessageBox::information(
@ -484,14 +575,38 @@ void ConnectionController::onForgotPasswordSuccess()
SettingsCache::instance().servers().setFPPlayerName("");
}
void ConnectionController::onForgotPasswordError()
void ConnectionController::onForgotPasswordError(int responseCode)
{
QMessageBox::warning(
dialogParent, tr("Reset Password"),
tr("Failed to reset user account password, please contact the server operator to reset your password."));
SettingsCache::instance().servers().setFPHostName("");
SettingsCache::instance().servers().setFPPort("");
SettingsCache::instance().servers().setFPPlayerName("");
// Rate limiting is retryable, so it must not clear the prefill.
bool keepPrefill = false;
QString message;
switch (static_cast<Response::ResponseCode>(responseCode)) {
case Response::RespTooManyRequests:
// Retryable: keep the host/port/user prefill so the user can try again. The window
// is server-configured (an hour by default), so do not promise a shorter wait.
keepPrefill = true;
message = tr("Too many password reset attempts from your address. Please wait before "
"requesting a new reset.");
break;
case Response::RespFunctionNotAllowed:
// Also returned for an unknown user, a banned user, and an expired token or
// challenge, so the message must not claim a single cause.
message = tr("The server did not accept the reset request. Check the user name, or contact "
"the server operator to reset your password.");
break;
default:
message = tr("Failed to reset user account password, please contact the server operator to reset your "
"password.");
break;
}
QMessageBox::warning(dialogParent, tr("Reset Password"), message);
if (!keepPrefill) {
SettingsCache::instance().servers().setFPHostName("");
SettingsCache::instance().servers().setFPPort("");
SettingsCache::instance().servers().setFPPlayerName("");
}
}
void ConnectionController::onPromptForgotPasswordReset()

View file

@ -40,8 +40,13 @@ public:
void registerToServer();
void forgotPasswordRequest();
void connectToServer();
void
connectToServerDirect(const QString &host, unsigned int port, const QString &playerName, const QString &password);
void connectToServerDirect(const QString &host,
unsigned int port,
const QString &playerName,
const QString &password,
const QString &storedVerifier = QString(),
const QString &saveName = QString(),
bool savePassword = false);
void disconnectFromServer();
void refreshWindowTitle()
@ -82,10 +87,17 @@ private slots:
// Forgot-password flow
void onForgotPasswordSuccess();
void onForgotPasswordError();
void onForgotPasswordError(int responseCode);
void onPromptForgotPasswordReset();
void onPromptForgotPasswordChallenge();
// Persists the derived scrypt verifier after a successful challenge-response login
void onPasswordVerifierReady(const QString &verifier);
// Completes the forced password change the server demanded at login
void onPasswordChangeSuccess();
void onPasswordChangeError(int responseCode);
private:
void wireClientSignals();
void updateWindowTitle();
@ -102,6 +114,10 @@ private:
// Kept as a member so the forgot-password signal can be wired to it
DlgConnect *dlgConnect{nullptr};
// Captured from the connect dialog when a connection is initiated
QString pendingSaveName;
bool pendingSavePassword{false};
};
#endif // COCKATRICE_REMOTE_CONNECTION_CONTROLLER_H

View file

@ -20,11 +20,12 @@
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/deck_list/deck_list.h>
#include <libcockatrice/deck_list/tree/deck_list_card_node.h>
#include <libcockatrice/network/network_manager.h>
#include <version_string.h>
DeckStatsInterface::DeckStatsInterface(QObject *parent) : QObject(parent)
{
manager = new QNetworkAccessManager(this);
manager = NetworkManagerFactory::create(this);
connect(manager, &QNetworkAccessManager::finished, this, &DeckStatsInterface::queryFinished);
}

View file

@ -26,11 +26,12 @@
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/deck_list/deck_list.h>
#include <libcockatrice/deck_list/tree/deck_list_card_node.h>
#include <libcockatrice/network/network_manager.h>
#include <version_string.h>
TappedOutInterface::TappedOutInterface(QObject *parent) : QObject(parent)
{
manager = new QNetworkAccessManager(this);
manager = NetworkManagerFactory::create(this);
connect(manager, &QNetworkAccessManager::finished, this, &TappedOutInterface::queryFinished);
}

View file

@ -27,6 +27,7 @@
#include <functional>
#include <libcockatrice/card/database/card_database.h>
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/network/network_manager.h>
#include <libcockatrice/settings/download_settings.h>
#include <libcockatrice/settings/paths_settings.h>
#include <tuple>
@ -56,7 +57,7 @@ void SpoilerBackgroundUpdater::startSpoilerDownloadProcess(QString url, bool sav
void SpoilerBackgroundUpdater::downloadFromURL(QUrl url, bool saveResults)
{
auto *nam = new QNetworkAccessManager(this);
auto *nam = NetworkManagerFactory::create(this);
auto request = QNetworkRequest(url);
request.setHeader(QNetworkRequest::UserAgentHeader, QString("Cockatrice %1").arg(VERSION_STRING));
QNetworkReply *reply = nam->get(request);

View file

@ -20,6 +20,7 @@
#include <QVariant>
#include <QVariantMap>
#include <QtGlobal>
#include <libcockatrice/network/network_manager.h>
#include <optional>
#include <qnamespace.h>
#include <qprocessordetection.h>
@ -62,7 +63,7 @@ std::optional<int> getProductVersionMajor()
} // namespace
ReleaseChannel::ReleaseChannel() : netMan(new QNetworkAccessManager(this)), response(nullptr), lastRelease(nullptr)
ReleaseChannel::ReleaseChannel() : netMan(NetworkManagerFactory::create(this)), response(nullptr), lastRelease(nullptr)
{
}

View file

@ -9,10 +9,11 @@
#include <QNetworkRequest>
#include <QUrl>
#include <QVariant>
#include <libcockatrice/network/network_manager.h>
UpdateDownloader::UpdateDownloader(QObject *parent) : QObject(parent), response(nullptr)
{
netMan = new QNetworkAccessManager(this);
netMan = NetworkManagerFactory::create(this);
}
void UpdateDownloader::beginDownload(QUrl downloadUrl)

View file

@ -19,7 +19,6 @@ inline Q_LOGGING_CATEGORY(SettingsCacheLog, "settings_cache");
class ReleaseChannel;
#define DEFAULT_LANG_NAME "English"
#define CLIENT_INFO_NOT_SET "notset"
#define DEFAULT_FONT_SIZE 12

View file

@ -2,13 +2,18 @@
#define COCKATRICE_SETTINGS_CARD_PREFERENCE_PROVIDER_H
#include "../../client/settings/cache_settings.h"
#include <QObject>
#include <libcockatrice/interfaces/interface_card_preference_provider.h>
#include <libcockatrice/settings/card_override_settings.h>
#include <libcockatrice/settings/cards_display_settings.h>
class SettingsCardPreferenceProvider : public ICardPreferenceProvider
class SettingsCardPreferenceProvider : public QObject, public ICardPreferenceProvider
{
public:
explicit SettingsCardPreferenceProvider(QObject *parent = nullptr) : QObject(parent)
{
}
[[nodiscard]] QString getCardPreferenceOverride(const QString &cardName) const override
{
return SettingsCache::instance().cardOverrides().getCardPreferenceOverride(cardName);

View file

@ -1,6 +1,5 @@
#include "deck_filter_string.h"
#include "../interface/deck_loader/loaded_deck.h"
#include "libcockatrice/card/card_localization.h"
#include "libcockatrice/card/database/card_database_querier.h"
#include "libcockatrice/deck_list/deck_list.h"
@ -15,6 +14,7 @@
#include <algorithm>
#include <any>
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/deck_list/loaded_deck.h>
#include <libcockatrice/filters/filter_string.h>
#include <libcockatrice/utility/peglib.h>
#include <mutex>

View file

@ -11,6 +11,7 @@
#include <QString>
#include <functional>
#include <libcockatrice/card/card_localization.h>
#include <libcockatrice/deck_list/loaded_deck.h>
struct CardSearchLanguage;
struct LoadedDeck;

View file

@ -13,6 +13,7 @@
#include <QVariant>
#include <QtGlobal>
#include <libcockatrice/filters/filter_card.h>
#include <libcockatrice/utility/qt_utils.h>
#include <stddef.h>
FilterBuilder::FilterBuilder(QWidget *parent) : QWidget(parent)
@ -39,9 +40,8 @@ FilterBuilder::FilterBuilder(QWidget *parent) : QWidget(parent)
edit->setClearButtonEnabled(true);
edit->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
QGridLayout *layout = new QGridLayout;
QGridLayout *layout = QtUtils::makeLayout<QGridLayout>();
layout->setObjectName("layout");
layout->setContentsMargins(0, 0, 0, 0);
layout->addWidget(typeCombo, 0, 0, 1, 2);
layout->addWidget(filterCombo, 0, 2, 1, 2);

View file

@ -11,6 +11,27 @@
class QObject;
namespace
{
/**
* @brief Visits every filter item below @p node, depth first.
*/
template <typename Callback> void forEachFilter(const FilterTreeNode *node, const Callback &callback)
{
if (node == nullptr) {
return;
}
if (const auto *filterItem = dynamic_cast<const FilterItem *>(node)) {
callback(filterItem);
}
for (int i = 0; i < node->childCount(); ++i) {
forEachFilter(node->nodeAt(i), callback);
}
}
} // namespace
FilterTreeModel::FilterTreeModel(QObject *parent) : QAbstractItemModel(parent)
{
fTree = new FilterTree;
@ -110,46 +131,31 @@ void FilterTreeModel::clearFiltersOfType(CardFilter::Attr filterType)
QList<const CardFilter *> FilterTreeModel::getFiltersOfType(CardFilter::Attr filterType) const
{
QList<const CardFilter *> filters;
if (!fTree) {
return filters;
}
std::function<void(const FilterTreeNode *)> traverse;
traverse = [&](const FilterTreeNode *node) {
if (const auto *filterItem = dynamic_cast<const FilterItem *>(node)) {
if (filterItem->attr() == filterType) {
QString text = filterItem->text();
filters.append(new CardFilter(text, filterItem->type(), filterItem->attr()));
}
forEachFilter(fTree, [&](const FilterItem *filterItem) {
if (filterItem->attr() == filterType) {
filters.append(new CardFilter(filterItem->text(), filterItem->type(), filterItem->attr()));
}
for (int i = 0; i < node->childCount(); ++i) {
traverse(node->nodeAt(i));
}
};
traverse(fTree);
});
return filters;
}
QStringList FilterTreeModel::getFilterTermsOfType(CardFilter::Attr filterType) const
{
QStringList terms;
forEachFilter(fTree, [&](const FilterItem *filterItem) {
if (filterItem->attr() == filterType) {
terms.append(filterItem->text());
}
});
return terms;
}
QList<const CardFilter *> FilterTreeModel::allFilters() const
{
QList<const CardFilter *> filters;
if (!fTree) {
return filters;
}
std::function<void(const FilterTreeNode *)> traverse;
traverse = [&](const FilterTreeNode *node) {
if (const auto *filterItem = dynamic_cast<const FilterItem *>(node)) {
QString text = filterItem->text();
filters.append(new CardFilter(text, filterItem->type(), filterItem->attr()));
}
for (int i = 0; i < node->childCount(); ++i) {
traverse(node->nodeAt(i));
}
};
traverse(fTree);
forEachFilter(fTree, [&](const FilterItem *filterItem) {
filters.append(new CardFilter(filterItem->text(), filterItem->type(), filterItem->attr()));
});
return filters;
}

View file

@ -28,7 +28,14 @@ public slots:
void addFilter(const CardFilter *f);
void removeFilter(const CardFilter *f);
void clearFiltersOfType(CardFilter::Attr filterType);
/**
* @brief Copies every active filter of one attribute into new CardFilter objects.
*
* The caller owns the returned filters. Prefer getFilterTermsOfType() when only
* the terms are needed.
*/
[[nodiscard]] QList<const CardFilter *> getFiltersOfType(CardFilter::Attr filterType) const;
/// @brief Copies every active filter into new CardFilter objects. The caller owns them.
[[nodiscard]] QList<const CardFilter *> allFilters() const;
private slots:
@ -48,6 +55,13 @@ public:
{
return fTree;
}
/**
* @brief The texts of every active filter of one attribute, without allocating filters.
*
* Prefer this over getFiltersOfType() when only the filter terms are needed.
*/
[[nodiscard]] QStringList getFilterTermsOfType(CardFilter::Attr filterType) const;
[[nodiscard]] int rowCount(const QModelIndex &parent = QModelIndex()) const override;
[[nodiscard]] int columnCount(const QModelIndex & /*parent*/ = QModelIndex()) const override;
[[nodiscard]] QVariant data(const QModelIndex &index, int role) const override;

View file

@ -1,113 +0,0 @@
#include "card_state.h"
#include <QSharedPointer>
void CardState::resetState(bool keepAnnotations)
{
attacking = false;
counters.clear();
pt.clear();
if (!keepAnnotations) {
annotation.clear();
}
attachedTo = nullptr;
}
void CardState::setZone(CardZoneLogic *_zone)
{
if (zone == _zone) {
return;
}
zone = _zone;
emit zoneChanged(this, zone);
emit stateChanged();
}
void CardState::setAttacking(bool _attacking)
{
if (attacking == _attacking) {
return;
}
attacking = _attacking;
emit attackingChanged(_attacking);
emit stateChanged();
}
void CardState::insertCounter(int id, int value)
{
counters.insert(id, value);
emit countersChanged(counters);
emit stateChanged();
}
void CardState::setCounter(int id, int value)
{
if (value) {
counters[id] = value;
} else {
counters.remove(id);
}
emit countersChanged(counters);
emit stateChanged();
}
void CardState::clearCounters()
{
counters.clear();
emit countersChanged(counters);
emit stateChanged();
}
void CardState::setAnnotation(const QString &_annotation)
{
if (annotation == _annotation) {
return;
}
annotation = _annotation;
emit annotationChanged(annotation);
emit stateChanged();
}
void CardState::setPT(const QString &_pt)
{
if (pt == _pt) {
return;
}
pt = _pt;
emit ptChanged(pt);
emit stateChanged();
}
void CardState::setDoesntUntap(bool _doesntUntap)
{
if (doesntUntap == _doesntUntap) {
return;
}
doesntUntap = _doesntUntap;
emit doesntUntapChanged(_doesntUntap);
emit stateChanged();
}
void CardState::setDestroyOnZoneChange(bool _destroyOnZoneChange)
{
if (destroyOnZoneChange == _destroyOnZoneChange) {
return;
}
destroyOnZoneChange = _destroyOnZoneChange;
emit destroyOnZoneChangeChanged(_destroyOnZoneChange);
emit stateChanged();
}
void CardState::setAttachedTo(CardItem *_attachedTo)
{
if (attachedTo == _attachedTo) {
return;
}
attachedTo = _attachedTo;
emit attachedToChanged(_attachedTo);
emit stateChanged();
}

View file

@ -1,106 +0,0 @@
#ifndef COCKATRICE_CARD_STATE_H
#define COCKATRICE_CARD_STATE_H
#include <QMap>
#include <QObject>
#include <QString>
#include <qtmetamacros.h>
class CardZoneLogic;
class CardItem;
class CardState : public QObject
{
Q_OBJECT
private:
bool attacking = false;
QMap<int, int> counters;
QString annotation;
QString pt;
bool doesntUntap = false;
bool destroyOnZoneChange = false;
CardItem *attachedTo = nullptr;
CardZoneLogic *zone = nullptr;
signals:
void stateChanged();
void attackingChanged(bool newValue);
void countersChanged(const QMap<int, int> &newCounters);
void annotationChanged(const QString &newAnnotation);
void ptChanged(const QString &newPt);
void doesntUntapChanged(bool newValue);
void destroyOnZoneChangeChanged(bool newValue);
void attachedToChanged(CardItem *newAttachedTo);
void zoneChanged(CardState *changedCard, CardZoneLogic *newZone);
public:
explicit CardState(QObject *parent, CardZoneLogic *_zone) : QObject(parent), zone(_zone)
{
}
void resetState(bool keepAnnotations);
CardZoneLogic *getZone() const
{
return zone;
}
void setZone(CardZoneLogic *_zone);
bool getAttacking() const
{
return attacking;
}
void setAttacking(bool _attacking);
const QMap<int, int> &getCounters() const
{
return counters;
}
void insertCounter(int id, int value);
void setCounter(int id, int value);
void clearCounters();
QString getAnnotation() const
{
return annotation;
}
void setAnnotation(const QString &_annotation);
QString getPT() const
{
return pt;
}
void setPT(const QString &_pt);
bool getDoesntUntap() const
{
return doesntUntap;
}
void setDoesntUntap(bool _doesntUntap);
bool getDestroyOnZoneChange() const
{
return destroyOnZoneChange;
}
void setDestroyOnZoneChange(bool _destroyOnZoneChange);
CardItem *getAttachedTo() const
{
return attachedTo;
}
void setAttachedTo(CardItem *_attachedTo);
};
#endif // COCKATRICE_CARD_STATE_H

View file

@ -1,26 +0,0 @@
/**
* @file replay.h
* @ingroup GameLogic
* @ingroup Replay
*/
//! \todo Document this file.
#ifndef COCKATRICE_REPLAY_H
#define COCKATRICE_REPLAY_H
#include "abstract_game.h"
#include <qtmetamacros.h>
class GameReplay;
class QObject;
class Replay : public AbstractGame
{
Q_OBJECT
public:
explicit Replay(QObject *_parent, const GameReplay *_replay, bool isLocalGame);
};
#endif // COCKATRICE_REPLAY_H

View file

@ -7,6 +7,7 @@
#include <QBrush>
#include <QColor>
#include <QCursor>
#include <QGraphicsScene>
#include <QGraphicsSceneMouseEvent>
#include <QPainter>
#include <QPainterPath>
@ -51,7 +52,20 @@ AbstractCardDragItem::AbstractCardDragItem(AbstractCardItem *_item,
update();
});
connect(item, &QObject::destroyed, this, &AbstractCardDragItem::deleteLater);
// The source card can be destroyed mid-drag (e.g. moved by a server event). deleteLater() alone is not enough:
// a scene repaint can run before the deferred delete and paint the dead card. Detach from the scene immediately.
connect(_item, &QObject::destroyed, this, [this]() {
hide();
if (QGraphicsScene *sc = scene()) {
sc->removeItem(this);
}
deleteLater();
});
}
AbstractCardItem *AbstractCardDragItem::getItem() const
{
return item.data();
}
QPainterPath AbstractCardDragItem::shape() const
@ -65,6 +79,10 @@ QPainterPath AbstractCardDragItem::shape() const
void AbstractCardDragItem::paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget)
{
if (item.isNull()) {
return;
}
item->paint(painter, option, widget);
// adds a mask to the card so it looks like the card hasnt been placed yet

View file

@ -14,6 +14,7 @@
#include <QList>
#include <QObject>
#include <QPointF>
#include <QPointer>
#include <QRectF>
#include <qtmetamacros.h>
@ -24,7 +25,8 @@ class AbstractCardDragItem : public QObject, public QGraphicsItem
Q_OBJECT
Q_INTERFACES(QGraphicsItem)
protected:
AbstractCardItem *item;
/// Guarded pointer: the source card may be destroyed while this drag item still exists.
QPointer<AbstractCardItem> item;
QPointF hotSpot;
QList<AbstractCardDragItem *> childDrags;
@ -44,10 +46,8 @@ public:
}
[[nodiscard]] QPainterPath shape() const override;
void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) override;
[[nodiscard]] AbstractCardItem *getItem() const
{
return item;
}
/// @return the source card, or nullptr if it has already been destroyed.
[[nodiscard]] AbstractCardItem *getItem() const;
[[nodiscard]] QPointF getHotSpot() const
{
return hotSpot;

View file

@ -12,6 +12,7 @@
#include "libcockatrice/card/printing/exact_card.h"
#include "libcockatrice/utility/card_ref.h"
#include <QApplication>
#include <QBrush>
#include <QChar>
#include <QCursor>
@ -28,11 +29,11 @@
#include <QPixmap>
#include <QPointF>
#include <QRectF>
#include <QTimer>
#include <QTransform>
#include <QVariant>
#include <QtGlobal>
#include <algorithm>
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/settings/appearance_settings.h>
#include <libcockatrice/settings/cards_display_settings.h>
#include <libcockatrice/settings/debug_settings.h>
@ -41,13 +42,23 @@
class QSizeF;
AbstractCardItem::AbstractCardItem(QGraphicsItem *parent, const CardRef &cardRef, PlayerLogic *_owner, int _id)
: ArrowTarget(_owner, parent), id(_id), cardRef(cardRef), tapped(false), facedown(false), tapAngle(0),
: ArrowTarget(_owner, parent), state(new CardState(this, nullptr, cardRef, _id)), tapAngle(0),
bgColor(Qt::transparent), isHovered(false), realZValue(0)
{
setCursor(Qt::OpenHandCursor);
setFlag(ItemIsSelectable);
setCacheMode(DeviceCoordinateCache);
connect(state, &CardState::cardPixmapUpdated, this, &AbstractCardItem::pixmapUpdated);
connect(state, &CardState::cardInfoChanged, this, &AbstractCardItem::onCardInfoChanged);
connect(state, &CardState::cardRefChanged, this, &AbstractCardItem::onCardRefChanged);
connect(state, &CardState::tappedChanged, this, &AbstractCardItem::onTappedChanged);
connect(state, &CardState::facedownChanged, this, [this] { update(); });
connect(state, &CardState::colorChanged, this, [this] {
cacheBgColor();
update();
});
connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::displayCardNamesChanged, this,
[this] { update(); });
connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardLangChanged, this,
@ -65,7 +76,7 @@ AbstractCardItem::AbstractCardItem(QGraphicsItem *parent, const CardRef &cardRef
AbstractCardItem::~AbstractCardItem()
{
emit deleteCardInfoPopup(cardRef.name);
emit deleteCardInfoPopup(state->getCardRef().name);
}
QRectF AbstractCardItem::boundingRect() const
@ -88,30 +99,29 @@ void AbstractCardItem::pixmapUpdated()
emit sigPixmapUpdated();
}
void AbstractCardItem::refreshCardInfo()
void AbstractCardItem::onCardInfoChanged()
{
exactCard = CardDatabaseManager::query()->getCard(cardRef);
if (!exactCard && !cardRef.name.isEmpty()) {
CardInfo::UiAttributes attributes = {.tableRow = -1};
auto info = CardInfo::newInstance(cardRef.name, "", true, {}, {}, {}, {}, attributes);
exactCard = ExactCard(info);
}
if (exactCard) {
connect(exactCard.getCardPtr().data(), &CardInfo::pixmapUpdated, this, &AbstractCardItem::pixmapUpdated);
}
cacheBgColor();
update();
}
void AbstractCardItem::onCardRefChanged(const CardRef &oldCardRef, const CardRef & /*newCardRef*/)
{
emit deleteCardInfoPopup(oldCardRef.name);
}
void AbstractCardItem::refreshCardInfo()
{
state->refreshCardInfo();
}
/**
* Convenience method to get the CardInfo of the exactCard
* @return A const reference to the CardInfo, or an empty CardInfo if card was null
*/
const CardInfo &AbstractCardItem::getCardInfo() const
{
return exactCard.getInfo();
return state->getCardInfo();
}
void AbstractCardItem::setRealZValue(qreal _zValue)
@ -156,13 +166,13 @@ void AbstractCardItem::paintPicture(QPainter *painter, const QSizeF &translatedS
QPixmap translatedPixmap;
bool paintImage = true;
if (facedown || cardRef.name.isEmpty()) {
if (state->getFaceDown() || state->getCardRef().name.isEmpty()) {
// never reveal card color, always paint the card back
CardPictureLoader::getCardBackPixmap(translatedPixmap, translatedSize.toSize());
} else {
// don't even spend time trying to load the picture if our size is too small
if (translatedSize.width() > 10) {
CardPictureLoader::getPixmap(translatedPixmap, exactCard, translatedSize.toSize());
CardPictureLoader::getPixmap(translatedPixmap, state->getCard(), translatedSize.toSize());
if (translatedPixmap.isNull()) {
paintImage = false;
}
@ -183,19 +193,20 @@ void AbstractCardItem::paintPicture(QPainter *painter, const QSizeF &translatedS
painter->drawPath(shape());
}
if (translatedPixmap.isNull() || SettingsCache::instance().cardsDisplay().getDisplayCardNames() || facedown) {
if (translatedPixmap.isNull() || SettingsCache::instance().cardsDisplay().getDisplayCardNames() ||
state->getFaceDown()) {
painter->save();
transformPainter(painter, translatedSize, angle);
painter->setPen(Qt::white);
painter->setBackground(Qt::black);
painter->setBackgroundMode(Qt::OpaqueMode);
QString nameStr;
if (facedown) {
nameStr = "# " + QString::number(id);
if (state->getFaceDown()) {
nameStr = "# " + QString::number(state->getId());
} else {
QString prefix = "";
if (SettingsCache::instance().debug().getShowCardId()) {
prefix = "#" + QString::number(id) + " ";
prefix = "#" + QString::number(state->getId()) + " ";
}
nameStr = prefix + CardLocalization::displayName(getCardInfo());
}
@ -233,21 +244,6 @@ void AbstractCardItem::paint(QPainter *painter, const QStyleOptionGraphicsItem *
painter->restore();
}
void AbstractCardItem::setCardRef(const CardRef &_cardRef)
{
if (cardRef == _cardRef) {
return;
}
emit deleteCardInfoPopup(cardRef.name);
if (exactCard) {
disconnect(exactCard.getCardPtr().data(), nullptr, this, nullptr);
}
cardRef = _cardRef;
refreshCardInfo();
}
void AbstractCardItem::setHovered(bool _hovered)
{
if (isHovered == _hovered) {
@ -273,18 +269,17 @@ void AbstractCardItem::setHovered(bool _hovered)
void AbstractCardItem::setColor(const QString &_color)
{
color = _color;
cacheBgColor();
update();
state->setColor(_color);
}
void AbstractCardItem::cacheBgColor()
{
QChar colorChar;
if (color.isEmpty()) {
colorChar = exactCard.getInfo().getColorChar();
const QString cardColor = state->getColor();
if (cardColor.isEmpty()) {
colorChar = state->getCardInfo().getColorChar();
} else {
colorChar = color.at(0);
colorChar = cardColor.at(0);
}
switch (colorChar.toLower().toLatin1()) {
@ -312,17 +307,12 @@ void AbstractCardItem::cacheBgColor()
}
}
void AbstractCardItem::setTapped(bool _tapped, bool canAnimate)
void AbstractCardItem::onTappedChanged(bool newTapped, bool canAnimate)
{
if (tapped == _tapped) {
return;
}
tapped = _tapped;
if (SettingsCache::instance().cardsDisplay().getTapAnimation() && canAnimate) {
static_cast<GameScene *>(scene())->registerAnimationItem(this);
} else {
tapAngle = tapped ? 90 : 0;
tapAngle = newTapped ? 90 : 0;
setTransform(QTransform()
.translate(CardDimensions::WIDTH_HALF_F, CardDimensions::HEIGHT_HALF_F)
.rotate(tapAngle)
@ -336,40 +326,85 @@ bool AbstractCardItem::animationEvent()
return false;
}
void AbstractCardItem::setFaceDown(bool _facedown)
{
facedown = _facedown;
update();
}
void AbstractCardItem::mousePressEvent(QGraphicsSceneMouseEvent *event)
{
cancelPendingSelectionCollapse();
if (scene() != nullptr) {
// A new press starts a new gesture: no card may collapse the selection out from under it afterwards.
for (QGraphicsItem *selectedItem : scene()->selectedItems()) {
auto *selectedCard = qgraphicsitem_cast<AbstractCardItem *>(selectedItem);
if (selectedCard != nullptr && selectedCard != this) {
selectedCard->cancelPendingSelectionCollapse();
}
}
}
if ((event->modifiers() & Qt::AltModifier) && event->button() == Qt::LeftButton) {
emit cardShiftClicked(cardRef.name);
emit cardShiftClicked(state->getCardRef().name);
} else if ((event->modifiers() & Qt::ControlModifier)) {
setSelected(!isSelected());
} else if (!isSelected()) {
scene()->clearSelection();
setSelected(true);
} else if (event->button() == Qt::LeftButton && scene()->selectedItems().count() > 1) {
// Part of a multi-selection; collapse only if this turns out to be a click rather than a drag,
// so that dragging the whole selection keeps it intact.
pendingSelectionCollapse = true;
}
if (event->button() == Qt::LeftButton) {
setCursor(Qt::ClosedHandCursor);
} else if (event->button() == Qt::MiddleButton) {
emit showCardInfoPopup(event->screenPos(), cardRef);
emit showCardInfoPopup(event->screenPos(), state->getCardRef());
}
event->accept();
}
void AbstractCardItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event)
{
if (pendingSelectionCollapse) {
pendingSelectionCollapse = false;
bool dragged = (event->screenPos() - event->buttonDownScreenPos(event->button())).manhattanLength() >=
2 * QApplication::startDragDistance();
if (!dragged) {
if (doubleClickActsOnSelection()) {
// A double-click may still arrive and must act on the whole multi-selection, so collapse only
// once the double-click window has passed without one. The generation guard (and the receiver
// context) drops the collapse if the item is gone, deselected, or a double-click cancelled it;
// the count guard drops it if the selection changed meanwhile (rubber band, ctrl-toggle).
const int generation = ++selectionCollapseGeneration;
const int armedSelectionCount = scene()->selectedItems().count();
QTimer::singleShot(QApplication::doubleClickInterval(), this, [this, generation, armedSelectionCount] {
if (generation == selectionCollapseGeneration && scene() != nullptr && isSelected() &&
scene()->selectedItems().count() == armedSelectionCount && armedSelectionCount > 1) {
scene()->clearSelection();
setSelected(true);
}
});
} else {
scene()->clearSelection();
setSelected(true);
}
}
}
if (event->button() == Qt::MiddleButton) {
emit deleteCardInfoPopup(cardRef.name);
emit deleteCardInfoPopup(state->getCardRef().name);
}
// This function ensures the parent function doesn't mess around with our selection.
event->accept();
}
void AbstractCardItem::cancelPendingSelectionCollapse()
{
pendingSelectionCollapse = false;
++selectionCollapseGeneration;
}
bool AbstractCardItem::doubleClickActsOnSelection() const
{
return false;
}
void AbstractCardItem::processHoverEvent()
{
emit hovered(this);

View file

@ -16,8 +16,7 @@
#include <QSizeF>
#include <QString>
#include <QtGlobal>
#include <libcockatrice/card/printing/exact_card.h>
#include <libcockatrice/utility/card_ref.h>
#include <libcockatrice/game/board/card_state.h>
#include <qtmetamacros.h>
class PlayerLogic;
@ -29,20 +28,20 @@ class AbstractCardItem : public ArrowTarget, public IAnimatedItem
{
Q_OBJECT
protected:
ExactCard exactCard;
int id;
CardRef cardRef;
bool tapped;
bool facedown;
CardState *state;
int tapAngle;
QString color;
QColor bgColor;
private:
bool isHovered;
qreal realZValue;
bool pendingSelectionCollapse;
int selectionCollapseGeneration = 0; ///< Guards the deferred collapse timer against stale firings
private slots:
void pixmapUpdated();
void onCardInfoChanged();
void onCardRefChanged(const CardRef &oldCardRef, const CardRef &newCardRef);
void onTappedChanged(bool newTapped, bool canAnimate);
public slots:
void refreshCardInfo();
@ -78,31 +77,38 @@ public:
QSizeF getTranslatedSize(QPainter *painter) const;
void paintPicture(QPainter *painter, const QSizeF &translatedSize, int angle);
void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) override;
[[nodiscard]] CardState *getState() const
{
return state;
}
ExactCard getCard() const
{
return exactCard;
return state->getCard();
}
const CardInfo &getCardInfo() const;
int getId() const
{
return id;
return state->getId();
}
void setId(int _id)
{
id = _id;
state->setId(_id);
}
QString getName() const
{
return cardRef.name;
return state->getCardRef().name;
}
QString getProviderId() const
{
return cardRef.providerId;
return state->getCardRef().providerId;
}
void setCardRef(const CardRef &_cardRef)
{
state->setCardRef(_cardRef);
}
void setCardRef(const CardRef &_cardRef);
CardRef getCardRef() const
{
return cardRef;
return state->getCardRef();
}
qreal getRealZValue() const
{
@ -114,25 +120,33 @@ public:
{
return isHovered;
}
QString getColor() const
[[nodiscard]] QString getColor() const
{
return color;
return state->getColor();
}
void setColor(const QString &_color);
bool getTapped() const
{
return tapped;
return state->getTapped();
}
void setTapped(bool _tapped, bool canAnimate = false)
{
state->setTapped(_tapped, canAnimate);
}
void setTapped(bool _tapped, bool canAnimate = false);
bool getFaceDown() const
{
return facedown;
return state->getFaceDown();
}
void setFaceDown(bool _facedown)
{
state->setFaceDown(_facedown);
}
void setFaceDown(bool _facedown);
void processHoverEvent();
/** @brief Drop a pending collapse and disarm any deferred collapse timer. */
void cancelPendingSelectionCollapse();
void deleteCardInfoPopup()
{
emit deleteCardInfoPopup(cardRef.name);
emit deleteCardInfoPopup(state->getCardRef().name);
}
/** @brief Default: no per-tick animation. Subclasses override to animate. */
@ -144,6 +158,16 @@ protected:
void mouseReleaseEvent(QGraphicsSceneMouseEvent *event) override;
QVariant itemChange(QGraphicsItem::GraphicsItemChange change, const QVariant &value) override;
void cacheBgColor();
/**
* @brief Whether a double-click on this item acts on the whole selection.
*
* When true, a release without drag defers the multi-selection collapse
* until the double-click window passes, so a following double-click still
* sees every selected card. Subclasses that handle double-clicks on the
* selection must return true here and call
* cancelPendingSelectionCollapse() from their double-click handler.
*/
virtual bool doubleClickActsOnSelection() const;
};
#endif

View file

@ -2,10 +2,6 @@
#include "../../client/settings/cache_settings.h"
#include "../../client/settings/shortcuts_settings.h"
#include "../../game/board/counter_state.h"
#include "../../game/player/player_actions.h"
#include "../../game/player/player_info.h"
#include "../../game/player/player_logic.h"
#include "../../game_graphics/board/translate_counter_name.h"
#include "../../interface/widgets/menus/tearoff_menu.h"
@ -23,8 +19,10 @@
#include <QString>
#include <QVariant>
#include <QtGlobal>
#include <libcockatrice/protocol/pb/command_inc_counter.pb.h>
#include <libcockatrice/protocol/pb/command_set_counter.pb.h>
#include <libcockatrice/game/board/counter_state.h>
#include <libcockatrice/game/player/player_actions.h>
#include <libcockatrice/game/player/player_info.h>
#include <libcockatrice/game/player/player_logic.h>
#include <libcockatrice/utility/expression.h>
#include <qnamespace.h>
@ -151,10 +149,7 @@ void AbstractCounter::mousePressEvent(QGraphicsSceneMouseEvent *event)
menu->exec(event->screenPos());
}
} else {
Command_IncCounter cmd;
cmd.set_counter_id(id);
cmd.set_delta(event->button() == Qt::LeftButton ? 1 : -1);
player->getPlayerActions()->sendGameCommand(cmd);
player->getPlayerActions()->actIncrementCounter(id, event->button() == Qt::LeftButton ? 1 : -1);
}
event->accept();
}
@ -172,10 +167,7 @@ void AbstractCounter::hoverLeaveEvent(QGraphicsSceneHoverEvent *)
void AbstractCounter::incrementCounter()
{
Command_IncCounter cmd;
cmd.set_counter_id(id);
cmd.set_delta(static_cast<QAction *>(sender())->data().toInt());
player->getPlayerActions()->sendGameCommand(cmd);
player->getPlayerActions()->actIncrementCounter(id, static_cast<QAction *>(sender())->data().toInt());
}
void AbstractCounter::setCounter()
@ -199,10 +191,7 @@ void AbstractCounter::setCounter()
}
Expression exp(value);
Command_SetCounter cmd;
cmd.set_counter_id(id);
cmd.set_value(static_cast<int>(exp.parse(dlg.textValue())));
player->getPlayerActions()->sendGameCommand(cmd);
player->getPlayerActions()->actSetCounter(id, static_cast<int>(exp.parse(dlg.textValue())));
}
AbstractCounterDialog::AbstractCounterDialog(const QString &name, const QString &value, QWidget *parent)

View file

@ -2,11 +2,6 @@
#include "arrow_item.h"
#include "../../client/settings/cache_settings.h"
#include "../../game/board/arrow_data.h"
#include "../../game/player/player_actions.h"
#include "../../game/player/player_info.h"
#include "../../game/player/player_logic.h"
#include "../../game/zones/card_zone_logic.h"
#include "../game_scene.h"
#include "../player/player_target.h"
#include "../z_values.h"
@ -27,12 +22,12 @@
#include <QTransform>
#include <QtGlobal>
#include <QtMath>
#include <libcockatrice/card/card_info.h>
#include <libcockatrice/protocol/pb/command_attach_card.pb.h>
#include <libcockatrice/protocol/pb/command_create_arrow.pb.h>
#include <libcockatrice/game/board/arrow_data.h>
#include <libcockatrice/game/player/player_actions.h>
#include <libcockatrice/game/player/player_info.h>
#include <libcockatrice/game/player/player_logic.h>
#include <libcockatrice/game/zones/card_zone_logic.h>
#include <libcockatrice/settings/cards_display_settings.h>
#include <libcockatrice/settings/interface_settings.h>
#include <libcockatrice/utility/color.h>
#include <libcockatrice/utility/zone_names.h>
#include <math.h>
#include <qnamespace.h>
@ -426,19 +421,19 @@ void ArrowDragItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event)
CardZoneLogic *startZone = startCard->getZone();
Command_CreateArrow cmd;
cmd.mutable_arrow_color()->CopyFrom(convertQColorToColor(data->color));
cmd.set_start_player_id(startZone->getPlayer()->getPlayerInfo()->getId());
cmd.set_start_zone(startZone->getName().toStdString());
cmd.set_start_card_id(startCard->getId());
ArrowData arrow;
arrow.color = data->color;
arrow.startPlayerId = startZone->getPlayer()->getPlayerInfo()->getId();
arrow.startZone = startZone->getName();
arrow.startCardId = startCard->getId();
if (auto *targetCard = qgraphicsitem_cast<CardItem *>(targetItem)) {
CardZoneLogic *targetZone = targetCard->getZone();
cmd.set_target_player_id(targetZone->getPlayer()->getPlayerInfo()->getId());
cmd.set_target_zone(targetZone->getName().toStdString());
cmd.set_target_card_id(targetCard->getId());
arrow.targetPlayerId = targetZone->getPlayer()->getPlayerInfo()->getId();
arrow.targetZone = targetZone->getName();
arrow.targetCardId = targetCard->getId();
} else if (auto *targetPlayer = qgraphicsitem_cast<PlayerTarget *>(targetItem)) {
cmd.set_target_player_id(targetPlayer->getOwner()->getPlayerInfo()->getId());
arrow.targetPlayerId = targetPlayer->getOwner()->getPlayerInfo()->getId();
} else {
delArrow();
return;
@ -447,22 +442,9 @@ void ArrowDragItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event)
// if the card is in hand then we will move the card to stack or table as part of drawing the arrow
if (startZone->getName() == ZoneNames::HAND) {
startCard->playCard(false);
CardInfoPtr ci = startCard->getCard().getCardPtr();
bool playToStack = SettingsCache::instance().userInterface().getPlayToStack();
if (ci && ((!playToStack && ci->getUiAttributes().tableRow == 3) ||
(playToStack && ci->getUiAttributes().tableRow != 0 &&
startCard->getZone()->getName() != ZoneNames::STACK))) {
cmd.set_start_zone(ZoneNames::STACK);
} else {
cmd.set_start_zone(playToStack ? ZoneNames::STACK : ZoneNames::TABLE);
}
}
if (deleteInPhase != 0) {
cmd.set_delete_in_phase(deleteInPhase);
}
player->getPlayerActions()->sendGameCommand(cmd);
player->getPlayerActions()->createArrow(startCard->getState(), arrow, deleteInPhase);
}
delArrow();
@ -554,7 +536,7 @@ void ArrowAttachItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event)
auto *startCard = qgraphicsitem_cast<CardItem *>(startItem);
auto *targetCard = qgraphicsitem_cast<CardItem *>(targetItem);
if (startCard && targetCard) {
attachCards(startCard, targetCard);
player->getPlayerActions()->attachCards(startCard->getState(), targetCard->getState());
}
}
@ -563,23 +545,3 @@ void ArrowAttachItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event)
child->mouseReleaseEvent(event);
}
}
void ArrowAttachItem::attachCards(CardItem *startCard, const CardItem *targetCard)
{
if (targetCard->getAttachedTo() || targetCard->getZone()->getName() != ZoneNames::TABLE) {
return;
}
// move card onto table first if attaching from some other zone
if (startCard->getZone()->getName() != ZoneNames::TABLE) {
player->getPlayerActions()->playCardToTable(startCard, false);
}
Command_AttachCard cmd;
cmd.set_start_zone(ZoneNames::TABLE);
cmd.set_card_id(startCard->getId());
cmd.set_target_player_id(targetCard->getZone()->getPlayer()->getPlayerInfo()->getId());
cmd.set_target_zone(targetCard->getZone()->getName().toStdString());
cmd.set_target_card_id(targetCard->getId());
player->getPlayerActions()->sendGameCommand(cmd);
}

View file

@ -1,7 +1,6 @@
#ifndef ARROWITEM_H
#define ARROWITEM_H
#include "../../game/board/arrow_data.h"
#include "../animated_item.h"
#include "arrow_target.h"
#include "graphics_item_type.h"
@ -18,6 +17,7 @@
#include <QRectF>
#include <QSharedPointer>
#include <QtGlobal>
#include <libcockatrice/game/board/arrow_data.h>
#include <qtmetamacros.h>
class CardItem;
@ -131,7 +131,6 @@ private:
PlayerLogic *player;
QList<ArrowAttachItem *> childArrows;
QMetaObject::Connection positionConnection;
void attachCards(CardItem *startCard, const CardItem *targetCard);
public:
explicit ArrowAttachItem(ArrowTarget *_startItem);

View file

@ -1,9 +1,9 @@
#include "arrow_registry.h"
#include "../game_graphics/board/arrow_item.h"
#include "board/arrow_data.h"
#include "arrow_item.h"
#include <QSharedPointer>
#include <libcockatrice/game/board/arrow_data.h>
void ArrowRegistry::insert(QSharedPointer<ArrowData> data, ArrowItem *arrow)
{

View file

@ -4,6 +4,7 @@
#include <QList>
#include <QMap>
#include <QSet>
#include <libcockatrice/game/board/arrow_data.h>
class ArrowItem;
struct ArrowData;

View file

@ -130,8 +130,9 @@ void CardDragItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event)
sc->removeItem(this);
QList<CardDragItem *> dragItemList;
CardZoneLogic *startZone = static_cast<CardItem *>(item)->getZone();
if (currentZone && !(static_cast<CardItem *>(item)->getAttachedTo() && (startZone == currentZone->getLogic()))) {
CardZoneLogic *startZone = static_cast<CardItem *>(item.data())->getZone();
if (currentZone &&
!(static_cast<CardItem *>(item.data())->getAttachedTo() && (startZone == currentZone->getLogic()))) {
if (!occupied) {
dragItemList.append(this);
}
@ -139,7 +140,7 @@ void CardDragItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event)
for (int i = 0; i < childDrags.size(); i++) {
CardDragItem *c = static_cast<CardDragItem *>(childDrags[i]);
if (!occupied &&
!(static_cast<CardItem *>(c->item)->getAttachedTo() && (startZone == currentZone->getLogic())) &&
!(static_cast<CardItem *>(c->item.data())->getAttachedTo() && (startZone == currentZone->getLogic())) &&
!c->occupied) {
dragItemList.append(c);
}

View file

@ -1,17 +1,6 @@
#include "card_item.h"
#include "../../client/settings/cache_settings.h"
#include "../../game/abstract_game.h"
#include "../../game/board/card_state.h"
#include "../../game/game_state.h"
#include "../../game/phase.h"
#include "../../game/player/player_actions.h"
#include "../../game/player/player_info.h"
#include "../../game/player/player_logic.h"
#include "../../game/player/player_manager.h"
#include "../../game/zones/card_zone_logic.h"
#include "../../game/zones/table_zone_logic.h"
#include "../../game/zones/view_zone_logic.h"
#include "../card_dimensions.h"
#include "../game_scene.h"
#include "arrow_item.h"
@ -41,6 +30,17 @@
#include <QVariant>
#include <QtGlobal>
#include <libcockatrice/card/card_info.h>
#include <libcockatrice/game/abstract_game.h>
#include <libcockatrice/game/board/card_state.h>
#include <libcockatrice/game/game_state.h>
#include <libcockatrice/game/phase.h>
#include <libcockatrice/game/player/player_actions.h>
#include <libcockatrice/game/player/player_info.h>
#include <libcockatrice/game/player/player_logic.h>
#include <libcockatrice/game/player/player_manager.h>
#include <libcockatrice/game/zones/card_zone_logic.h>
#include <libcockatrice/game/zones/table_zone_logic.h>
#include <libcockatrice/game/zones/view_zone_logic.h>
#include <libcockatrice/protocol/pb/serverinfo_card.pb.h>
#include <libcockatrice/settings/interface_settings.h>
#include <libcockatrice/utility/card_ref.h>
@ -53,8 +53,9 @@ CardItem::CardItem(PlayerLogic *_owner,
const CardRef &cardRef,
int _cardid,
CardZoneLogic *_zone)
: AbstractCardItem(parent, cardRef, _owner, _cardid), state(new CardState(this, _zone)), dragItem(nullptr)
: AbstractCardItem(parent, cardRef, _owner, _cardid), dragItem(nullptr)
{
state->setZone(_zone);
owner->addCard(this);
connect(&SettingsCache::instance().cardCounters(), &CardCounterSettings::colorChanged, this, [this](int counterId) {
@ -62,25 +63,44 @@ CardItem::CardItem(PlayerLogic *_owner,
update();
}
});
connect(state, &CardState::stateReset, this, [this] {
if (scene()) {
static_cast<GameScene *>(scene())->unregisterAnimationItem(this);
}
update();
});
// The retyped logic layer drives CardState setters directly (bypassing the CardItem wrappers that called
// update()), so repaint on every state change: attacking, annotation, doesntUntap, P/T and counters.
connect(state, &CardState::stateChanged, this, [this] { update(); });
connect(state, &CardState::viewDeleteRequested, this, &CardItem::deleteLater);
}
void CardItem::prepareDelete()
{
if (owner != nullptr) {
if (owner->getGame()->getActiveCard() == this) {
auto *gameScene = qobject_cast<GameScene *>(scene());
if (gameScene && gameScene->getActiveCard() == this) {
emit owner->requestCardMenuUpdate(nullptr);
owner->getGame()->setActiveCard(nullptr);
gameScene->setActiveCard(nullptr);
}
owner = nullptr;
}
while (!attachedCards.isEmpty()) {
attachedCards.first()->setZone(nullptr); // so that it won't try to call reorganizeCards()
attachedCards.first()->setAttachedTo(nullptr);
const QList<CardState *> &attachedStates = state->getAttachedCards();
while (!attachedStates.isEmpty()) {
auto *attachedItem = qobject_cast<CardItem *>(attachedStates.first()->parent());
if (attachedItem == nullptr) {
break;
}
attachedItem->setZone(nullptr); // so that it won't try to call reorganizeCards()
attachedItem->setAttachedTo(nullptr);
}
if (state->getAttachedTo() != nullptr) {
state->getAttachedTo()->removeAttachedCard(this);
state->getAttachedTo()->removeAttachedCard(state);
state->setAttachedTo(nullptr);
}
}
@ -127,7 +147,7 @@ void CardItem::paint(QPainter *painter, const QStyleOptionGraphicsItem *option,
painter->save();
transformPainter(painter, translatedSize, tapAngle);
if (!getFaceDown() && state->getPT() == exactCard.getInfo().getPowTough()) {
if (!getFaceDown() && state->getPT() == state->getCardInfo().getPowTough()) {
painter->setPen(Qt::white);
} else {
painter->setPen(QColor(255, 150, 0)); // dark orange
@ -209,36 +229,13 @@ void CardItem::setPT(const QString &_pt)
void CardItem::setAttachedTo(CardItem *_attachedTo)
{
if (state->getAttachedTo() != nullptr) {
state->getAttachedTo()->removeAttachedCard(this);
}
CardState *newParent = _attachedTo == nullptr ? nullptr : _attachedTo->getState();
state->setAttachedTo(newParent);
gridPoint.setX(-1);
state->setAttachedTo(_attachedTo);
if (state->getAttachedTo() != nullptr) {
// If the zone is being torn down, it might already be null by the time a card tries to un-attach all its
// attached cards
if (state->getAttachedTo()->getZone() == nullptr) {
deleteLater();
} else {
emit state->getAttachedTo()->getZone()->cardAdded(this);
state->getAttachedTo()->addAttachedCard(this);
if (state->getZone() != state->getAttachedTo()->getZone()) {
state->getAttachedTo()->getZone()->reorganizeCards();
}
}
} else {
// If the zone is being torn down, it might already be null by the time a card tries to un-attach all its
// attached cards
if (state->getZone() == nullptr) {
deleteLater();
} else {
emit state->getZone()->cardAdded(this);
}
}
if (state->getZone() != nullptr) {
state->getZone()->reorganizeCards();
// If the zone is being torn down, it might already be null by the time a card tries to un-attach all its attached
// cards
if (newParent != nullptr ? newParent->getZone() == nullptr : state->getZone() == nullptr) {
deleteLater();
}
}
@ -248,13 +245,6 @@ void CardItem::setAttachedTo(CardItem *_attachedTo)
void CardItem::resetState(bool keepAnnotations)
{
state->resetState(keepAnnotations);
attachedCards.clear();
setTapped(false, false);
setDoesntUntap(false);
if (scene()) {
static_cast<GameScene *>(scene())->unregisterAnimationItem(this);
}
update();
}
void CardItem::processCardInfo(const ServerInfo_Card &_info)
@ -390,7 +380,7 @@ void CardItem::mouseMoveEvent(QGraphicsSceneMouseEvent *event)
// Use the buttonDownPos to align the hot spot with the position when
// the user originally clicked
createDragItem(id, event->buttonDownPos(Qt::LeftButton), event->scenePos(), forceFaceDown);
createDragItem(state->getId(), event->buttonDownPos(Qt::LeftButton), event->scenePos(), forceFaceDown);
dragItem->grabMouse();
int childIndex = 0;
@ -433,42 +423,11 @@ void CardItem::playCard(bool faceDown)
emit playSelected(this);
}
} else {
state->getZone()->getPlayer()->getPlayerActions()->playCard(this, faceDown);
state->getZone()->getPlayer()->getPlayerActions()->playCard(state, faceDown);
}
}
}
QVariantList CardItem::parsePT(const QString &pt)
{
QVariantList ptList = QVariantList();
if (!pt.isEmpty()) {
int sep = pt.indexOf('/');
if (sep == 0) {
ptList.append(QVariant(pt.mid(1))); // cut off starting '/' and take full string
} else {
int start = 0;
for (;;) {
QString item = pt.mid(start, sep - start);
if (item.isEmpty()) {
ptList.append(QVariant(QString()));
} else if (item[0] == '+') {
ptList.append(QVariant(item.mid(1).toInt())); // add as int
} else if (item[0] == '-') {
ptList.append(QVariant(item.toInt())); // add as int
} else {
ptList.append(QVariant(item)); // add as qstring
}
if (sep == -1) {
break;
}
start = sep + 1;
sep = pt.indexOf('/', start);
}
}
}
return ptList;
}
/**
* @brief returns true if the zone is a unwritable reveal zone view (eg a card reveal window). Will return false if zone
* is nullptr.
@ -493,7 +452,7 @@ void CardItem::handleClickedToPlay(bool shiftHeld)
if (SettingsCache::instance().userInterface().getClickPlaysAllSelected()) {
emit hideSelected(this);
} else {
state->getZone()->removeCard(this);
state->getZone()->removeCard(state);
}
} else {
playCard(shiftHeld);
@ -518,6 +477,9 @@ void CardItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event)
void CardItem::mouseDoubleClickEvent(QGraphicsSceneMouseEvent *event)
{
// The preceding release deferred its multi-selection collapse for exactly this: the double-click plays
// every selected card, so the collapse must not run.
cancelPendingSelectionCollapse();
if ((event->modifiers() != Qt::AltModifier) && (event->buttons() == Qt::LeftButton) &&
(SettingsCache::instance().userInterface().getDoubleClickToPlay())) {
handleClickedToPlay(event->modifiers().testFlag(Qt::ShiftModifier));
@ -525,20 +487,25 @@ void CardItem::mouseDoubleClickEvent(QGraphicsSceneMouseEvent *event)
event->accept();
}
bool CardItem::doubleClickActsOnSelection() const
{
return SettingsCache::instance().userInterface().getDoubleClickToPlay();
}
bool CardItem::animationEvent()
{
int rotation = ROTATION_DEGREES_PER_FRAME;
bool animationIncomplete = true;
if (!tapped) {
if (!state->getTapped()) {
rotation *= -1;
}
tapAngle += rotation;
if (tapped && (tapAngle > 90)) {
if (state->getTapped() && (tapAngle > 90)) {
tapAngle = 90;
animationIncomplete = false;
}
if (!tapped && (tapAngle < 0)) {
if (!state->getTapped() && (tapAngle < 0)) {
tapAngle = 0;
animationIncomplete = false;
}
@ -559,7 +526,9 @@ QVariant CardItem::itemChange(GraphicsItemChange change, const QVariant &value)
bool selected = value.toBool();
if (selected) {
owner->getGame()->setActiveCard(this);
if (auto *gameScene = qobject_cast<GameScene *>(scene())) {
gameScene->setActiveCard(this);
}
}
emit selectionChanged(this, selected);

View file

@ -7,7 +7,6 @@
#ifndef CARDITEM_H
#define CARDITEM_H
#include "../../game/board/card_state.h"
#include "abstract_card_item.h"
#include "graphics_item_type.h"
@ -16,6 +15,7 @@
#include <QPointF>
#include <QString>
#include <QVariantList>
#include <libcockatrice/game/board/card_state.h>
#include <qtmetamacros.h>
class CardDragItem;
@ -33,11 +33,7 @@ class CardItem : public AbstractCardItem
{
Q_OBJECT
private:
CardState *state;
QPoint gridPoint;
CardDragItem *dragItem;
QList<CardItem *> attachedCards;
void prepareDelete();
void handleClickedToPlay(bool shiftHeld);
@ -60,10 +56,6 @@ public:
CardZoneLogic *_zone = nullptr);
void retranslateUi();
[[nodiscard]] CardState *getState() const
{
return state;
}
[[nodiscard]] CardZoneLogic *getZone() const
{
return state->getZone();
@ -72,15 +64,15 @@ public:
void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) override;
[[nodiscard]] QPoint getGridPoint() const
{
return gridPoint;
return state->getGridPoint();
}
void setGridPoint(const QPoint &_gridPoint)
{
gridPoint = _gridPoint;
state->setGridPoint(_gridPoint);
}
[[nodiscard]] QPoint getGridPos() const
{
return gridPoint;
return state->getGridPoint();
}
[[nodiscard]] PlayerLogic *getOwner() const
{
@ -123,22 +115,22 @@ public:
{
state->setDestroyOnZoneChange(_destroy);
}
[[nodiscard]] CardItem *getAttachedTo() const
[[nodiscard]] CardState *getAttachedTo() const
{
return state->getAttachedTo();
}
void setAttachedTo(CardItem *_attachedTo);
void addAttachedCard(CardItem *card)
void addAttachedCard(CardState *card)
{
attachedCards.append(card);
state->addAttachedCard(card);
}
void removeAttachedCard(CardItem *card)
void removeAttachedCard(CardState *card)
{
attachedCards.removeOne(card);
state->removeAttachedCard(card);
}
[[nodiscard]] const QList<CardItem *> &getAttachedCards() const
[[nodiscard]] const QList<CardState *> &getAttachedCards() const
{
return attachedCards;
return state->getAttachedCards();
}
void resetState(bool keepAnnotations = false);
void processCardInfo(const ServerInfo_Card &_info);
@ -150,31 +142,12 @@ public:
void drawAttachArrow();
void playCard(bool faceDown);
/**
* @brief Parses a string representing a p/t in order to extract the values from it.
*
* If the string contains '/', the string will be split at the '/' and each side will be parsed separately,
* which means the result list will have two elements.
*
* If '/' is not found, then the entire string is parsed together, which means the result list will
* have a single element.
*
* If either side of the split is empty, there will also only be a single element in the result list.
*
* This function will attempt to parse each substring as an int first, handling plus and minus prefixes.
* If successful, it will put the parsed value into the QVariant as an int.
* If failed, it will just put the substring into the QVariant as a QString.
*
* @param pt The p/t string
* @return A QVariantList that can contain one or two elements, where each QVariant can be either int or QString
*/
static QVariantList parsePT(const QString &pt);
protected:
void mouseMoveEvent(QGraphicsSceneMouseEvent *event) override;
void mouseReleaseEvent(QGraphicsSceneMouseEvent *event) override;
void mouseDoubleClickEvent(QGraphicsSceneMouseEvent *event) override;
QVariant itemChange(GraphicsItemChange change, const QVariant &value) override;
bool doubleClickActsOnSelection() const override;
};
#endif

View file

@ -77,7 +77,7 @@ void DeckViewCardDragItem::updatePosition(const QPointF &cursorScenePos)
void DeckViewCardDragItem::handleDrop(DeckViewCardContainer *target)
{
auto *card = static_cast<DeckViewCard *>(item);
auto *card = static_cast<DeckViewCard *>(item.data());
auto *start = static_cast<DeckViewCardContainer *>(item->parentItem());
start->removeCard(card);
target->addCard(card);
@ -182,6 +182,15 @@ void DeckView::mouseDoubleClickEvent(QMouseEvent *event)
QList<MoveCard_ToZone> result;
QList<QGraphicsItem *> sel = scene()->selectedItems();
// The double-click goes to the view, not the card, so cancel any pending
// selection collapse on the cards before reading the selection.
for (auto *item : sel) {
if (auto *c = dynamic_cast<DeckViewCard *>(item)) {
c->cancelPendingSelectionCollapse();
}
}
sel = scene()->selectedItems();
for (int i = 0; i < sel.size(); i++) {
auto *c = static_cast<DeckViewCard *>(sel.at(i));
auto *zone = static_cast<DeckViewCardContainer *>(c->parentItem());

View file

@ -49,6 +49,10 @@ public:
{
return originZone;
}
bool doubleClickActsOnSelection() const override
{
return true;
}
protected:
void mouseMoveEvent(QGraphicsSceneMouseEvent *event) override;

View file

@ -2,13 +2,8 @@
#include "../../client/settings/cache_settings.h"
#include "../../client/settings/shortcuts_settings.h"
#include "../../game/abstract_game.h"
#include "../../game/game_event_handler.h"
#include "../../game/game_state.h"
#include "../../interface/card_picture_loader/card_picture_loader.h"
#include "../../interface/deck_loader/deck_file_format.h"
#include "../../interface/deck_loader/deck_loader.h"
#include "../../interface/deck_loader/loaded_deck.h"
#include "../../interface/widgets/dialogs/dlg_load_deck.h"
#include "../../interface/widgets/dialogs/dlg_load_deck_from_clipboard.h"
#include "../../interface/widgets/dialogs/dlg_load_deck_from_website.h"
@ -33,16 +28,17 @@
#include <QRect>
#include <QVBoxLayout>
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/deck_list/deck_file_format.h>
#include <libcockatrice/deck_list/loaded_deck.h>
#include <libcockatrice/deck_list/playmat_resolver.h>
#include <libcockatrice/protocol/pb/command_deck_select.pb.h>
#include <libcockatrice/protocol/pb/command_ready_start.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>
#include <libcockatrice/game/abstract_game.h>
#include <libcockatrice/game/game_event_handler.h>
#include <libcockatrice/game/game_state.h>
#include <libcockatrice/protocol/pb/response_deck_download.pb.h>
#include <libcockatrice/protocol/pending_command.h>
#include <libcockatrice/settings/interface_settings.h>
#include <libcockatrice/settings/visual_deck_storage_settings.h>
#include <libcockatrice/utility/qt_utils.h>
#include <libcockatrice/utility/string_limits.h>
#include <optional>
#include <qnamespace.h>
@ -111,10 +107,9 @@ DeckViewContainer::DeckViewContainer(int _playerId, TabGame *parent)
connect(deckView, &DeckView::newCardAdded, this, &DeckViewContainer::newCardAdded);
connect(deckView, &DeckView::sideboardPlanChanged, this, &DeckViewContainer::sideboardPlanChanged);
deckViewLayout = new QVBoxLayout;
deckViewLayout = QtUtils::makeLayout<QVBoxLayout>();
deckViewLayout->addLayout(buttonHBox);
deckViewLayout->addWidget(deckView);
deckViewLayout->setContentsMargins(0, 0, 0, 0);
setLayout(deckViewLayout);
retranslateUi();
@ -314,9 +309,7 @@ void DeckViewContainer::loadDeckFromDeckList(const DeckList &deck)
return;
}
Command_DeckSelect cmd;
cmd.set_deck(deckString.toStdString());
PendingCommand *pend = parentGame->getGame()->getGameEventHandler()->prepareGameCommand(cmd);
PendingCommand *pend = parentGame->getGame()->getGameEventHandler()->prepareDeckSelect(deckString);
connect(pend, &PendingCommand::finished, this, &DeckViewContainer::deckSelectFinished);
parentGame->getGame()->getGameEventHandler()->sendGameCommand(pend, playerId);
@ -345,15 +338,20 @@ void DeckViewContainer::resolveAndSendPlaymat()
lastResolvedPlaymat = resolved;
Command_SetPlaymat playmatCmd;
auto *pp = playmatCmd.mutable_playmat_params();
pp->set_card_name(resolved.card.name.toStdString());
pp->set_card_provider_id(resolved.card.providerId.toStdString());
pp->set_margin_pct_l(resolved.params.marginPctL);
pp->set_margin_pct_r(resolved.params.marginPctR);
pp->set_vertical_offset(resolved.params.verticalOffset);
pp->set_zoom(resolved.params.zoom);
PendingCommand *playmatPend = parentGame->getGame()->getGameEventHandler()->prepareGameCommand(playmatCmd);
PendingCommand *playmatPend = parentGame->getGame()->getGameEventHandler()->prepareSetPlaymat(resolved);
const QString sentName = resolved.card.name;
connect(playmatPend, &PendingCommand::finished, this, [this, sentName](const Response &r) {
// Only RespFunctionNotAllowed signals a card art DENY rule. Older servers that predate
// playmats answer RespInvalidCommand and strip the mat from DeckSelect, which must stay silent.
if (r.response_code() == Response::RespFunctionNotAllowed) {
if (!sentName.isEmpty()) {
notifyPlaymatBlacklisted(
sentName, tr("The selected playmat card is blacklisted on this server, so it was not applied."));
}
} else if (r.response_code() == Response::RespOk && !sentName.isEmpty()) {
lastBlacklistedPlaymatNotice.clear();
}
});
parentGame->getGame()->getGameEventHandler()->sendGameCommand(playmatPend, playerId);
}
@ -371,9 +369,7 @@ void DeckViewContainer::loadRemoteDeck()
{
DlgLoadRemoteDeck dlg(parentGame->getGame()->getClientForPlayer(playerId), this);
if (dlg.exec()) {
Command_DeckSelect cmd;
cmd.set_deck_id(dlg.getDeckId());
PendingCommand *pend = parentGame->getGame()->getGameEventHandler()->prepareGameCommand(cmd);
PendingCommand *pend = parentGame->getGame()->getGameEventHandler()->prepareDeckSelectId(dlg.getDeckId());
connect(pend, &PendingCommand::finished, this, &DeckViewContainer::deckSelectFinished);
parentGame->getGame()->getGameEventHandler()->sendGameCommand(pend, playerId);
}
@ -412,6 +408,15 @@ void DeckViewContainer::deckSelectFinished(const Response &r)
switchToDeckLoadedView();
}
void DeckViewContainer::notifyPlaymatBlacklisted(const QString &cardName, const QString &message)
{
if (cardName.isEmpty() || cardName == lastBlacklistedPlaymatNotice) {
return;
}
lastBlacklistedPlaymatNotice = cardName;
QMessageBox::critical(this, tr("Error"), message);
}
void DeckViewContainer::readyStart()
{
sendReadyStartCommand(!readyStartButton->getState());
@ -420,34 +425,21 @@ void DeckViewContainer::readyStart()
void DeckViewContainer::forceStart()
{
const auto msg = tr("Are you sure you want to force start?\nThis will kick all non-ready players from the game.");
const auto res =
QMessageBox::question(this, tr("Cockatrice"), msg, QMessageBox::Yes | QMessageBox::No, QMessageBox::Yes);
if (res == QMessageBox::No) {
if (!QtUtils::confirm(this, tr("Cockatrice"), msg, QMessageBox::Yes)) {
return;
}
Command_ReadyStart cmd;
cmd.set_force_start(true);
cmd.set_ready(true);
parentGame->getGame()->getGameEventHandler()->sendGameCommand(cmd, playerId);
parentGame->getGame()->getGameEventHandler()->sendForceStart(playerId);
}
void DeckViewContainer::sideboardLockButtonClicked()
{
Command_SetSideboardLock cmd;
cmd.set_locked(sideboardLockButton->getState());
parentGame->getGame()->getGameEventHandler()->sendGameCommand(cmd, playerId);
parentGame->getGame()->getGameEventHandler()->sendSetSideboardLock(sideboardLockButton->getState(), playerId);
}
void DeckViewContainer::sideboardPlanChanged()
{
Command_SetSideboardPlan cmd;
const QList<MoveCard_ToZone> &newPlan = deckView->getSideboardPlan();
for (const auto &i : newPlan) {
cmd.add_move_list()->CopyFrom(i);
}
parentGame->getGame()->getGameEventHandler()->sendGameCommand(cmd, playerId);
parentGame->getGame()->getGameEventHandler()->sendSetSideboardPlan(deckView->getSideboardPlan(), playerId);
}
/**
@ -459,9 +451,7 @@ void DeckViewContainer::sendReadyStartCommand(bool ready)
resolveAndSendPlaymat();
}
Command_ReadyStart cmd;
cmd.set_ready(ready);
parentGame->getGame()->getGameEventHandler()->sendGameCommand(cmd, playerId);
parentGame->getGame()->getGameEventHandler()->sendReadyStart(ready, playerId);
}
/**

View file

@ -60,12 +60,14 @@ private:
VisualDeckStorageWidget *visualDeckStorageWidget;
TabGame *parentGame;
int playerId;
int playmatRotationIndex = 0; ///< Per-match cursor for round-robin playmat mode.
DeckList currentDeck; ///< Cached deck for live settings re-resolution.
PlaymatInfo lastResolvedPlaymat; ///< Tracks last sent playmat to avoid repeats in random mode.
int playmatRotationIndex = 0; ///< Per-match cursor for round-robin playmat mode.
DeckList currentDeck; ///< Cached deck for live settings re-resolution.
PlaymatInfo lastResolvedPlaymat; ///< Tracks last sent playmat to avoid repeats in random mode.
QString lastBlacklistedPlaymatNotice; ///< Card name already reported as blacklisted, to avoid repeat popups.
void tryCreateVisualDeckStorageWidget();
void sendReadyStartCommand(bool ready);
void notifyPlaymatBlacklisted(const QString &cardName, const QString &message);
private slots:
void switchToDeckSelectView();
void switchToDeckLoadedView();

View file

@ -0,0 +1,13 @@
#include "dlg_annotation.h"
#include <QKeyEvent>
void AnnotationDialog::keyPressEvent(QKeyEvent *event)
{
if (event->key() == Qt::Key_Return && event->modifiers() & Qt::ControlModifier) {
event->accept();
accept();
return;
}
QInputDialog::keyPressEvent(event);
}

View file

@ -0,0 +1,22 @@
/**
* @file dlg_annotation.h
* @ingroup GameDialogs
*/
#ifndef COCKATRICE_DLG_ANNOTATION_H
#define COCKATRICE_DLG_ANNOTATION_H
#include <QInputDialog>
class AnnotationDialog : public QInputDialog
{
Q_OBJECT
void keyPressEvent(QKeyEvent *e) override;
public:
explicit AnnotationDialog(QWidget *parent = nullptr) : QInputDialog(parent)
{
}
};
#endif // COCKATRICE_DLG_ANNOTATION_H

View file

@ -11,6 +11,7 @@
#include <QList>
#include <QString>
#include <QStringList>
#include <libcockatrice/game/player/token_info.h>
#include <qtmetamacros.h>
class QLabel;
@ -25,17 +26,6 @@ class CardInfoPictureWidget;
class QModelIndex;
class QWidget;
struct TokenInfo
{
QString name;
QString color;
QString pt;
QString annotation;
bool destroy = true;
bool faceDown = false;
QString providerId;
};
class DlgCreateToken : public QDialog
{
Q_OBJECT

View file

@ -11,6 +11,7 @@
#include <QList>
#include <QString>
#include <QStringList>
#include <libcockatrice/game/player/move_top_cards_until_options.h>
#include <qtmetamacros.h>
class FilterString;
@ -21,13 +22,6 @@ class QLabel;
class QSpinBox;
class QWidget;
struct MoveTopCardsUntilOptions
{
QStringList exprs = {};
int numberOfHits = 1;
bool autoPlay = false;
};
class DlgMoveTopCardsUntil : public QDialog
{
Q_OBJECT

View file

@ -1,18 +1,10 @@
#include "game_scene.h"
#include "../client/settings/cache_settings.h"
#include "../game/abstract_game.h"
#include "../game/arrow_registry.h"
#include "../game/board/arrow_data.h"
#include "../game/player/player_actions.h"
#include "../game/player/player_event_handler.h"
#include "../game/player/player_info.h"
#include "../game/player/player_logic.h"
#include "../game/zones/card_zone_logic.h"
#include "../game/zones/view_zone_logic.h"
#include "animated_item.h"
#include "board/abstract_card_item.h"
#include "board/arrow_item.h"
#include "board/arrow_registry.h"
#include "board/card_item.h"
#include "phases_toolbar.h"
#include "player/menu/player_menu.h"
@ -40,6 +32,15 @@
#include <QSharedPointer>
#include <QtMath>
#include <algorithm>
#include <functional>
#include <libcockatrice/game/abstract_game.h>
#include <libcockatrice/game/board/arrow_data.h>
#include <libcockatrice/game/player/player_actions.h>
#include <libcockatrice/game/player/player_event_handler.h>
#include <libcockatrice/game/player/player_info.h>
#include <libcockatrice/game/player/player_logic.h>
#include <libcockatrice/game/zones/card_zone_logic.h>
#include <libcockatrice/game/zones/view_zone_logic.h>
#include <libcockatrice/settings/interface_settings.h>
#include <libcockatrice/utility/zone_names.h>
#include <numeric>
@ -112,6 +113,15 @@ void GameScene::retranslateUi()
}
}
QList<CardState *> GameScene::selectedCardStates() const
{
QList<CardState *> selectedStates;
for (auto *card : selectedCards()) {
selectedStates.append(card->getState());
}
return selectedStates;
}
QList<CardItem *> GameScene::selectedCards() const
{
QList<CardItem *> selectedCards;
@ -124,6 +134,67 @@ QList<CardItem *> GameScene::selectedCards() const
return selectedCards;
}
/**
* @brief Selects every card of @p zone for which @p filter holds.
* @param filter A predicate to filter which cards are selected.
*/
static void selectCardsInZone(
const CardZoneLogic *zone,
std::function<bool(const CardItem *)> filter = [](const CardItem *) { return true; })
{
if (!zone) {
return;
}
for (auto *cardState : zone->getCards()) {
auto *cardItem = cardState == nullptr ? nullptr : qobject_cast<CardItem *>(cardState->parent());
if (cardItem && filter(cardItem)) {
cardItem->setSelected(true);
}
}
}
void GameScene::selectAllInZone(const CardItem *card)
{
if (!card) {
return;
}
selectCardsInZone(card->getZone());
}
void GameScene::selectSameRow(const CardItem *card)
{
if (!card) {
return;
}
auto isSameRow = [card](const CardItem *cardItem) {
return qAbs(card->scenePos().y() - cardItem->scenePos().y()) < 50;
};
selectCardsInZone(card->getZone(), isSameRow);
}
void GameScene::selectSameColumn(const CardItem *card)
{
if (!card) {
return;
}
auto isSameColumn = [card](const CardItem *cardItem) { return cardItem->x() == card->x(); };
selectCardsInZone(card->getZone(), isSameColumn);
}
CardItem *GameScene::getActiveCard() const
{
return activeCard;
}
void GameScene::setActiveCard(CardItem *card)
{
activeCard = card;
}
void GameScene::onCardSelectionChanged(AbstractCardItem *abstractCard, bool selected)
{
CardItem *card = qobject_cast<CardItem *>(abstractCard);
@ -134,12 +205,12 @@ void GameScene::onCardSelectionChanged(AbstractCardItem *abstractCard, bool sele
auto *owner = card->getOwner();
if (selected) {
owner->requestCardMenuUpdate(card);
owner->requestCardMenuUpdate(card->getState());
return;
}
if (selectedItems().isEmpty()) {
owner->getGame()->setActiveCard(nullptr);
setActiveCard(nullptr);
owner->requestCardMenuUpdate(nullptr);
}
}
@ -158,9 +229,9 @@ void GameScene::onCardRightClicked(AbstractCardItem *abstractCard, QPoint screen
return;
}
card->getOwner()->getGame()->setActiveCard(card);
setActiveCard(card);
if (auto *menu = view->getPlayerMenu()->updateCardMenu(card)) {
if (auto *menu = view->getPlayerMenu()->updateCardMenu(card->getState())) {
menu->popup(screenPos);
}
}
@ -173,7 +244,7 @@ void GameScene::playSelected(AbstractCardItem *card)
if (!card->getOwner()) {
return;
}
card->getOwner()->getPlayerActions()->actPlay(selectedCards());
card->getOwner()->getPlayerActions()->actPlay(selectedCardStates());
}
void GameScene::playSelectedFaceDown(AbstractCardItem *card)
@ -184,7 +255,7 @@ void GameScene::playSelectedFaceDown(AbstractCardItem *card)
if (!card->getOwner()) {
return;
}
card->getOwner()->getPlayerActions()->actPlayFacedown(selectedCards());
card->getOwner()->getPlayerActions()->actPlayFacedown(selectedCardStates());
}
void GameScene::hideSelected(AbstractCardItem *card)
@ -195,7 +266,7 @@ void GameScene::hideSelected(AbstractCardItem *card)
if (!card->getOwner()) {
return;
}
card->getOwner()->getPlayerActions()->actHide(selectedCards());
card->getOwner()->getPlayerActions()->actHide(selectedCardStates());
}
/**
@ -230,6 +301,8 @@ void GameScene::addPlayer(PlayerLogic *player)
[this, id = player->getPlayerInfo()->getId()]() { clearArrowsForPlayerLocally(id); });
connect(player->getPlayerEventHandler(), &PlayerEventHandler::cardZoneChanged, this, &GameScene::onCardZoneChanged);
connect(player->getPlayerEventHandler(), &PlayerEventHandler::cardViewRefreshRequested, this,
&GameScene::onCardViewRefreshRequested);
rearrange();
}
@ -533,7 +606,11 @@ void GameScene::addArrow(QSharedPointer<ArrowData> data)
return;
}
CardItem *startCard = startZone->getCard(data->startCardId);
CardState *startCardState = startZone->getCard(data->startCardId);
if (!startCardState) {
return;
}
CardItem *startCard = qobject_cast<CardItem *>(startCardState->parent());
if (!startCard) {
return;
}
@ -544,7 +621,8 @@ void GameScene::addArrow(QSharedPointer<ArrowData> data)
} else {
auto *zone = targetView->getLogic()->getZones().value(data->targetZone);
if (zone) {
targetItem = zone->getCard(data->targetCardId);
CardState *targetCardState = zone->getCard(data->targetCardId);
targetItem = targetCardState == nullptr ? nullptr : qobject_cast<CardItem *>(targetCardState->parent());
}
}
if (!targetItem) {
@ -572,11 +650,15 @@ void GameScene::requestArrowDeletion(int playerId, int arrowId)
}
}
void GameScene::onCardZoneChanged(CardItem *card, bool sameZone)
void GameScene::onCardZoneChanged(CardState *card, bool sameZone)
{
auto *item = qobject_cast<CardItem *>(card->parent());
if (!item) {
return;
}
QList<ArrowItem *> toDelete;
for (auto *arrow : arrowRegistry.all()) {
if (arrow->getStartItem() == card || arrow->getTargetItem() == card) {
if (arrow->getStartItem() == item || arrow->getTargetItem() == item) {
if (sameZone) {
arrow->updatePath();
} else {
@ -589,6 +671,25 @@ void GameScene::onCardZoneChanged(CardItem *card, bool sameZone)
}
}
void GameScene::onCardViewRefreshRequested(CardState *card, bool zoneChanged, PlayerLogic *newOwner)
{
auto *item = qobject_cast<CardItem *>(card->parent());
if (!item) {
return;
}
if (zoneChanged) {
item->deleteCardInfoPopup();
}
item->deleteDragItem();
if (zoneChanged) {
item->setBeingPointedAt(false);
item->setHovered(false);
}
if (newOwner) {
item->setOwner(newOwner);
}
}
void GameScene::clearArrowsForPlayer(int playerId)
{
for (int arrowId : arrowRegistry.idsForPlayer(playerId)) {

View file

@ -1,7 +1,9 @@
#ifndef GAMESCENE_H
#define GAMESCENE_H
#include "../game/arrow_registry.h"
#include "animated_item.h"
#include "board/arrow_item.h"
#include "board/arrow_registry.h"
#include <QGraphicsScene>
#include <QHash>
@ -16,6 +18,8 @@
#include <QString>
#include <QTransform>
#include <QtGlobal>
#include <libcockatrice/game/board/arrow_data.h>
#include <libcockatrice/game/zones/card_zone_logic.h>
#include <qtmetamacros.h>
// IWYU pragma: keep
@ -39,6 +43,7 @@ class ZoneViewWidget;
class CardZone;
class AbstractCardItem;
class CardItem;
class CardState;
class ServerInfo_Card;
class PhasesToolbar;
class QBasicTimer;
@ -69,6 +74,7 @@ private:
QList<ZoneViewWidget *> zoneViews; ///< Active zone view widgets
QSize viewSize; ///< Current view size
QPointer<CardItem> hoveredCard; ///< Currently hovered card
QPointer<CardItem> activeCard; ///< Card the currently open context menu targets
QBasicTimer *animationTimer; ///< Timer for scene animations
QHash<QObject *, IAnimatedItem *> animatedItems; ///< Items currently animating
QHash<QObject *, QMetaObject::Connection>
@ -107,6 +113,22 @@ public:
/** @brief Gets all selected CardItems. */
QList<CardItem *> selectedCards() const;
QList<CardState *> selectedCardStates() const;
/** @brief Selects every card sharing the zone of @p card. */
void selectAllInZone(const CardItem *card);
/** @brief Selects the cards laid out in the same visual row as @p card. */
void selectSameRow(const CardItem *card);
/** @brief Selects the cards laid out in the same visual column as @p card. */
void selectSameColumn(const CardItem *card);
/** @brief Card the currently open context menu targets, or null. */
CardItem *getActiveCard() const;
/** @brief Sets the card the currently open context menu targets. */
void setActiveCard(CardItem *card);
/**
* @brief Adds a player to the scene and stores their graphics item.
@ -261,7 +283,15 @@ public slots:
/// Queues up arrow deletion but doesn't directly modify the scene
void requestArrowDeletion(int playerId, int arrowId);
void onCardZoneChanged(CardItem *card, bool sameZone);
void onCardZoneChanged(CardState *card, bool sameZone);
/**
* @brief Refreshes the view of a card that the server just moved.
*
* @param card The card that moved.
* @param zoneChanged Whether the card crossed a zone boundary.
* @param newOwner The player the card now belongs to, or nullptr to leave the owner alone.
*/
void onCardViewRefreshRequested(CardState *card, bool zoneChanged, PlayerLogic *newOwner);
protected:
/** @brief Handles hover updates. */

View file

@ -127,6 +127,7 @@ void GameView::startRubberBand(const QPointF &_selectionOrigin)
selectionOrigin = _selectionOrigin;
previousBandRect = QRect();
rubberBand->setGeometry(QRect(mapFromScene(selectionOrigin), QSize(0, 0)));
rubberBand->raise();
rubberBand->show();
}

View file

@ -1,7 +1,5 @@
#include "hand_counter.h"
#include "../game/board/card_list.h"
#include "../game/zones/card_zone_logic.h"
#include "../interface/pixel_map_generator.h"
#include <QGraphicsSceneMouseEvent>
@ -14,6 +12,8 @@
#include <QString>
#include <QStringLiteral>
#include <QTransform>
#include <libcockatrice/game/board/card_list.h>
#include <libcockatrice/game/zones/card_zone_logic.h>
#include <qnamespace.h>
class QGraphicsItem;

View file

@ -3,16 +3,6 @@
#include "../../client/settings/cache_settings.h"
#include "../../client/settings/card_counter_settings.h"
#include "../../client/sound_engine.h"
#include "../../client/translation.h"
#include "../../game/abstract_game.h"
#include "../../game/board/card_list.h"
#include "../../game/game_state.h"
#include "../../game/phase.h"
#include "../../game/player/player_event_handler.h"
#include "../../game/player/player_info.h"
#include "../../game/player/player_logic.h"
#include "../../game/player/player_manager.h"
#include "../../game/zones/card_zone_logic.h"
#include "../../interface/card_localization.h"
#include "../../interface/widgets/server/chat_view/chat_view.h"
#include "../board/card_item.h"
@ -24,6 +14,16 @@
#include <QList>
#include <QSharedPointer>
#include <QStringList>
#include <libcockatrice/game/abstract_game.h>
#include <libcockatrice/game/board/card_list.h>
#include <libcockatrice/game/game_state.h>
#include <libcockatrice/game/phase.h>
#include <libcockatrice/game/player/player_event_handler.h>
#include <libcockatrice/game/player/player_info.h>
#include <libcockatrice/game/player/player_logic.h>
#include <libcockatrice/game/player/player_manager.h>
#include <libcockatrice/game/translation.h>
#include <libcockatrice/game/zones/card_zone_logic.h>
#include <libcockatrice/protocol/pb/context_move_card.pb.h>
#include <libcockatrice/protocol/pb/context_mulligan.pb.h>
#include <libcockatrice/settings/chat_settings.h>
@ -283,7 +283,7 @@ void MessageLogWidget::logDestroyCard(PlayerLogic *player, QString cardName)
}
void MessageLogWidget::logMoveCard(PlayerLogic *player,
CardItem *card,
CardState *card,
CardZoneLogic *startZone,
int oldX,
CardZoneLogic *targetZone,
@ -662,7 +662,7 @@ void MessageLogWidget::logSetActivePlayer(PlayerLogic *player)
QString(tr("%1's turn.")).arg(player->getPlayerInfo()->getName()) + "</b></font><br>");
}
void MessageLogWidget::logSetAnnotation(PlayerLogic *player, CardItem *card, QString newAnnotation)
void MessageLogWidget::logSetAnnotation(PlayerLogic *player, CardState *card, QString newAnnotation)
{
appendHtmlServerMessage(
QString(tr("%1 sets annotation of %2 to %3."))
@ -706,7 +706,7 @@ void MessageLogWidget::logSetCounter(PlayerLogic *player, QString counterName, i
.arg(value - oldValue));
}
void MessageLogWidget::logSetDoesntUntap(PlayerLogic *player, CardItem *card, bool doesntUntap)
void MessageLogWidget::logSetDoesntUntap(PlayerLogic *player, CardState *card, bool doesntUntap)
{
QString str;
if (doesntUntap) {
@ -717,7 +717,7 @@ void MessageLogWidget::logSetDoesntUntap(PlayerLogic *player, CardItem *card, bo
appendHtmlServerMessage(str.arg(sanitizeHtml(player->getPlayerInfo()->getName())).arg(cardLink(card->getName())));
}
void MessageLogWidget::logSetPT(PlayerLogic *player, CardItem *card, QString newPT)
void MessageLogWidget::logSetPT(PlayerLogic *player, CardState *card, QString newPT)
{
if (currentContext == MessageContext_MoveCard) {
return;
@ -755,7 +755,7 @@ void MessageLogWidget::logSetSideboardLock(PlayerLogic *player, bool locked)
}
}
void MessageLogWidget::logSetTapped(PlayerLogic *player, CardItem *card, bool tapped)
void MessageLogWidget::logSetTapped(PlayerLogic *player, CardState *card, bool tapped)
{
if (currentContext == MessageContext_MoveCard) {
return;

View file

@ -14,6 +14,7 @@
#include <qtmetamacros.h>
#include <sys/types.h>
class CardState;
class AbstractGame;
class CardItem;
class GameEventContext;
@ -76,7 +77,7 @@ public slots:
void logLeaveSpectator(QString name, QString reason);
void logNotReadyStart(PlayerLogic *player);
void logMoveCard(PlayerLogic *player,
CardItem *card,
CardState *card,
CardZoneLogic *startZone,
int oldX,
CardZoneLogic *targetZone,
@ -97,13 +98,13 @@ public slots:
void logSay(PlayerLogic *player, QString message);
void logSetActivePhase(int phase);
void logSetActivePlayer(PlayerLogic *player);
void logSetAnnotation(PlayerLogic *player, CardItem *card, QString newAnnotation);
void logSetAnnotation(PlayerLogic *player, CardState *card, QString newAnnotation);
void logSetCardCounter(PlayerLogic *player, QString cardName, int counterId, int value, int oldValue);
void logSetCounter(PlayerLogic *player, QString counterName, int value, int oldValue);
void logSetDoesntUntap(PlayerLogic *player, CardItem *card, bool doesntUntap);
void logSetPT(PlayerLogic *player, CardItem *card, QString newPT);
void logSetDoesntUntap(PlayerLogic *player, CardState *card, bool doesntUntap);
void logSetPT(PlayerLogic *player, CardState *card, QString newPT);
void logSetSideboardLock(PlayerLogic *player, bool locked);
void logSetTapped(PlayerLogic *player, CardItem *card, bool tapped);
void logSetTapped(PlayerLogic *player, CardState *card, bool tapped);
void logShuffle(PlayerLogic *player, CardZoneLogic *zone, int start, int end);
void logSpectatorSay(const ServerInfo_User &spectator, QString message);
void logUnattachCard(PlayerLogic *player, QString cardName);

View file

@ -14,11 +14,6 @@
#include <QTransform>
#include <QtGlobal>
#include <google/protobuf/stubs/port.h>
#include <libcockatrice/protocol/pb/command_draw_cards.pb.h>
#include <libcockatrice/protocol/pb/command_next_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/utility/zone_names.h>
#include <qnamespace.h>
#include <qnumeric.h>
@ -120,9 +115,9 @@ PhasesToolbar::PhasesToolbar(QGraphicsItem *parent)
: QGraphicsItem(parent), width(100), height(100), ySpacing(1), symbolSize(8)
{
auto *aUntapAll = new QAction(this);
connect(aUntapAll, &QAction::triggered, this, &PhasesToolbar::actUntapAll);
connect(aUntapAll, &QAction::triggered, this, &PhasesToolbar::untapAllRequested);
auto *aDrawCard = new QAction(this);
connect(aDrawCard, &QAction::triggered, this, &PhasesToolbar::actDrawCard);
connect(aDrawCard, &QAction::triggered, this, &PhasesToolbar::drawCardRequested);
PhaseButton *untapButton = new PhaseButton("untap", this, aUntapAll);
PhaseButton *upkeepButton = new PhaseButton("upkeep", this);
@ -144,7 +139,7 @@ PhasesToolbar::PhasesToolbar(QGraphicsItem *parent)
}
nextTurnButton = new PhaseButton("nextturn", this, nullptr, false);
connect(nextTurnButton, &PhaseButton::clicked, this, &PhasesToolbar::actNextTurn);
connect(nextTurnButton, &PhaseButton::clicked, this, &PhasesToolbar::nextTurnRequested);
rearrangeButtons();
@ -265,31 +260,5 @@ void PhasesToolbar::phaseButtonClicked()
button->triggerDoubleClickAction();
}
Command_SetActivePhase cmd;
cmd.set_phase(static_cast<google::protobuf::uint32>(buttonList.indexOf(button)));
emit sendGameCommand(cmd, -1);
}
void PhasesToolbar::actNextTurn()
{
emit sendGameCommand(Command_NextTurn(), -1);
}
void PhasesToolbar::actUntapAll()
{
Command_SetCardAttr cmd;
cmd.set_zone(ZoneNames::TABLE);
cmd.set_attribute(AttrTapped);
cmd.set_attr_value("0");
emit sendGameCommand(cmd, -1);
}
void PhasesToolbar::actDrawCard()
{
Command_DrawCards cmd;
cmd.set_number(1);
emit sendGameCommand(cmd, -1);
emit setActivePhaseRequested(buttonList.indexOf(button));
}

View file

@ -17,14 +17,6 @@
class QAction;
class QTimer;
namespace google
{
namespace protobuf
{
class Message;
}
} // namespace google
class PhaseButton : public QObject, public QGraphicsItem
{
Q_OBJECT
@ -95,11 +87,16 @@ public slots:
void triggerPhaseAction(int phase);
private slots:
void phaseButtonClicked();
void actNextTurn();
void actUntapAll();
void actDrawCard();
signals:
void sendGameCommand(const ::google::protobuf::Message &command, int playerId);
/** @name Intent signals
* The view reports what the user asked for; GameEventHandler builds the commands.
* @{
*/
void setActivePhaseRequested(int phase);
void nextTurnRequested();
void untapAllRequested();
void drawCardRequested();
/** @} */
protected:
void paint(QPainter *painter, const QStyleOptionGraphicsItem * /*option*/, QWidget * /*widget*/) override;

View file

@ -3,16 +3,8 @@
#include "../../../client/settings/cache_settings.h"
#include "../../../client/settings/card_counter_settings.h"
#include "../../../client/settings/shortcuts_settings.h"
#include "../../../game/abstract_game.h"
#include "../../../game/player/player_info.h"
#include "../../../game/player/player_manager.h"
#include "../../../game/zones/card_zone_logic.h"
#include "../../board/card_item.h"
#include "../../game/player/player_actions.h"
#include "../../game/player/player_logic.h"
#include "../../game/zones/view_zone_logic.h"
#include "../../game_scene.h"
#include "../card_menu_action_type.h"
#include "../player_graphics_item.h"
#include "libcockatrice/card/card_info.h"
#include "libcockatrice/card/database/card_database_querier.h"
@ -35,6 +27,14 @@
#include <QtGlobal>
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/card/relation/card_relation.h>
#include <libcockatrice/game/abstract_game.h>
#include <libcockatrice/game/player/card_menu_action_type.h>
#include <libcockatrice/game/player/player_actions.h>
#include <libcockatrice/game/player/player_info.h>
#include <libcockatrice/game/player/player_logic.h>
#include <libcockatrice/game/player/player_manager.h>
#include <libcockatrice/game/zones/card_zone_logic.h>
#include <libcockatrice/game/zones/view_zone_logic.h>
#include <libcockatrice/utility/zone_names.h>
#include <qnamespace.h>
#include <utility>
@ -67,7 +67,7 @@ static QAction *makeAction(QObject *parent, Slot &&slot, bool checkable = false,
return a;
}
CardMenu::CardMenu(PlayerGraphicsItem *_player, const CardItem *_card, bool _shortcutsActive)
CardMenu::CardMenu(PlayerGraphicsItem *_player, CardItem *_card, bool _shortcutsActive)
: player(_player), card(_card), shortcutsActive(_shortcutsActive)
{
const QList<PlayerLogic *> &players = player->getLogic()->getGame()->getPlayerManager()->getPlayers().values();
@ -86,7 +86,7 @@ CardMenu::CardMenu(PlayerGraphicsItem *_player, const CardItem *_card, bool _sho
auto *gameScene = player->getGameScene();
// Single selection resolver used by all lambdas — called at trigger time
auto sel = [gameScene]() { return gameScene->selectedCards(); };
auto sel = [gameScene]() { return gameScene->selectedCardStates(); };
// Unified dispatcher for card menu actions
auto invoke = [actions, sel](CardMenuActionType type) {
@ -108,18 +108,18 @@ CardMenu::CardMenu(PlayerGraphicsItem *_player, const CardItem *_card, bool _sho
aHide = makeAction(this, [actions, sel]() { actions->actHide(sel()); });
aReduceLifeByPower = makeAction(this, [actions, sel]() { actions->actReduceLifeByPower(sel()); });
// Actions that use activeCard, not selection — direct connection
// Actions bound to the card this menu was opened for, not to the current selection
aAttach = new QAction(this);
aDrawArrow = new QAction(this);
aSelectAll = new QAction(this);
aSelectRow = new QAction(this);
aSelectColumn = new QAction(this);
connect(aAttach, &QAction::triggered, actions, &PlayerActions::actAttach);
connect(aDrawArrow, &QAction::triggered, actions, &PlayerActions::actDrawArrow);
connect(aSelectAll, &QAction::triggered, actions, &PlayerActions::actSelectAll);
connect(aSelectRow, &QAction::triggered, actions, &PlayerActions::actSelectRow);
connect(aSelectColumn, &QAction::triggered, actions, &PlayerActions::actSelectColumn);
connect(aAttach, &QAction::triggered, this, &CardMenu::attachCard);
connect(aDrawArrow, &QAction::triggered, this, &CardMenu::drawArrow);
connect(aSelectAll, &QAction::triggered, this, [this, gameScene]() { gameScene->selectAllInZone(card); });
connect(aSelectRow, &QAction::triggered, this, [this, gameScene]() { gameScene->selectSameRow(card); });
connect(aSelectColumn, &QAction::triggered, this, [this, gameScene]() { gameScene->selectSameColumn(card); });
aRevealToAll = new QAction(this);
@ -337,7 +337,7 @@ void CardMenu::createHandOrCustomZoneMenu(bool canModifyCard)
initContextualPlayersMenu(revealMenu, aRevealToAll);
connect(revealMenu, &QMenu::triggered, this, [this](QAction *action) {
player->getLogic()->getPlayerActions()->actReveal(player->getGameScene()->selectedCards(), action);
player->getLogic()->getPlayerActions()->actReveal(player->getGameScene()->selectedCardStates(), action);
});
addSeparator();
@ -441,6 +441,7 @@ void CardMenu::addRelatedCardActions()
if (relatedCards.isEmpty()) {
return;
}
auto *playerActions = player->getLogic()->getPlayerActions();
addSeparator();
int index = 0;
@ -484,9 +485,10 @@ void CardMenu::addRelatedCardActions()
}
auto *createRelated = new QAction(text, this);
createRelated->setData(QVariant(index++));
connect(createRelated, &QAction::triggered, player->getLogic()->getPlayerActions(),
&PlayerActions::actCreateRelatedCard);
const int relatedIndex = index++;
connect(createRelated, &QAction::triggered, this, [this, playerActions, relatedIndex] {
playerActions->actCreateRelatedCard(card->getState(), relatedIndex);
});
addAction(createRelated);
}
@ -495,12 +497,26 @@ void CardMenu::addRelatedCardActions()
createRelatedCards->setShortcuts(
SettingsCache::instance().shortcuts().getShortcut("Player/aCreateRelatedTokens"));
}
connect(createRelatedCards, &QAction::triggered, player->getLogic()->getPlayerActions(),
&PlayerActions::actCreateAllRelatedCards);
connect(createRelatedCards, &QAction::triggered, this,
[this, playerActions] { playerActions->actCreateAllRelatedCards(card->getState()); });
addAction(createRelatedCards);
}
}
void CardMenu::attachCard()
{
if (card) {
card->drawAttachArrow();
}
}
void CardMenu::drawArrow()
{
if (card) {
card->drawArrow(Qt::red);
}
}
void CardMenu::retranslateUi()
{
aSelectAll->setText(tr("&Select All"));

View file

@ -27,7 +27,7 @@ signals:
void cardInfoRequested(const CardRef &cardRef);
public:
explicit CardMenu(PlayerGraphicsItem *player, const CardItem *card, bool shortcutsActive);
explicit CardMenu(PlayerGraphicsItem *player, CardItem *card, bool shortcutsActive);
void removePlayer(PlayerLogic *playerToRemove);
void createTableMenu(bool canModifyCard);
void createStackMenu(bool canModifyCard);
@ -53,11 +53,13 @@ public:
private:
PlayerGraphicsItem *player;
const CardItem *card;
CardItem *card;
QList<QPair<QString, int>> playersInfo;
bool shortcutsActive;
void addRelatedCardActions();
void attachCard();
void drawArrow();
void retranslateUi();
void initContextualPlayersMenu(QMenu *menu, QAction *allPlayersAction);
void setShortcutsActive();

View file

@ -1,13 +1,13 @@
#include "custom_zone_menu.h"
#include "../../../game/player/player_info.h"
#include "../../game/player/player_logic.h"
#include "../../game_scene.h"
#include "../player_graphics_item.h"
#include <QAction>
#include <QList>
#include <QVariant>
#include <libcockatrice/game/player/player_info.h>
#include <libcockatrice/game/player/player_logic.h>
CustomZoneMenu::CustomZoneMenu(PlayerGraphicsItem *_player) : player(_player)
{

View file

@ -2,13 +2,7 @@
#include "../../../client/settings/cache_settings.h"
#include "../../../client/settings/shortcuts_settings.h"
#include "../../../game/player/player_info.h"
#include "../../../game/player/player_manager.h"
#include "../../../game/zones/pile_zone_logic.h"
#include "../../../interface/widgets/menus/tearoff_menu.h"
#include "../../game/abstract_game.h"
#include "../../game/player/player_actions.h"
#include "../../game/player/player_logic.h"
#include "../player_graphics_item.h"
#include <QAction>
@ -19,6 +13,12 @@
#include <QObject>
#include <QString>
#include <QVariant>
#include <libcockatrice/game/abstract_game.h>
#include <libcockatrice/game/player/player_actions.h>
#include <libcockatrice/game/player/player_info.h>
#include <libcockatrice/game/player/player_logic.h>
#include <libcockatrice/game/player/player_manager.h>
#include <libcockatrice/game/zones/pile_zone_logic.h>
#include <libcockatrice/utility/zone_names.h>
class QWidget;

View file

@ -2,14 +2,7 @@
#include "../../../client/settings/cache_settings.h"
#include "../../../client/settings/shortcuts_settings.h"
#include "../../../game/board/card_list.h"
#include "../../../game/player/player_info.h"
#include "../../../game/player/player_manager.h"
#include "../../../game/zones/hand_zone_logic.h"
#include "../../../interface/widgets/menus/tearoff_menu.h"
#include "../../game/abstract_game.h"
#include "../../game/player/player_actions.h"
#include "../../game/player/player_logic.h"
#include "../player_graphics_item.h"
#include <QAction>
@ -20,6 +13,13 @@
#include <QObject>
#include <QString>
#include <QVariant>
#include <libcockatrice/game/abstract_game.h>
#include <libcockatrice/game/board/card_list.h>
#include <libcockatrice/game/player/player_actions.h>
#include <libcockatrice/game/player/player_info.h>
#include <libcockatrice/game/player/player_logic.h>
#include <libcockatrice/game/player/player_manager.h>
#include <libcockatrice/game/zones/hand_zone_logic.h>
#include <libcockatrice/utility/zone_names.h>
class QWidget;

View file

@ -2,14 +2,7 @@
#include "../../../client/settings/cache_settings.h"
#include "../../../client/settings/shortcuts_settings.h"
#include "../../../game/board/card_list.h"
#include "../../../game/player/player_info.h"
#include "../../../game/player/player_manager.h"
#include "../../../game/zones/pile_zone_logic.h"
#include "../../../interface/widgets/menus/tearoff_menu.h"
#include "../../game/abstract_game.h"
#include "../../game/player/player_actions.h"
#include "../../game/player/player_logic.h"
#include "../player_graphics_item.h"
#include <QAction>
@ -23,6 +16,13 @@
#include <QObject>
#include <QString>
#include <QVariant>
#include <libcockatrice/game/abstract_game.h>
#include <libcockatrice/game/board/card_list.h>
#include <libcockatrice/game/player/player_actions.h>
#include <libcockatrice/game/player/player_info.h>
#include <libcockatrice/game/player/player_logic.h>
#include <libcockatrice/game/player/player_manager.h>
#include <libcockatrice/game/zones/pile_zone_logic.h>
class QWidget;

View file

@ -2,13 +2,13 @@
#include "../../../client/settings/cache_settings.h"
#include "../../../client/settings/shortcuts_settings.h"
#include "../../game/player/player_actions.h"
#include "../../game/player/player_logic.h"
#include "../../game_scene.h"
#include "../card_menu_action_type.h"
#include "../player_graphics_item.h"
#include <QAction>
#include <libcockatrice/game/player/card_menu_action_type.h>
#include <libcockatrice/game/player/player_actions.h>
#include <libcockatrice/game/player/player_logic.h>
MoveMenu::MoveMenu(PlayerGraphicsItem *player) : QMenu(tr("Move to"))
{
@ -30,7 +30,7 @@ MoveMenu::MoveMenu(PlayerGraphicsItem *player) : QMenu(tr("Move to"))
auto invoke = [player](CardMenuActionType type) {
return [type, player]() {
player->getLogic()->getPlayerActions()->cardMenuAction(player->getGameScene()->selectedCards(), type);
player->getLogic()->getPlayerActions()->cardMenuAction(player->getGameScene()->selectedCardStates(), type);
};
};

View file

@ -2,10 +2,6 @@
#include "../../../client/settings/cache_settings.h"
#include "../../../client/settings/shortcuts_settings.h"
#include "../../../game/abstract_game.h"
#include "../../../game/player/player_info.h"
#include "../../../game/player/player_logic.h"
#include "../../../game/player/player_manager.h"
#include "../../../game_graphics/zones/hand_zone.h"
#include "../../../game_graphics/zones/pile_zone.h"
#include "../../../game_graphics/zones/table_zone.h"
@ -26,6 +22,10 @@
#include <QMenu>
#include <QString>
#include <libcockatrice/game/abstract_game.h>
#include <libcockatrice/game/player/player_info.h>
#include <libcockatrice/game/player/player_logic.h>
#include <libcockatrice/game/player/player_manager.h>
PlayerMenu::PlayerMenu(PlayerGraphicsItem *_player) : QObject(_player), player(_player)
{
@ -87,22 +87,24 @@ void PlayerMenu::setMenusForGraphicItems()
}
}
QMenu *PlayerMenu::updateCardMenu(const CardItem *card)
QMenu *PlayerMenu::updateCardMenu(CardState *card)
{
if (!card) {
emit cardMenuUpdated(nullptr);
return nullptr;
}
auto *cardItem = qobject_cast<CardItem *>(card->parent());
// If is spectator (as spectators don't need card menus), return
// only update the menu if the card is actually selected
if ((player->getLogic()->getGame()->getPlayerManager()->isSpectator() &&
!player->getLogic()->getGame()->getPlayerManager()->isJudge()) ||
player->getLogic()->getGame()->getActiveCard() != card) {
cardItem == nullptr || player->getGameScene()->getActiveCard() != cardItem) {
return nullptr;
}
CardMenu *menu = new CardMenu(player, card, shortcutsActive);
CardMenu *menu = new CardMenu(player, cardItem, shortcutsActive);
connect(menu, &CardMenu::cardInfoRequested, this, &PlayerMenu::cardInfoRequested);
emit cardMenuUpdated(menu);

View file

@ -14,7 +14,7 @@
#include <qtmetamacros.h>
#include <utility>
class CardItem;
class CardState;
class CardMenu;
class PlayerGraphicsItem;
class AbstractPlayerComponent;
@ -43,7 +43,7 @@ signals:
public slots:
void setMenusForGraphicItems();
QMenu *updateCardMenu(const CardItem *card);
QMenu *updateCardMenu(CardState *card);
private slots:
void refreshShortcuts();

View file

@ -2,12 +2,12 @@
#include "../../../client/settings/cache_settings.h"
#include "../../../client/settings/shortcuts_settings.h"
#include "../../game/player/player_actions.h"
#include "../../game/player/player_logic.h"
#include "../../game_scene.h"
#include "../player_graphics_item.h"
#include <QAction>
#include <libcockatrice/game/player/player_actions.h>
#include <libcockatrice/game/player/player_logic.h>
PtMenu::PtMenu(PlayerGraphicsItem *player) : QMenu(tr("Power / toughness"))
{
@ -15,34 +15,35 @@ PtMenu::PtMenu(PlayerGraphicsItem *player) : QMenu(tr("Power / toughness"))
aIncP = new QAction(this);
connect(aIncP, &QAction::triggered, playerActions,
[player, playerActions] { playerActions->actIncP(player->getGameScene()->selectedCards()); });
[player, playerActions] { playerActions->actIncP(player->getGameScene()->selectedCardStates()); });
aDecP = new QAction(this);
connect(aDecP, &QAction::triggered, playerActions,
[player, playerActions] { playerActions->actDecP(player->getGameScene()->selectedCards()); });
[player, playerActions] { playerActions->actDecP(player->getGameScene()->selectedCardStates()); });
aIncT = new QAction(this);
connect(aIncT, &QAction::triggered, playerActions,
[player, playerActions] { playerActions->actIncT(player->getGameScene()->selectedCards()); });
[player, playerActions] { playerActions->actIncT(player->getGameScene()->selectedCardStates()); });
aDecT = new QAction(this);
connect(aDecT, &QAction::triggered, playerActions,
[player, playerActions] { playerActions->actDecT(player->getGameScene()->selectedCards()); });
[player, playerActions] { playerActions->actDecT(player->getGameScene()->selectedCardStates()); });
aIncPT = new QAction(this);
connect(aIncPT, &QAction::triggered, playerActions,
[player, playerActions] { playerActions->actIncPT(player->getGameScene()->selectedCards()); });
[player, playerActions] { playerActions->actIncPT(player->getGameScene()->selectedCardStates()); });
aDecPT = new QAction(this);
connect(aDecPT, &QAction::triggered, playerActions,
[player, playerActions] { playerActions->actDecPT(player->getGameScene()->selectedCards()); });
[player, playerActions] { playerActions->actDecPT(player->getGameScene()->selectedCardStates()); });
aFlowP = new QAction(this);
connect(aFlowP, &QAction::triggered, playerActions,
[player, playerActions] { playerActions->actFlowP(player->getGameScene()->selectedCards()); });
[player, playerActions] { playerActions->actFlowP(player->getGameScene()->selectedCardStates()); });
aFlowT = new QAction(this);
connect(aFlowT, &QAction::triggered, playerActions,
[player, playerActions] { playerActions->actFlowT(player->getGameScene()->selectedCards()); });
[player, playerActions] { playerActions->actFlowT(player->getGameScene()->selectedCardStates()); });
aSetPT = new QAction(this);
connect(aSetPT, &QAction::triggered, playerActions,
[player, playerActions] { playerActions->actRequestSetPTDialog(player->getGameScene()->selectedCards()); });
connect(aSetPT, &QAction::triggered, playerActions, [player, playerActions] {
playerActions->actRequestSetPTDialog(player->getGameScene()->selectedCardStates());
});
aResetPT = new QAction(this);
connect(aResetPT, &QAction::triggered, playerActions,
[player, playerActions] { playerActions->actResetPT(player->getGameScene()->selectedCards()); });
[player, playerActions] { playerActions->actResetPT(player->getGameScene()->selectedCardStates()); });
addAction(aIncP);
addAction(aDecP);

View file

@ -1,10 +1,6 @@
#include "rfg_menu.h"
#include "../../../game/player/player_info.h"
#include "../../../game/zones/pile_zone_logic.h"
#include "../../../interface/widgets/menus/tearoff_menu.h"
#include "../../game/player/player_actions.h"
#include "../../game/player/player_logic.h"
#include "../player_graphics_item.h"
#include <QAction>
@ -12,6 +8,10 @@
#include <QMenu>
#include <QString>
#include <QVariant>
#include <libcockatrice/game/player/player_actions.h>
#include <libcockatrice/game/player/player_info.h>
#include <libcockatrice/game/player/player_logic.h>
#include <libcockatrice/game/zones/pile_zone_logic.h>
#include <libcockatrice/utility/zone_names.h>
class QWidget;

View file

@ -1,14 +1,14 @@
#include "say_menu.h"
#include "../../../client/settings/cache_settings.h"
#include "../../game/player/player_actions.h"
#include "../../game/player/player_logic.h"
#include "../player_graphics_item.h"
#include <QAction>
#include <QKeySequence>
#include <QList>
#include <QString>
#include <libcockatrice/game/player/player_actions.h>
#include <libcockatrice/game/player/player_logic.h>
#include <libcockatrice/settings/message_settings.h>
SayMenu::SayMenu(PlayerGraphicsItem *_player) : player(_player)

View file

@ -2,13 +2,13 @@
#include "../../../client/settings/cache_settings.h"
#include "../../../client/settings/shortcuts_settings.h"
#include "../../../game/player/player_info.h"
#include "../../game/player/player_actions.h"
#include "../../game/player/player_logic.h"
#include "../player_graphics_item.h"
#include <QAction>
#include <QKeySequence>
#include <libcockatrice/game/player/player_actions.h>
#include <libcockatrice/game/player/player_info.h>
#include <libcockatrice/game/player/player_logic.h>
SideboardMenu::SideboardMenu(PlayerGraphicsItem *player, QMenu *playerMenu) : QMenu(playerMenu)
{

View file

@ -2,9 +2,6 @@
#include "../../../client/settings/cache_settings.h"
#include "../../../client/settings/shortcuts_settings.h"
#include "../../../game/player/player_info.h"
#include "../../game/player/player_actions.h"
#include "../../game/player/player_logic.h"
#include "../../game_scene.h"
#include "../player_graphics_item.h"
#include "libcockatrice/card/card_info.h"
@ -14,6 +11,9 @@
#include <QSet>
#include <libcockatrice/deck_list/tree/deck_list_card_node.h>
#include <libcockatrice/deck_list/tree/inner_deck_list_node.h>
#include <libcockatrice/game/player/player_actions.h>
#include <libcockatrice/game/player/player_info.h>
#include <libcockatrice/game/player/player_logic.h>
UtilityMenu::UtilityMenu(PlayerGraphicsItem *_player, QMenu *playerMenu) : QMenu(playerMenu), player(_player)
{
@ -44,7 +44,7 @@ UtilityMenu::UtilityMenu(PlayerGraphicsItem *_player, QMenu *playerMenu) : QMenu
aIncrementAllCardCounters = new QAction(this);
connect(aIncrementAllCardCounters, &QAction::triggered, playerActions, [this]() {
player->getLogic()->getPlayerActions()->actIncrementAllCardCounters(
player->getGameScene()->selectedCards());
player->getGameScene()->selectedCardStates());
});
createPredefinedTokenMenu = new QMenu(QString());

View file

@ -2,11 +2,10 @@
#include "../../client/settings/cache_settings.h"
#include "../../client/settings/card_counter_settings.h"
#include "../../game/player/player_actions.h"
#include "../../game/player/player_logic.h"
#include "../../interface/widgets/utility/get_text_with_max.h"
#include "../board/abstract_counter.h"
#include "../board/card_item.h"
#include "../dialogs/dlg_annotation.h"
#include "../dialogs/dlg_create_token.h"
#include "../dialogs/dlg_move_top_cards_until.h"
#include "../dialogs/dlg_roll_dice.h"
@ -17,6 +16,8 @@
#include <QInputDialog>
#include <QLineEdit>
#include <libcockatrice/card/relation/card_relation.h>
#include <libcockatrice/game/player/player_actions.h>
#include <libcockatrice/game/player/player_logic.h>
#include <libcockatrice/utility/string_limits.h>
PlayerDialogs::PlayerDialogs(PlayerGraphicsItem *_player, PlayerActions *_playerActions)
@ -34,7 +35,7 @@ PlayerDialogs::PlayerDialogs(PlayerGraphicsItem *_player, PlayerActions *_player
&PlayerDialogs::onShuffleBottomDialogRequested);
connect(playerActions, &PlayerActions::requestMulliganDialog, this, &PlayerDialogs::onMulliganDialogRequested);
connect(playerActions, &PlayerActions::startingHandSizeChosen, this, &PlayerDialogs::onStartingHandSizeChosen);
connect(playerActions, &PlayerActions::requestDrawCardsDialog, this, &PlayerDialogs::onDrawCardsDialogRequested);
connect(playerActions, &PlayerActions::requestMoveTopCardsToDialog, this,
@ -126,6 +127,11 @@ void PlayerDialogs::onMulliganDialogRequested(int startSize, int handSize, int d
}
}
void PlayerDialogs::onStartingHandSizeChosen(int number)
{
SettingsCache::instance().userInterface().setStartingHandSize(number);
}
void PlayerDialogs::onDrawCardsDialogRequested(int defaultNumberTopCards, int deckSize)
{
bool ok;
@ -196,7 +202,7 @@ void PlayerDialogs::onRollDieDialogRequested()
playerActions->actRollDie(dlg.getDieSideCount(), dlg.getDiceToRollCount());
}
void PlayerDialogs::onCreateRelatedFromRelationDialogRequested(const CardItem *sourceCard,
void PlayerDialogs::onCreateRelatedFromRelationDialogRequested(const CardState *sourceCard,
const CardRelation *cardRelation)
{
if (sourceCard == nullptr || cardRelation == nullptr) {
@ -251,14 +257,14 @@ void PlayerDialogs::onMoveCardXCardsFromTopDialogRequested(int defaultNumberTopC
number -= 1; // indexes start at 0
if (ok) {
playerActions->actMoveCardXCardsFromTop(player->getGameScene()->selectedCards(), number);
playerActions->actMoveCardXCardsFromTop(player->getGameScene()->selectedCardStates(), number);
}
}
void PlayerDialogs::onSetPTDialogRequested(const QString &oldPT)
{
bool ok;
auto cards = player->getGameScene()->selectedCards();
auto cards = player->getGameScene()->selectedCardStates();
emit requestDialogSemaphore(true);
QString pt = getTextWithMax(dialogParent(), tr("Change power/toughness"), tr("Change stats to:"), QLineEdit::Normal,
oldPT, &ok);
@ -273,7 +279,7 @@ void PlayerDialogs::onSetPTDialogRequested(const QString &oldPT)
void PlayerDialogs::onSetAnnotationDialogRequested(const QString &oldAnnotation)
{
auto cards = player->getGameScene()->selectedCards();
auto cards = player->getGameScene()->selectedCardStates();
emit requestDialogSemaphore(true);
AnnotationDialog *dialog = new AnnotationDialog(dialogParent());
dialog->setOptions(QInputDialog::UsePlainTextEditForTextInput);
@ -291,7 +297,7 @@ void PlayerDialogs::onSetAnnotationDialogRequested(const QString &oldAnnotation)
void PlayerDialogs::onSetCardCounterDialogRequested(int counterId, const QString &oldValueForDlg)
{
auto cards = player->getGameScene()->selectedCards();
auto cards = player->getGameScene()->selectedCardStates();
emit requestDialogSemaphore(true);
auto &cardCounterSettings = SettingsCache::instance().cardCounters();

View file

@ -17,6 +17,7 @@
class CardItem;
class CardRelation;
class CardState;
class PlayerActions;
class QWidget;
struct MoveTopCardsUntilOptions;
@ -52,12 +53,13 @@ public slots:
bool faceDown);
void onDrawBottomCardsDialogRequested(int defaultNumberBottomCards, int maxCards);
void onRollDieDialogRequested();
void onCreateRelatedFromRelationDialogRequested(const CardItem *sourceCard, const CardRelation *cardRelation);
void onCreateRelatedFromRelationDialogRequested(const CardState *sourceCard, const CardRelation *cardRelation);
void onCreateTokenDialogRequested(const QStringList &predefinedTokens);
void onMoveCardXCardsFromTopDialogRequested(int defaultNumberTopCardsToPlaceBelow, int deckSize);
void onSetPTDialogRequested(const QString &oldPT);
void onSetAnnotationDialogRequested(const QString &oldAnnotation);
void onSetCardCounterDialogRequested(int counterId, const QString &oldValueForDlg);
void onStartingHandSizeChosen(int number);
private:
PlayerGraphicsItem *player;

View file

@ -1,15 +1,6 @@
#include "player_graphics_item.h"
#include "../../client/settings/cache_settings.h"
#include "../../game/board/counter_state.h"
#include "../../game/player/player_actions.h"
#include "../../game/player/player_info.h"
#include "../../game/player/player_logic.h"
#include "../../game/zones/card_zone_logic.h"
#include "../../game/zones/hand_zone_logic.h"
#include "../../game/zones/pile_zone_logic.h"
#include "../../game/zones/stack_zone_logic.h"
#include "../../game/zones/table_zone_logic.h"
#include "../../interface/card_picture_loader/card_picture_loader.h"
#include "../../interface/widgets/cards/art_crop_attribution.h"
#include "../../interface/widgets/cards/card_art_utils.h"
@ -45,6 +36,15 @@
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/deck_list/deck_list.h>
#include <libcockatrice/deck_list/playmat_resolver.h>
#include <libcockatrice/game/board/counter_state.h>
#include <libcockatrice/game/player/player_actions.h>
#include <libcockatrice/game/player/player_info.h>
#include <libcockatrice/game/player/player_logic.h>
#include <libcockatrice/game/zones/card_zone_logic.h>
#include <libcockatrice/game/zones/hand_zone_logic.h>
#include <libcockatrice/game/zones/pile_zone_logic.h>
#include <libcockatrice/game/zones/stack_zone_logic.h>
#include <libcockatrice/game/zones/table_zone_logic.h>
#include <libcockatrice/settings/interface_settings.h>
#include <qnamespace.h>
#include <qnumeric.h>
@ -177,6 +177,22 @@ void PlayerGraphicsItem::initializeZones()
void PlayerGraphicsItem::onCustomZoneAdded(QString customZoneName)
{
// Custom zones have no board view, but their cards still need a graphics owner.
// Without one, PileZone::onCardAdded never runs and cards moved from the table
// or hand into a custom zone stay drawn at their old spot. Give them a hidden
// pile zone so added cards are reparented to (0, 0) and hidden.
CardZoneLogic *logic = player->getZone(customZoneName);
if (auto *pileLogic = qobject_cast<PileZoneLogic *>(logic)) {
auto *hiddenZone = new PileZone(pileLogic, this);
hiddenZone->setVisible(false);
hiddenZone->setPos(0, 0);
zoneGraphicsItems.insert(customZoneName, hiddenZone);
// Hide any cards that arrived before the graphics item existed.
for (CardState *state : pileLogic->getCards()) {
hiddenZone->onCardAdded(state);
}
return;
}
zoneGraphicsItems.insert(customZoneName, nullptr); // Custom zone view goes here, if we ever implement it.
}

View file

@ -1,8 +1,6 @@
#include "player_target.h"
#include "../../client/settings/cache_settings.h"
#include "../../game/player/player_info.h"
#include "../../game/player/player_logic.h"
#include "../../interface/pixel_map_generator.h"
#include "../board/abstract_counter.h"
#include "../board/abstract_graphics_item.h"
@ -30,6 +28,8 @@
#include <QTransform>
#include <QtGlobal>
#include <QtMath>
#include <libcockatrice/game/player/player_info.h>
#include <libcockatrice/game/player/player_logic.h>
#include <libcockatrice/protocol/pb/serverinfo_user.pb.h>
#include <qnamespace.h>
#include <qnumeric.h>

View file

@ -16,7 +16,7 @@ static int sumPowers(const QList<CardItem *> &cards)
// calculate total power;
int total = 0;
for (auto card : cards) {
QVariantList parsed = CardItem::parsePT(card->getPT());
QVariantList parsed = CardState::parsePT(card->getPT());
if (!parsed.isEmpty()) {
int power = parsed.first().toInt(); // toInt will default to 0 if it's not an int
total += qMax(power, 0);
@ -44,7 +44,7 @@ static int sumToughness(const QList<CardItem *> &cards)
{
int total = 0;
for (auto card : cards) {
QVariantList parsed = CardItem::parsePT(card->getPT());
QVariantList parsed = CardState::parsePT(card->getPT());
if (parsed.size() == 2) {
int toughness = parsed.at(1).toInt(); // toInt will default to 0 if it's not an int
total += qMax(toughness, 0);

View file

@ -1,13 +1,13 @@
#include "card_zone.h"
#include "../../game/board/card_list.h"
#include "../../game/zones/card_zone_logic.h"
#include "../board/abstract_graphics_item.h"
#include "../board/card_item.h"
#include <QAction>
#include <QGraphicsSceneMouseEvent>
#include <QMenu>
#include <libcockatrice/game/board/card_list.h>
#include <libcockatrice/game/zones/card_zone_logic.h>
#include <qnamespace.h>
class QGraphicsItem;
@ -18,23 +18,60 @@ CardZone::CardZone(CardZoneLogic *_logic, QGraphicsItem *parent)
: AbstractGraphicsItem(parent), menu(nullptr), doubleClickAction(0), logic(_logic)
{
connect(logic, &CardZoneLogic::retranslateUi, this, &CardZone::retranslateUi);
// Invalidation is connected before any consumer so a handler that reads cardItems()
// during the same signal emission always sees a stale flag and rebuilds.
connect(logic, &CardZoneLogic::cardAdded, this, &CardZone::invalidateItems);
connect(logic, &CardZoneLogic::cardCountChanged, this, &CardZone::invalidateItems);
connect(logic, &CardZoneLogic::reorganizeCards, this, &CardZone::invalidateItems);
connect(logic, &CardZoneLogic::cardAdded, this, &CardZone::onCardAdded);
connect(logic, &CardZoneLogic::createCardRequested, this, &CardZone::onCreateCardRequested);
connect(logic, &CardZoneLogic::setGraphicsVisibility, this, [this](bool v) { this->setVisible(v); });
connect(logic, &CardZoneLogic::updateGraphics, this, [this]() { update(); });
connect(logic, &CardZoneLogic::reorganizeCards, this, &CardZone::reorganizeCards);
}
void CardZone::onCardAdded(CardItem *addedCard)
void CardZone::invalidateItems()
{
addedCard->setParentItem(this);
addedCard->setVisible(true);
addedCard->update();
itemsDirty = true;
}
const QList<CardItem *> &CardZone::cardItems() const
{
if (itemsDirty) {
items.clear();
for (auto *cardState : getLogic()->getCards()) {
if (auto *cardItem = qobject_cast<CardItem *>(cardState->parent())) {
items.append(cardItem);
}
}
itemsDirty = false;
}
return items;
}
void CardZone::onCardAdded(CardState *addedCard)
{
auto *addedItem = addedCard == nullptr ? nullptr : qobject_cast<CardItem *>(addedCard->parent());
if (addedItem == nullptr) {
return;
}
addedItem->setParentItem(this);
addedItem->setVisible(true);
addedItem->update();
}
void CardZone::onCreateCardRequested(const ServerInfo_Card &cardInfo, bool reorganize)
{
CardRef cardRef = {QString::fromStdString(cardInfo.name()), QString::fromStdString(cardInfo.provider_id())};
auto *card = new CardItem(getLogic()->getPlayer(), nullptr, cardRef, cardInfo.id());
card->processCardInfo(cardInfo);
getLogic()->addCard(card->getState(), reorganize, cardInfo.x(), cardInfo.y());
}
void CardZone::retranslateUi()
{
for (int i = 0; i < getLogic()->getCards().size(); ++i) {
getLogic()->getCards()[i]->retranslateUi();
for (auto *card : cardItems()) {
card->retranslateUi();
}
}

View file

@ -16,13 +16,17 @@
class CardDragItem;
class CardItem;
class CardState;
class CardZoneLogic;
class QAction;
class QGraphicsItem;
class QMenu;
class QPoint;
class ServerInfo_Card;
template <typename T> class QList;
class CardItem;
inline Q_LOGGING_CATEGORY(CardZoneLog, "card_zone");
/**
@ -55,7 +59,9 @@ public slots:
* Virtual so subclasses (e.g. SelectZone) can override parenting behavior — the Qt signal
* connection in CardZone's constructor dispatches through the vtable.
*/
virtual void onCardAdded(CardItem *addedCard);
virtual void onCardAdded(CardState *addedCard);
/** @brief Builds the card item for @p cardInfo and hands it to this zone's logic. */
void onCreateCardRequested(const ServerInfo_Card &cardInfo, bool reorganize);
public:
enum
@ -76,6 +82,15 @@ public:
return logic;
}
/**
* @brief The CardItems backing getLogic()->getCards(), in zone order.
*
* Game logic talks about cards as states; this is the graphics side's view of the same list.
* It is rebuilt on demand after any content change, so views never reach into the logic's
* container themselves and never observe a stale one.
*/
[[nodiscard]] const QList<CardItem *> &cardItems() const;
void setMenu(QMenu *_menu, QAction *_doubleClickAction = 0)
{
menu = _menu;
@ -83,7 +98,12 @@ public:
}
private:
/** @brief Marks the cached card items stale. Connected to every content-changing signal. */
void invalidateItems();
CardZoneLogic *logic;
mutable QList<CardItem *> items;
mutable bool itemsDirty = true;
};
#endif

View file

@ -1,11 +1,6 @@
#include "hand_zone.h"
#include "../../client/settings/cache_settings.h"
#include "../../game/player/player_actions.h"
#include "../../game/player/player_info.h"
#include "../../game/player/player_logic.h"
#include "../../game/zones/card_zone_logic.h"
#include "../../game/zones/hand_zone_logic.h"
#include "../../interface/theme_manager.h"
#include "../board/card_drag_item.h"
#include "../board/card_item.h"
@ -18,7 +13,11 @@
#include <QPointF>
#include <QRectF>
#include <QString>
#include <libcockatrice/protocol/pb/command_move_card.pb.h>
#include <libcockatrice/game/player/player_actions.h>
#include <libcockatrice/game/player/player_info.h>
#include <libcockatrice/game/player/player_logic.h>
#include <libcockatrice/game/zones/card_zone_logic.h>
#include <libcockatrice/game/zones/hand_zone_logic.h>
#include <libcockatrice/settings/interface_settings.h>
#include <string>
@ -48,8 +47,8 @@ void HandZone::handleDropEvent(const QList<CardDragItem *> &dragItems,
QPoint point = dropPoint + scenePos().toPoint();
int x = -1;
if (SettingsCache::instance().userInterface().getHorizontalHand()) {
for (x = 0; x < getLogic()->getCards().size(); x++) {
if (point.x() < static_cast<CardItem *>(getLogic()->getCards().at(x))->scenePos().x()) {
for (x = 0; x < cardItems().size(); x++) {
if (point.x() < cardItems().at(x)->scenePos().x()) {
break;
}
}
@ -58,19 +57,13 @@ void HandZone::handleDropEvent(const QList<CardDragItem *> &dragItems,
x = calcDropIndexFromY(dropPoint.y(), !sameZone);
}
Command_MoveCard cmd;
cmd.set_start_player_id(startZone->getPlayer()->getPlayerInfo()->getId());
cmd.set_start_zone(startZone->getName().toStdString());
cmd.set_target_player_id(getLogic()->getPlayer()->getPlayerInfo()->getId());
cmd.set_target_zone(getLogic()->getName().toStdString());
cmd.set_x(x);
cmd.set_y(-1);
for (int i = 0; i < dragItems.size(); ++i) {
cmd.mutable_cards_to_move()->add_card()->set_card_id(dragItems[i]->getId());
QList<CardMoveRequest> cards;
cards.reserve(dragItems.size());
for (const CardDragItem *item : dragItems) {
cards.append(CardMoveRequest{item->getId()});
}
getLogic()->getPlayer()->getPlayerActions()->sendGameCommand(cmd);
getLogic()->getPlayer()->getPlayerActions()->moveCards(startZone, getLogic(), x, -1, cards);
}
QRectF HandZone::boundingRect() const
@ -90,23 +83,23 @@ void HandZone::paint(QPainter *painter, const QStyleOptionGraphicsItem * /*optio
void HandZone::reorganizeCards()
{
if (!getLogic()->getCards().isEmpty()) {
const int cardCount = getLogic()->getCards().size();
if (!cardItems().isEmpty()) {
const int cardCount = cardItems().size();
if (SettingsCache::instance().userInterface().getHorizontalHand()) {
bool leftJustified = SettingsCache::instance().userInterface().getLeftJustified();
qreal cardWidth = getLogic()->getCards().at(0)->boundingRect().width();
qreal cardWidth = cardItems().at(0)->boundingRect().width();
const int xPadding = leftJustified ? cardWidth * 1.4 : 5;
qreal totalWidth =
leftJustified ? boundingRect().width() - (1 * xPadding) - 5 : boundingRect().width() - 2 * xPadding;
if (cardCount == 1) {
CardItem *c = getLogic()->getCards().at(0);
CardItem *c = cardItems().at(0);
qreal xPosition = leftJustified ? xPadding : xPadding + (totalWidth - cardWidth) / 2;
c->setPos(xPosition, 5);
c->setRealZValue(0);
} else {
for (int i = 0; i < cardCount; i++) {
CardItem *c = getLogic()->getCards().at(i);
CardItem *c = cardItems().at(i);
// If the total width of the cards is smaller than the available width,
// the cards do not need to overlap and are displayed in the center of the area.
if (cardWidth * cardCount > totalWidth) {
@ -131,7 +124,7 @@ void HandZone::reorganizeCards()
void HandZone::sortHand(const QList<CardList::SortOption> &options)
{
if (getLogic()->getCards().isEmpty()) {
if (cardItems().isEmpty()) {
return;
}

View file

@ -7,10 +7,10 @@
#ifndef HANDZONE_H
#define HANDZONE_H
#include "../../game/board/card_list.h"
#include "select_zone.h"
#include <QtGlobal>
#include <libcockatrice/game/board/card_list.h>
#include <qtmetamacros.h>
class HandZoneLogic;

View file

@ -1,12 +1,6 @@
#include "pile_zone.h"
#include "../../client/settings/cache_settings.h"
#include "../../game/board/card_list.h"
#include "../../game/player/player_actions.h"
#include "../../game/player/player_info.h"
#include "../../game/player/player_logic.h"
#include "../../game/zones/card_zone_logic.h"
#include "../../game/zones/pile_zone_logic.h"
#include "../board/card_drag_item.h"
#include "../board/card_item.h"
#include "../card_dimensions.h"
@ -24,7 +18,12 @@
#include <QString>
#include <QTransform>
#include <QtGlobal>
#include <libcockatrice/protocol/pb/command_move_card.pb.h>
#include <libcockatrice/game/board/card_list.h>
#include <libcockatrice/game/player/player_actions.h>
#include <libcockatrice/game/player/player_info.h>
#include <libcockatrice/game/player/player_logic.h>
#include <libcockatrice/game/zones/card_zone_logic.h>
#include <libcockatrice/game/zones/pile_zone_logic.h>
#include <libcockatrice/settings/cards_display_settings.h>
#include <qnamespace.h>
#include <string>
@ -67,36 +66,35 @@ void PileZone::paint(QPainter *painter, const QStyleOptionGraphicsItem * /*optio
{
painter->drawPath(shape());
if (!getLogic()->getCards().isEmpty()) {
getLogic()->getCards().at(0)->paintPicture(painter, getLogic()->getCards().at(0)->getTranslatedSize(painter),
90);
if (!cardItems().isEmpty()) {
cardItems().at(0)->paintPicture(painter, cardItems().at(0)->getTranslatedSize(painter), 90);
}
painter->translate(CardDimensions::WIDTH_HALF_F, CardDimensions::HEIGHT_HALF_F);
painter->rotate(-90);
painter->translate(-CardDimensions::WIDTH_HALF_F, -CardDimensions::HEIGHT_HALF_F);
paintNumberEllipse(getLogic()->getCards().size(), 28, Qt::white, -1, -1, painter);
paintNumberEllipse(cardItems().size(), 28, Qt::white, -1, -1, painter);
}
void PileZone::handleDropEvent(const QList<CardDragItem *> &dragItems, CardZoneLogic *startZone, const QPoint &)
{
Command_MoveCard cmd;
cmd.set_start_player_id(startZone->getPlayer()->getPlayerInfo()->getId());
cmd.set_start_zone(startZone->getName().toStdString());
cmd.set_target_player_id(getLogic()->getPlayer()->getPlayerInfo()->getId());
cmd.set_target_zone(getLogic()->getName().toStdString());
cmd.set_x(0);
cmd.set_y(0);
for (int i = 0; i < dragItems.size(); ++i) {
auto cardToMove = cmd.mutable_cards_to_move()->add_card();
cardToMove->set_card_id(dragItems[i]->getId());
if (dragItems[i]->isForceFaceDown()) {
cardToMove->set_face_down(true);
}
QList<CardMoveRequest> cards;
cards.reserve(dragItems.size());
for (const CardDragItem *item : dragItems) {
cards.append(CardMoveRequest{item->getId(), item->isForceFaceDown()});
}
getLogic()->getPlayer()->getPlayerActions()->sendGameCommand(cmd);
getLogic()->getPlayer()->getPlayerActions()->moveCards(startZone, getLogic(), 0, 0, cards);
}
void PileZone::onCardAdded(CardState *addedCard)
{
CardZone::onCardAdded(addedCard);
auto *addedItem = addedCard == nullptr ? nullptr : qobject_cast<CardItem *>(addedCard->parent());
if (addedItem) {
addedItem->setPos(0, 0);
addedItem->setVisible(false);
}
}
void PileZone::reorganizeCards()
@ -126,15 +124,14 @@ void PileZone::mouseMoveEvent(QGraphicsSceneMouseEvent *event)
return;
}
if (getLogic()->getCards().isEmpty()) {
if (cardItems().isEmpty()) {
return;
}
bool forceFaceDown = event->modifiers().testFlag(Qt::ShiftModifier);
bool bottomCard = event->modifiers().testFlag(Qt::ControlModifier);
CardItem *card = bottomCard ? getLogic()->getCards().last() : getLogic()->getCards().first();
const int cardid =
getLogic()->contentsKnown() ? card->getId() : (bottomCard ? getLogic()->getCards().size() - 1 : 0);
CardItem *card = bottomCard ? cardItems().last() : cardItems().first();
const int cardid = getLogic()->contentsKnown() ? card->getId() : (bottomCard ? cardItems().size() - 1 : 0);
CardDragItem *drag = card->createDragItem(cardid, event->pos(), event->scenePos(), forceFaceDown);
drag->grabMouse();
setCursor(Qt::OpenHandCursor);
@ -147,8 +144,8 @@ void PileZone::mouseReleaseEvent(QGraphicsSceneMouseEvent * /*event*/)
void PileZone::hoverEnterEvent(QGraphicsSceneHoverEvent *event)
{
if (!getLogic()->getCards().isEmpty()) {
getLogic()->getCards()[0]->processHoverEvent();
if (!cardItems().isEmpty()) {
cardItems()[0]->processHoverEvent();
}
QGraphicsItem::hoverEnterEvent(event);
}

View file

@ -33,6 +33,7 @@ public:
[[nodiscard]] QPainterPath shape() const override;
void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) override;
void reorganizeCards() override;
void onCardAdded(CardState *addedCard) override;
void
handleDropEvent(const QList<CardDragItem *> &dragItems, CardZoneLogic *startZone, const QPoint &dropPoint) override;

View file

@ -1,8 +1,6 @@
#include "select_zone.h"
#include "../../client/settings/cache_settings.h"
#include "../../game/board/card_list.h"
#include "../../game/zones/card_zone_logic.h"
#include "../board/card_item.h"
#include "../game_scene.h"
@ -17,6 +15,8 @@
#include <QTransform>
#include <QtGlobal>
#include <QtMath>
#include <libcockatrice/game/board/card_list.h>
#include <libcockatrice/game/zones/card_zone_logic.h>
#include <libcockatrice/settings/cards_display_settings.h>
#include <qnamespace.h>
#include <qnumeric.h>
@ -85,7 +85,7 @@ SelectZone *SelectZone::findOwningSelectZone(const QGraphicsItem *card)
SelectZone::StackLayoutParams SelectZone::buildStackParams(qreal minOffset) const
{
const auto &cards = getLogic()->getCards();
const auto &cards = cardItems();
if (cards.isEmpty()) {
return {0, boundingRect().height(), 0.0, 0.0, minOffset};
}
@ -97,7 +97,7 @@ SelectZone::StackLayoutParams SelectZone::buildStackParams(qreal minOffset) cons
int SelectZone::calcDropIndexFromY(qreal dropY, bool allowCountExpand, qreal minOffset) const
{
const auto &cards = getLogic()->getCards();
const auto &cards = cardItems();
if (cards.isEmpty()) {
return 0;
}
@ -115,7 +115,7 @@ void SelectZone::restoreStaleEscapedCards()
if (!cardClipContainer) {
return;
}
for (auto *card : getLogic()->getCards()) {
for (auto *card : cardItems()) {
// A card parented to the zone (instead of the clip container) should
// only occur while it is actively hovered. If hover cleanup was
// missed, reparent it back so clipping resumes.
@ -127,7 +127,7 @@ void SelectZone::restoreStaleEscapedCards()
void SelectZone::layoutCardsVertically(const StackLayoutParams &params)
{
const auto &cards = getLogic()->getCards();
const auto &cards = cardItems();
if (cards.isEmpty() || params.cardCount <= 0) {
return;
}
@ -162,9 +162,9 @@ SelectZone::~SelectZone()
if (cardClipContainer) {
// Reparent any hover-escaped cards back to the clip container so Qt's
// parent-child tree is consistent for destruction. setParentItem() does
// not invalidate getLogic()->getCards() (it modifies the graphics tree,
// not the zone's logical card list).
for (auto *card : getLogic()->getCards()) {
// not invalidate cardItems() — it only moves the item in the graphics
// tree, it never changes the zone's contents.
for (auto *card : cardItems()) {
if (card && card->parentItem() == this) {
card->setParentItem(cardClipContainer);
}
@ -172,12 +172,13 @@ SelectZone::~SelectZone()
}
}
void SelectZone::onCardAdded(CardItem *addedCard)
void SelectZone::onCardAdded(CardState *addedCard)
{
if (cardClipContainer && addedCard) {
addedCard->setParentItem(cardClipContainer);
addedCard->setVisible(true);
addedCard->update();
auto *addedItem = addedCard == nullptr ? nullptr : qobject_cast<CardItem *>(addedCard->parent());
if (cardClipContainer && addedItem) {
addedItem->setParentItem(cardClipContainer);
addedItem->setVisible(true);
addedItem->update();
} else {
CardZone::onCardAdded(addedCard);
}
@ -247,7 +248,7 @@ void SelectZone::mouseMoveEvent(QGraphicsSceneMouseEvent *event)
}
QRectF selectionRect = QRectF(selectionOrigin, pos).normalized();
for (auto card : getLogic()->getCards()) {
for (auto card : cardItems()) {
if (card->getAttachedTo() && card->getAttachedTo()->getZone() != getLogic()) {
continue;
}

View file

@ -35,7 +35,7 @@ public:
SelectZone(CardZoneLogic *logic, QGraphicsItem *parent = nullptr);
~SelectZone() override;
void onCardAdded(CardItem *addedCard) override;
void onCardAdded(CardState *addedCard) override;
/**
* @brief Temporarily reparents a card from the clip container to this zone so hover scaling is visible beyond clip

View file

@ -1,11 +1,5 @@
#include "stack_zone.h"
#include "../../game/board/card_list.h"
#include "../../game/player/player_actions.h"
#include "../../game/player/player_info.h"
#include "../../game/player/player_logic.h"
#include "../../game/zones/card_zone_logic.h"
#include "../../game/zones/stack_zone_logic.h"
#include "../../interface/theme_manager.h"
#include "../board/card_drag_item.h"
#include "../board/card_item.h"
@ -19,7 +13,12 @@
#include <QPoint>
#include <QRectF>
#include <QString>
#include <libcockatrice/protocol/pb/command_move_card.pb.h>
#include <libcockatrice/game/board/card_list.h>
#include <libcockatrice/game/player/player_actions.h>
#include <libcockatrice/game/player/player_info.h>
#include <libcockatrice/game/player/player_logic.h>
#include <libcockatrice/game/zones/card_zone_logic.h>
#include <libcockatrice/game/zones/stack_zone_logic.h>
#include <qnumeric.h>
#include <string>
@ -73,31 +72,21 @@ void StackZone::handleDropEvent(const QList<CardDragItem *> &dragItems,
int index = calcDropIndexFromY(dropPoint.y(), !sameZone, MIN_CARD_VISIBLE);
if (sameZone) {
// Same-zone no-op: don't move a card onto itself
const auto &cards = getLogic()->getCards();
const auto &cards = cardItems();
if (!cards.isEmpty() && cards.at(index)->getId() == dragItems.at(0)->getId()) {
return;
}
}
Command_MoveCard cmd;
cmd.set_start_player_id(startZone->getPlayer()->getPlayerInfo()->getId());
cmd.set_start_zone(startZone->getName().toStdString());
cmd.set_target_player_id(getLogic()->getPlayer()->getPlayerInfo()->getId());
cmd.set_target_zone(getLogic()->getName().toStdString());
cmd.set_x(index);
cmd.set_y(0);
QList<CardMoveRequest> cards;
cards.reserve(dragItems.size());
for (const CardDragItem *item : dragItems) {
if (item) {
auto *cardToMove = cmd.mutable_cards_to_move()->add_card();
cardToMove->set_card_id(item->getId());
if (item->isForceFaceDown()) {
cardToMove->set_face_down(true);
}
cards.append(CardMoveRequest{item->getId(), item->isForceFaceDown()});
}
}
getLogic()->getPlayer()->getPlayerActions()->sendGameCommand(cmd);
getLogic()->getPlayer()->getPlayerActions()->moveCards(startZone, getLogic(), index, 0, cards);
}
void StackZone::setHeight(qreal newHeight)
@ -113,7 +102,7 @@ void StackZone::setHeight(qreal newHeight)
void StackZone::reorganizeCards()
{
if (!getLogic()->getCards().isEmpty()) {
if (!cardItems().isEmpty()) {
const auto params = buildStackParams(MIN_CARD_VISIBLE);
layoutCardsVertically(params);
}

View file

@ -1,12 +1,6 @@
#include "table_zone.h"
#include "../../client/settings/cache_settings.h"
#include "../../game/board/card_list.h"
#include "../../game/player/player_actions.h"
#include "../../game/player/player_info.h"
#include "../../game/player/player_logic.h"
#include "../../game/zones/card_zone_logic.h"
#include "../../game/zones/table_zone_logic.h"
#include "../../interface/theme_manager.h"
#include "../board/abstract_card_item.h"
#include "../board/card_drag_item.h"
@ -32,8 +26,12 @@
#include <algorithm>
#include <iterator>
#include <libcockatrice/card/card_info.h>
#include <libcockatrice/protocol/pb/command_move_card.pb.h>
#include <libcockatrice/protocol/pb/command_set_card_attr.pb.h>
#include <libcockatrice/game/board/card_list.h>
#include <libcockatrice/game/player/player_actions.h>
#include <libcockatrice/game/player/player_info.h>
#include <libcockatrice/game/player/player_logic.h>
#include <libcockatrice/game/zones/card_zone_logic.h>
#include <libcockatrice/game/zones/table_zone_logic.h>
#include <libcockatrice/settings/interface_settings.h>
#include <libcockatrice/utility/zone_names.h>
#include <string>
@ -62,7 +60,8 @@ TableZone::TableZone(TableZoneLogic *_logic, bool _mirrored, QGraphicsItem *pare
updateBg();
height = MARGIN_TOP + MARGIN_BOTTOM + TABLEROWS * CardDimensions::HEIGHT + (TABLEROWS - 1) * PADDING_Y;
height = MARGIN_TOP + MARGIN_BOTTOM + TableZoneLogic::ROW_COUNT * CardDimensions::HEIGHT +
(TableZoneLogic::ROW_COUNT - 1) * PADDING_Y;
width = MIN_WIDTH;
currentMinimumWidth = width;
@ -208,29 +207,20 @@ void TableZone::handleDropEventByGrid(const QList<CardDragItem *> &dragItems,
CardZoneLogic *startZone,
const QPoint &gridPoint)
{
Command_MoveCard cmd;
cmd.set_start_player_id(startZone->getPlayer()->getPlayerInfo()->getId());
cmd.set_start_zone(startZone->getName().toStdString());
cmd.set_target_player_id(getLogic()->getPlayer()->getPlayerInfo()->getId());
cmd.set_target_zone(getLogic()->getName().toStdString());
cmd.set_x(gridPoint.x());
cmd.set_y(gridPoint.y());
for (const auto &item : dragItems) {
CardToMove *ctm = cmd.mutable_cards_to_move()->add_card();
ctm->set_card_id(item->getId());
if (item->isForceFaceDown()) {
ctm->set_face_down(true);
}
QList<CardMoveRequest> cards;
cards.reserve(dragItems.size());
for (const CardDragItem *item : dragItems) {
CardMoveRequest card{item->getId(), item->isForceFaceDown()};
if (startZone->getName() != getLogic()->getName() && !item->isForceFaceDown()) {
const auto &card = item->getItem()->getCard();
if (card) {
ctm->set_pt(card.getInfo().getPowTough().toStdString());
const auto &cardInfo = item->getItem()->getCard();
if (cardInfo) {
card.pt = cardInfo.getInfo().getPowTough();
}
}
cards.append(card);
}
startZone->getPlayer()->getPlayerActions()->sendGameCommand(cmd);
startZone->getPlayer()->getPlayerActions()->moveCards(startZone, getLogic(), gridPoint.x(), gridPoint.y(), cards);
}
void TableZone::reorganizeCards()
@ -238,8 +228,8 @@ void TableZone::reorganizeCards()
// Calculate card stack widths so mapping functions work properly
computeCardStackWidths();
for (int i = 0; i < getLogic()->getCards().size(); ++i) {
QPoint gridPoint = getLogic()->getCards()[i]->getGridPos();
for (int i = 0; i < cardItems().size(); ++i) {
QPoint gridPoint = cardItems()[i]->getGridPos();
if (gridPoint.x() == -1) {
continue;
}
@ -248,21 +238,24 @@ void TableZone::reorganizeCards()
qreal x = mapPoint.x();
qreal y = mapPoint.y();
int numberAttachedCards = getLogic()->getCards()[i]->getAttachedCards().size();
int numberAttachedCards = cardItems()[i]->getAttachedCards().size();
qreal actualX = x + numberAttachedCards * STACKED_CARD_OFFSET_X;
qreal actualY = y;
if (numberAttachedCards) {
actualY += 15;
}
getLogic()->getCards()[i]->setPos(actualX, actualY);
getLogic()->getCards()[i]->setRealZValue(ZValues::tableCardZValue(actualX, actualY));
cardItems()[i]->setPos(actualX, actualY);
cardItems()[i]->setRealZValue(ZValues::tableCardZValue(actualX, actualY));
QListIterator<CardItem *> attachedCardIterator(getLogic()->getCards()[i]->getAttachedCards());
QListIterator<CardState *> attachedCardIterator(cardItems()[i]->getAttachedCards());
int j = 0;
while (attachedCardIterator.hasNext()) {
++j;
CardItem *attachedCard = attachedCardIterator.next();
CardItem *attachedCard = qobject_cast<CardItem *>(attachedCardIterator.next()->parent());
if (attachedCard == nullptr) {
continue;
}
qreal childX = actualX - j * STACKED_CARD_OFFSET_X;
qreal childY = y + 5;
attachedCard->setPos(childX, childY);
@ -276,35 +269,18 @@ void TableZone::reorganizeCards()
void TableZone::toggleTapped()
{
QList<QGraphicsItem *> selectedItemsRaw = scene()->selectedItems();
QList<QGraphicsItem *> selectedItems;
auto isCardOnTable = [](const QGraphicsItem *item) {
if (auto card = qgraphicsitem_cast<const CardItem *>(item)) {
return card->getZone()->getName() == ZoneNames::TABLE;
}
return false;
};
std::copy_if(selectedItemsRaw.begin(), selectedItemsRaw.end(), std::back_inserter(selectedItems), isCardOnTable);
bool tapAll = std::any_of(selectedItems.begin(), selectedItems.end(), [](const QGraphicsItem *item) {
return !qgraphicsitem_cast<const CardItem *>(item)->getTapped();
});
QList<const ::google::protobuf::Message *> cmdList;
for (const auto &selectedItem : selectedItems) {
CardItem *temp = qgraphicsitem_cast<CardItem *>(selectedItem);
if (temp->getTapped() != tapAll) {
Command_SetCardAttr *cmd = new Command_SetCardAttr;
cmd->set_zone(getLogic()->getName().toStdString());
cmd->set_card_id(temp->getId());
cmd->set_attribute(AttrTapped);
cmd->set_attr_value(tapAll ? "1" : "0");
cmdList.append(cmd);
QList<CardState *> selectedCards;
for (auto *item : scene()->selectedItems()) {
auto *card = qgraphicsitem_cast<CardItem *>(item);
if (card && card->getZone()->getName() == ZoneNames::TABLE) {
selectedCards.append(card->getState());
}
}
getLogic()->getPlayer()->getPlayerActions()->sendGameCommand(
getLogic()->getPlayer()->getPlayerActions()->prepareGameCommand(cmdList));
const bool tapAll = std::any_of(selectedCards.begin(), selectedCards.end(),
[](const CardState *card) { return !card->getTapped(); });
getLogic()->getPlayer()->getPlayerActions()->actToggleTapped(getLogic()->getName(), selectedCards, tapAll);
}
void TableZone::resizeToContents()
@ -312,9 +288,9 @@ void TableZone::resizeToContents()
int xMax = 0;
// Find rightmost card position, which includes the left margin amount.
for (int i = 0; i < getLogic()->getCards().size(); ++i) {
if (getLogic()->getCards()[i]->pos().x() > xMax) {
xMax = (int)getLogic()->getCards()[i]->pos().x();
for (int i = 0; i < cardItems().size(); ++i) {
if (cardItems()[i]->pos().x() > xMax) {
xMax = (int)cardItems()[i]->pos().x();
}
}
@ -335,9 +311,9 @@ void TableZone::resizeToContents()
CardItem *TableZone::getCardFromGrid(const QPoint &gridPoint) const
{
for (int i = 0; i < getLogic()->getCards().size(); i++) {
if (getLogic()->getCards().at(i)->getGridPoint() == gridPoint) {
return getLogic()->getCards().at(i);
for (int i = 0; i < cardItems().size(); i++) {
if (cardItems().at(i)->getGridPoint() == gridPoint) {
return cardItems().at(i);
}
}
return 0;
@ -354,8 +330,8 @@ void TableZone::computeCardStackWidths()
// Each card stack is three grid points worth of card locations.
// First pass: compute the number of cards at each card stack.
QMap<int, int> cardStackCount;
for (int i = 0; i < getLogic()->getCards().size(); ++i) {
const QPoint &gridPoint = getLogic()->getCards()[i]->getGridPos();
for (int i = 0; i < cardItems().size(); ++i) {
const QPoint &gridPoint = cardItems()[i]->getGridPos();
if (gridPoint.x() == -1) {
continue;
}
@ -366,8 +342,8 @@ void TableZone::computeCardStackWidths()
// Second pass: compute the width at each card stack.
cardStackWidth.clear();
for (int i = 0; i < getLogic()->getCards().size(); ++i) {
const QPoint &gridPoint = getLogic()->getCards()[i]->getGridPos();
for (int i = 0; i < cardItems().size(); ++i) {
const QPoint &gridPoint = cardItems()[i]->getGridPos();
if (gridPoint.x() == -1) {
continue;
}
@ -375,8 +351,8 @@ void TableZone::computeCardStackWidths()
const int key = getCardStackMapKey(gridPoint.x() / 3, gridPoint.y());
const int stackCount = cardStackCount.value(key, 0);
if (stackCount == 1) {
cardStackWidth.insert(key, CardDimensions::WIDTH + getLogic()->getCards()[i]->getAttachedCards().size() *
STACKED_CARD_OFFSET_X);
cardStackWidth.insert(key, CardDimensions::WIDTH +
cardItems()[i]->getAttachedCards().size() * STACKED_CARD_OFFSET_X);
} else {
cardStackWidth.insert(key, CardDimensions::WIDTH + (stackCount - 1) * STACKED_CARD_OFFSET_X);
}
@ -397,7 +373,7 @@ QPointF TableZone::mapFromGrid(QPoint gridPoint) const
}
if (isInverted()) {
gridPoint.setY(TABLEROWS - 1 - gridPoint.y());
gridPoint.setY(TableZoneLogic::ROW_COUNT - 1 - gridPoint.y());
}
// Start with margin plus stacked card offset
@ -423,10 +399,10 @@ QPoint TableZone::mapToGrid(const QPointF &mapPoint) const
const int gridPointHeight = CardDimensions::HEIGHT + PADDING_Y;
int gridPointY = (y + PADDING_Y / 2) / gridPointHeight;
gridPointY = clampValidTableRow(gridPointY);
gridPointY = TableZoneLogic::clampValidTableRow(gridPointY);
if (isInverted()) {
gridPointY = TABLEROWS - 1 - gridPointY;
gridPointY = TableZoneLogic::ROW_COUNT - 1 - gridPointY;
}
// Calculating the x-coordinate of the grid space requires adding up the
@ -471,22 +447,3 @@ QPointF TableZone::closestGridPoint(const QPointF &point)
}
return mapFromGrid(gridPoint);
}
int TableZone::clampValidTableRow(const int row)
{
if (row < 0) {
return 0;
}
if (row >= TABLEROWS) {
return TABLEROWS - 1;
}
return row;
}
int TableZone::tableRowToGridY(int tableRow)
{
if (tableRow > 2) {
tableRow = 1;
}
return clampValidTableRow(2 - tableRow);
}

View file

@ -17,6 +17,7 @@
#include <QPoint>
#include <QPointF>
#include <QtGlobal>
#include <libcockatrice/game/zones/table_zone_logic.h>
#include <qtmetamacros.h>
class CardDragItem;
@ -25,7 +26,6 @@ class CardZoneLogic;
class QColor;
class QGraphicsItem;
class QPainter;
class TableZoneLogic;
template <typename T> class QList;
/**
@ -42,8 +42,6 @@ signals:
void sizeChanged();
private:
static const int TABLEROWS = 3;
/*
Margins between table edges and cards, paddings between cards
*/
@ -181,15 +179,6 @@ public:
QPointF closestGridPoint(const QPointF &point) override;
static int clampValidTableRow(const int row);
/**
* Converts a card's logical table row (0=creatures, 1=noncreatures, 2=lands)
* to the corresponding grid Y coordinate. Cards with tableRow > 2 (e.g.,
* instants/sorceries) default to the noncreatures row.
*/
static int tableRowToGridY(int tableRow);
/**
Resizes the TableZone in case CardItems are within or
outside of the TableZone constraints.

View file

@ -1,11 +1,6 @@
#include "view_zone.h"
#include "../../client/settings/cache_settings.h"
#include "../../game/player/player_actions.h"
#include "../../game/player/player_info.h"
#include "../../game/player/player_logic.h"
#include "../../game/zones/card_zone_logic.h"
#include "../../game/zones/view_zone_logic.h"
#include "../board/card_drag_item.h"
#include "../board/card_item.h"
#include "../card_dimensions.h"
@ -26,8 +21,11 @@
#include <QtMath>
#include <functional>
#include <libcockatrice/card/card_localization.h>
#include <libcockatrice/protocol/pb/command_dump_zone.pb.h>
#include <libcockatrice/protocol/pb/command_move_card.pb.h>
#include <libcockatrice/game/player/player_actions.h>
#include <libcockatrice/game/player/player_info.h>
#include <libcockatrice/game/player/player_logic.h>
#include <libcockatrice/game/zones/card_zone_logic.h>
#include <libcockatrice/game/zones/view_zone_logic.h>
#include <libcockatrice/protocol/pb/response_dump_zone.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_card.pb.h>
#include <libcockatrice/protocol/pending_command.h>
@ -42,21 +40,23 @@ class QGraphicsItem;
ZoneViewZone::ZoneViewZone(ZoneViewZoneLogic *_logic, QGraphicsItem *parent)
: SelectZone(_logic, parent), bRect(QRectF()), minRows(0), groupBy(CardList::NoSort), sortBy(CardList::NoSort)
{
if (!(qobject_cast<ZoneViewZoneLogic *>(getLogic())->getRevealZone() &&
!qobject_cast<ZoneViewZoneLogic *>(getLogic())->getWriteableRevealZone())) {
qobject_cast<ZoneViewZoneLogic *>(getLogic())->getOriginalZone()->getViews().append(this);
auto *viewLogic = qobject_cast<ZoneViewZoneLogic *>(getLogic());
if (!(viewLogic->getRevealZone() && !viewLogic->getWriteableRevealZone())) {
viewLogic->getOriginalZone()->getViews().append(viewLogic);
}
connect(_logic, &ZoneViewZoneLogic::closeView, this, &ZoneViewZone::close);
}
void ZoneViewZone::addToViews()
{
qobject_cast<ZoneViewZoneLogic *>(getLogic())->getOriginalZone()->getViews().append(this);
auto *viewLogic = qobject_cast<ZoneViewZoneLogic *>(getLogic());
viewLogic->getOriginalZone()->getViews().append(viewLogic);
}
void ZoneViewZone::removeFromViews()
{
qobject_cast<ZoneViewZoneLogic *>(getLogic())->getOriginalZone()->getViews().removeOne(this);
auto *viewLogic = qobject_cast<ZoneViewZoneLogic *>(getLogic());
viewLogic->getOriginalZone()->getViews().removeOne(viewLogic);
}
/**
@ -66,9 +66,9 @@ void ZoneViewZone::removeFromViews()
void ZoneViewZone::close()
{
emit closed();
if (!(qobject_cast<ZoneViewZoneLogic *>(getLogic())->getRevealZone() &&
!qobject_cast<ZoneViewZoneLogic *>(getLogic())->getWriteableRevealZone())) {
qobject_cast<ZoneViewZoneLogic *>(getLogic())->getOriginalZone()->getViews().removeOne(this);
auto *viewLogic = qobject_cast<ZoneViewZoneLogic *>(getLogic());
if (!(viewLogic->getRevealZone() && !viewLogic->getWriteableRevealZone())) {
viewLogic->getOriginalZone()->getViews().removeOne(viewLogic);
}
deleteLater();
}
@ -95,28 +95,27 @@ void ZoneViewZone::initializeCards(const QList<const ServerInfo_Card *> &cardLis
auto copy = new CardItem(getLogic()->getPlayer(), this, cardRef, card->id());
copy->setFaceDown(card->face_down());
getLogic()->addCard(copy, false, i);
getLogic()->addCard(copy->getState(), false, i);
}
reorganizeCards();
} else if (!qobject_cast<ZoneViewZoneLogic *>(getLogic())->getOriginalZone()->contentsKnown()) {
Command_DumpZone cmd;
cmd.set_player_id(getLogic()->getPlayer()->getPlayerInfo()->getId());
cmd.set_zone_name(getLogic()->getName().toStdString());
cmd.set_number_cards(numberCards);
cmd.set_is_reversed(qobject_cast<ZoneViewZoneLogic *>(getLogic())->getIsReversed());
PendingCommand *pend = getLogic()->getPlayer()->getPlayerActions()->prepareGameCommand(cmd);
connect(pend, &PendingCommand::finished, this, &ZoneViewZone::zoneDumpReceived);
getLogic()->getPlayer()->getPlayerActions()->sendGameCommand(pend);
auto *viewLogic = qobject_cast<ZoneViewZoneLogic *>(getLogic());
PendingCommand *pend = viewLogic->prepareZoneDump();
if (pend != nullptr) {
// A local game answers synchronously, so the handler has to be connected before the
// command is sent or the response would be delivered to nobody.
connect(pend, &PendingCommand::finished, this, &ZoneViewZone::zoneDumpReceived);
viewLogic->sendZoneDump(pend);
}
} else {
const CardList &c = qobject_cast<ZoneViewZoneLogic *>(getLogic())->getOriginalZone()->getCards();
int number = numberCards == -1 ? c.size() : (numberCards < c.size() ? numberCards : c.size());
for (int i = 0; i < number; i++) {
CardItem *card = c.at(i);
CardState *card = c.at(i);
auto copy = new CardItem(getLogic()->getPlayer(), this, card->getCardRef(), card->getId());
copy->setFaceDown(card->getFaceDown());
getLogic()->addCard(copy, false, i);
getLogic()->addCard(copy->getState(), false, i);
}
reorganizeCards();
}
@ -132,7 +131,7 @@ void ZoneViewZone::zoneDumpReceived(const Response &r)
auto cardProviderId = QString::fromStdString(cardInfo.provider_id());
auto card = new CardItem(getLogic()->getPlayer(), this, {cardName, cardProviderId}, cardInfo.id(), getLogic());
card->setFaceDown(cardInfo.face_down());
getLogic()->rawInsertCard(card, i);
getLogic()->rawInsertCard(card->getState(), i);
}
qobject_cast<ZoneViewZoneLogic *>(getLogic())->updateCardIds(ZoneViewZoneLogic::INITIALIZE);
@ -146,11 +145,11 @@ void ZoneViewZone::zoneDumpReceived(const Response &r)
void ZoneViewZone::reorganizeCards()
{
// filter cards
CardList cardsToDisplay = CardList(getLogic()->getCards().getContentsKnown());
for (auto card : getLogic()->getCards()) {
CardList cardsToDisplay = CardList(getLogic()->contentsKnown());
for (auto card : cardItems()) {
if (filterString.check(card->getCard().getCardPtr())) {
card->show();
cardsToDisplay.append(card);
cardsToDisplay.append(card->getState());
} else {
card->hide();
}
@ -218,7 +217,8 @@ ZoneViewZone::GridSize ZoneViewZone::positionCardsForDisplay(CardList &cards, Ca
const auto extractor = CardList::getExtractorFor(pileOption);
for (int i = 0; i < cardCount; i++) {
CardItem *c = cards.at(i);
CardState *c = cards.at(i);
CardItem *cItem = qobject_cast<CardItem *>(c->parent());
QString columnProp = extractor(c);
if (i) { // if not the first card
@ -233,8 +233,8 @@ ZoneViewZone::GridSize ZoneViewZone::positionCardsForDisplay(CardList &cards, Ca
lastColumnProp = columnProp;
qreal x = col * CardDimensions::WIDTH_F;
qreal y = row * CardDimensions::HEIGHT_F / 3;
c->setPos(HORIZONTAL_PADDING + x, VERTICAL_PADDING + y);
c->setRealZValue(i);
cItem->setPos(HORIZONTAL_PADDING + x, VERTICAL_PADDING + y);
cItem->setRealZValue(i);
longestRow = qMax(row, longestRow);
}
@ -259,11 +259,11 @@ ZoneViewZone::GridSize ZoneViewZone::positionCardsForDisplay(CardList &cards, Ca
qCDebug(ViewZoneLog) << "reorganizeCards: rows=" << rows << "cols=" << cols;
for (int i = 0; i < cardCount; i++) {
CardItem *c = cards.at(i);
auto *cItem = qobject_cast<CardItem *>(cards.at(i)->parent());
qreal x = (i / rows) * CardDimensions::WIDTH_F;
qreal y = (i % rows) * CardDimensions::HEIGHT_F / 3;
c->setPos(HORIZONTAL_PADDING + x, VERTICAL_PADDING + y);
c->setRealZValue(i);
cItem->setPos(HORIZONTAL_PADDING + x, VERTICAL_PADDING + y);
cItem->setRealZValue(i);
}
return GridSize{rows, qMax(cols, 1)};
@ -301,24 +301,13 @@ void ZoneViewZone::handleDropEvent(const QList<CardDragItem *> &dragItems,
CardZoneLogic *startZone,
const QPoint & /*dropPoint*/)
{
Command_MoveCard cmd;
cmd.set_start_player_id(startZone->getPlayer()->getPlayerInfo()->getId());
cmd.set_start_zone(startZone->getName().toStdString());
cmd.set_target_player_id(getLogic()->getPlayer()->getPlayerInfo()->getId());
cmd.set_target_zone(getLogic()->getName().toStdString());
cmd.set_x(0);
cmd.set_y(0);
cmd.set_is_reversed(qobject_cast<ZoneViewZoneLogic *>(getLogic())->getIsReversed());
QList<CardMoveRequest> cards;
cards.reserve(dragItems.size());
for (int i = 0; i < dragItems.size(); ++i) {
auto cardToMove = cmd.mutable_cards_to_move()->add_card();
cardToMove->set_card_id(dragItems[i]->getId());
if (dragItems[i]->isForceFaceDown()) {
cardToMove->set_face_down(true);
}
cards.append(CardMoveRequest{dragItems[i]->getId(), dragItems[i]->isForceFaceDown()});
}
getLogic()->getPlayer()->getPlayerActions()->sendGameCommand(cmd);
getLogic()->getPlayer()->getPlayerActions()->moveCards(startZone, getLogic(), 0, 0, cards);
}
void ZoneViewZone::setGeometry(const QRectF &rect)

View file

@ -7,7 +7,6 @@
#ifndef ZONEVIEWERZONE_H
#define ZONEVIEWERZONE_H
#include "../../game/board/card_list.h"
#include "select_zone.h"
#include <QGraphicsLayoutItem>
@ -17,6 +16,7 @@
#include <QSizeF>
#include <QString>
#include <libcockatrice/filters/filter_string.h>
#include <libcockatrice/game/board/card_list.h>
#include <qnamespace.h>
#include <qtmetamacros.h>

Some files were not shown because too many files have changed in this diff Show more