mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-11-05 17:43:23 -08:00
Compare commits
7 commits
fa4c5766eb
...
0426fddf0d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0426fddf0d | ||
|
|
eef0213238 | ||
|
|
4721cde443 | ||
|
|
1a70940fc7 | ||
|
|
8f09048bce | ||
|
|
a03f4c2fa4 | ||
|
|
10d46bafbd |
23 changed files with 1067 additions and 18 deletions
|
|
@ -27,6 +27,8 @@ set(cockatrice_SOURCES
|
|||
src/interface/deck_loader/deck_file_format.cpp
|
||||
src/interface/deck_loader/deck_loader.cpp
|
||||
src/interface/deck_loader/loaded_deck.cpp
|
||||
src/interface/deck_sync/deck_sync_profile.cpp
|
||||
src/interface/deck_sync/deck_sync_sidecar.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
|
||||
|
|
|
|||
31
cockatrice/src/interface/deck_sync/deck_sync_profile.cpp
Normal file
31
cockatrice/src/interface/deck_sync/deck_sync_profile.cpp
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
#include "deck_sync_profile.h"
|
||||
|
||||
#include <QCryptographicHash>
|
||||
|
||||
DeckSyncProfile::DeckSyncProfile(const QString &_host, int _port, const QString &_username)
|
||||
: host(_host), port(_port), username(_username)
|
||||
{
|
||||
}
|
||||
|
||||
bool DeckSyncProfile::isValid() const
|
||||
{
|
||||
return !host.isEmpty() && port > 0 && !username.isEmpty();
|
||||
}
|
||||
|
||||
QString DeckSyncProfile::key() const
|
||||
{
|
||||
if (!isValid()) {
|
||||
return {};
|
||||
}
|
||||
// NUL separators keep the triple injective: neither IPv6 hosts nor
|
||||
// usernames containing ':' can collide two profiles into one file.
|
||||
// Lowercasing is safe on both parts: DNS names are case-insensitive, and
|
||||
// Servatrice usernames are unique under a case-insensitive collation
|
||||
// (cockatrice_users uses utf8mb4_unicode_ci), so both spellings are one account.
|
||||
QByteArray blob = host.trimmed().toLower().toUtf8();
|
||||
blob.append('\0');
|
||||
blob.append(QByteArray::number(port));
|
||||
blob.append('\0');
|
||||
blob.append(username.trimmed().toLower().toUtf8());
|
||||
return QCryptographicHash::hash(blob, QCryptographicHash::Sha1).toHex();
|
||||
}
|
||||
43
cockatrice/src/interface/deck_sync/deck_sync_profile.h
Normal file
43
cockatrice/src/interface/deck_sync/deck_sync_profile.h
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
/**
|
||||
* @file deck_sync_profile.h
|
||||
* @brief Identity of one sync target (server + user) for deck collection sync.
|
||||
*/
|
||||
|
||||
#ifndef DECK_SYNC_PROFILE_H
|
||||
#define DECK_SYNC_PROFILE_H
|
||||
|
||||
#include <QString>
|
||||
|
||||
/**
|
||||
* @class DeckSyncProfile
|
||||
* @brief Identifies which server and login a sync sidecar belongs to.
|
||||
*
|
||||
* A local deck folder can sync against several servers. Every server login
|
||||
* gets its own profile, and every profile gets its own sidecar file, so
|
||||
* switching servers never mixes up remote deck ids.
|
||||
*/
|
||||
class DeckSyncProfile
|
||||
{
|
||||
public:
|
||||
QString host; ///< Server hostname as entered by the user.
|
||||
int port = 0; ///< Server port.
|
||||
QString username; ///< Login name on that server.
|
||||
|
||||
DeckSyncProfile() = default;
|
||||
DeckSyncProfile(const QString &_host, int _port, const QString &_username);
|
||||
|
||||
/** @brief False when any of host, port or username is missing. */
|
||||
[[nodiscard]] bool isValid() const;
|
||||
/**
|
||||
* @brief Stable file-system-safe key for this profile.
|
||||
*
|
||||
* SHA1 hex over the NUL-separated lowercased "host", port and "username"
|
||||
* triple, so the same server reached with different hostname
|
||||
* capitalisation maps to one file, while IPv6 hosts and usernames
|
||||
* containing ':' can never collide two profiles. Empty for invalid
|
||||
* profiles, so no two half-configured profiles share a file.
|
||||
*/
|
||||
[[nodiscard]] QString key() const;
|
||||
};
|
||||
|
||||
#endif // DECK_SYNC_PROFILE_H
|
||||
99
cockatrice/src/interface/deck_sync/deck_sync_sidecar.cpp
Normal file
99
cockatrice/src/interface/deck_sync/deck_sync_sidecar.cpp
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
#include "deck_sync_sidecar.h"
|
||||
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QSaveFile>
|
||||
|
||||
namespace
|
||||
{
|
||||
constexpr int SIDECAR_FORMAT_VERSION = 1;
|
||||
} // namespace
|
||||
|
||||
DeckSyncSidecar::DeckSyncSidecar(const QString &_filePath) : filePath(_filePath)
|
||||
{
|
||||
}
|
||||
|
||||
bool DeckSyncSidecar::load()
|
||||
{
|
||||
recordMap.clear();
|
||||
|
||||
QFile file(filePath);
|
||||
if (!file.exists()) {
|
||||
return true;
|
||||
}
|
||||
if (!file.open(QIODevice::ReadOnly)) {
|
||||
return false;
|
||||
}
|
||||
const QJsonDocument document = QJsonDocument::fromJson(file.readAll());
|
||||
if (!document.isObject() || !document.object().value("records").isObject()) {
|
||||
return false;
|
||||
}
|
||||
const QJsonObject records = document.object().value("records").toObject();
|
||||
for (auto it = records.begin(); it != records.end(); ++it) {
|
||||
if (!it.value().isObject()) {
|
||||
continue;
|
||||
}
|
||||
const QJsonObject entry = it.value().toObject();
|
||||
DeckSyncRecord record;
|
||||
record.guid = entry.value("guid").toString();
|
||||
record.remoteId = entry.value("remoteId").toInt(-1);
|
||||
record.baseHash = entry.value("baseHash").toString();
|
||||
record.lastSyncSecs = static_cast<qint64>(entry.value("lastSync").toDouble());
|
||||
record.deleted = entry.value("deleted").toBool(false);
|
||||
recordMap.insert(it.key(), record);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool DeckSyncSidecar::save() const
|
||||
{
|
||||
QJsonObject records;
|
||||
for (auto it = recordMap.begin(); it != recordMap.end(); ++it) {
|
||||
QJsonObject entry;
|
||||
entry.insert("guid", it.value().guid);
|
||||
entry.insert("remoteId", it.value().remoteId);
|
||||
entry.insert("baseHash", it.value().baseHash);
|
||||
entry.insert("lastSync", static_cast<double>(it.value().lastSyncSecs));
|
||||
entry.insert("deleted", it.value().deleted);
|
||||
records.insert(it.key(), entry);
|
||||
}
|
||||
QJsonObject root;
|
||||
root.insert("version", SIDECAR_FORMAT_VERSION);
|
||||
root.insert("records", records);
|
||||
|
||||
QDir().mkpath(QFileInfo(filePath).absolutePath());
|
||||
QSaveFile file(filePath);
|
||||
if (!file.open(QIODevice::WriteOnly)) {
|
||||
return false;
|
||||
}
|
||||
file.write(QJsonDocument(root).toJson(QJsonDocument::Compact));
|
||||
return file.commit();
|
||||
}
|
||||
|
||||
bool DeckSyncSidecar::contains(const QString &relativePath) const
|
||||
{
|
||||
return recordMap.contains(relativePath);
|
||||
}
|
||||
|
||||
DeckSyncRecord DeckSyncSidecar::get(const QString &relativePath) const
|
||||
{
|
||||
return recordMap.value(relativePath, DeckSyncRecord());
|
||||
}
|
||||
|
||||
void DeckSyncSidecar::set(const QString &relativePath, const DeckSyncRecord &record)
|
||||
{
|
||||
recordMap.insert(relativePath, record);
|
||||
}
|
||||
|
||||
void DeckSyncSidecar::remove(const QString &relativePath)
|
||||
{
|
||||
recordMap.remove(relativePath);
|
||||
}
|
||||
|
||||
QMap<QString, DeckSyncRecord> DeckSyncSidecar::records() const
|
||||
{
|
||||
return recordMap;
|
||||
}
|
||||
52
cockatrice/src/interface/deck_sync/deck_sync_sidecar.h
Normal file
52
cockatrice/src/interface/deck_sync/deck_sync_sidecar.h
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
/**
|
||||
* @file deck_sync_sidecar.h
|
||||
* @brief Per-profile local bookkeeping for deck collection sync.
|
||||
*/
|
||||
|
||||
#ifndef DECK_SYNC_SIDECAR_H
|
||||
#define DECK_SYNC_SIDECAR_H
|
||||
|
||||
#include <QMap>
|
||||
#include <QString>
|
||||
|
||||
/** @brief Sync bookkeeping for one local deck file. */
|
||||
struct DeckSyncRecord
|
||||
{
|
||||
QString guid; ///< Stable deck identity shared with the server.
|
||||
int remoteId = -1; ///< Server row id, -1 when never uploaded.
|
||||
QString baseHash; ///< Deck hash at the last successful sync.
|
||||
qint64 lastSyncSecs = 0; ///< Last sync time, seconds since Unix epoch.
|
||||
bool deleted = false; ///< Local tombstone waiting to be pushed.
|
||||
};
|
||||
|
||||
/**
|
||||
* @class DeckSyncSidecar
|
||||
* @brief Loads and stores sync records as JSON next to the deck folder.
|
||||
*
|
||||
* One sidecar file per DeckSyncProfile (named by profile key), mapping paths
|
||||
* relative to the deck folder to their last-synced state. Comparing the live
|
||||
* file hash against baseHash tells clean apart from locally modified.
|
||||
*/
|
||||
class DeckSyncSidecar
|
||||
{
|
||||
public:
|
||||
QString filePath; ///< Full path of the sidecar JSON file.
|
||||
|
||||
explicit DeckSyncSidecar(const QString &_filePath);
|
||||
|
||||
/** @brief Loads records; a missing file loads as empty and returns true. */
|
||||
bool load();
|
||||
/** @brief Stores records, creating parent folders as needed. */
|
||||
bool save() const;
|
||||
[[nodiscard]] bool contains(const QString &relativePath) const;
|
||||
/** @brief Returns the record, or a default record when unknown. */
|
||||
[[nodiscard]] DeckSyncRecord get(const QString &relativePath) const;
|
||||
void set(const QString &relativePath, const DeckSyncRecord &record);
|
||||
void remove(const QString &relativePath);
|
||||
[[nodiscard]] QMap<QString, DeckSyncRecord> records() const;
|
||||
|
||||
private:
|
||||
QMap<QString, DeckSyncRecord> recordMap;
|
||||
};
|
||||
|
||||
#endif // DECK_SYNC_SIDECAR_H
|
||||
|
|
@ -7,6 +7,7 @@ set(HEADERS
|
|||
libcockatrice/deck_list/tree/abstract_deck_list_node.h
|
||||
libcockatrice/deck_list/tree/deck_list_card_node.h
|
||||
libcockatrice/deck_list/tree/inner_deck_list_node.h
|
||||
libcockatrice/deck_list/deck_diff.h
|
||||
libcockatrice/deck_list/deck_list.h
|
||||
libcockatrice/deck_list/deck_list_history_manager.h
|
||||
libcockatrice/deck_list/deck_list_node_tree.h
|
||||
|
|
@ -25,6 +26,7 @@ add_library(
|
|||
libcockatrice/deck_list/tree/abstract_deck_list_node.cpp
|
||||
libcockatrice/deck_list/tree/deck_list_card_node.cpp
|
||||
libcockatrice/deck_list/tree/inner_deck_list_node.cpp
|
||||
libcockatrice/deck_list/deck_diff.cpp
|
||||
libcockatrice/deck_list/deck_list.cpp
|
||||
libcockatrice/deck_list/deck_list_history_manager.cpp
|
||||
libcockatrice/deck_list/deck_list_node_tree.cpp
|
||||
|
|
|
|||
143
libcockatrice_deck_list/libcockatrice/deck_list/deck_diff.cpp
Normal file
143
libcockatrice_deck_list/libcockatrice/deck_list/deck_diff.cpp
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
#include "deck_diff.h"
|
||||
|
||||
#include "deck_list.h"
|
||||
#include "tree/deck_list_card_node.h"
|
||||
#include "tree/inner_deck_list_node.h"
|
||||
|
||||
#include <QMap>
|
||||
#include <QPair>
|
||||
#include <QSet>
|
||||
#include <algorithm>
|
||||
|
||||
namespace
|
||||
{
|
||||
using ZoneCardKey = QPair<QString, QString>;
|
||||
|
||||
struct ZoneCardInfo
|
||||
{
|
||||
int count = 0;
|
||||
QString cardSetShortName;
|
||||
QString cardSetNumber;
|
||||
QString cardProviderId;
|
||||
};
|
||||
|
||||
QMap<ZoneCardKey, ZoneCardInfo> zoneCardInfos(const DeckList &deck)
|
||||
{
|
||||
QMap<ZoneCardKey, ZoneCardInfo> infos;
|
||||
deck.forEachCard([&infos](InnerDecklistNode *zone, DecklistCardNode *card) {
|
||||
ZoneCardInfo &info = infos[{zone->getName(), card->getName()}];
|
||||
if (info.count == 0) {
|
||||
info.cardSetShortName = card->getCardSetShortName();
|
||||
info.cardSetNumber = card->getCardCollectorNumber();
|
||||
info.cardProviderId = card->getCardProviderId();
|
||||
}
|
||||
info.count += card->getNumber();
|
||||
});
|
||||
return infos;
|
||||
}
|
||||
|
||||
QMap<ZoneCardKey, int> zoneCardCounts(const DeckList &deck)
|
||||
{
|
||||
QMap<ZoneCardKey, int> counts;
|
||||
const QMap<ZoneCardKey, ZoneCardInfo> infos = zoneCardInfos(deck);
|
||||
for (auto it = infos.begin(); it != infos.end(); ++it) {
|
||||
counts.insert(it.key(), it.value().count);
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
bool changeLessThan(const DeckCardChange &a, const DeckCardChange &b)
|
||||
{
|
||||
if (a.zone != b.zone) {
|
||||
return a.zone < b.zone;
|
||||
}
|
||||
return a.cardName < b.cardName;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
namespace DeckDiff
|
||||
{
|
||||
DeckDiffResult diff(const DeckList &oldDeck, const DeckList &newDeck)
|
||||
{
|
||||
const QMap<ZoneCardKey, int> oldCounts = zoneCardCounts(oldDeck);
|
||||
const QMap<ZoneCardKey, ZoneCardInfo> newInfos = zoneCardInfos(newDeck);
|
||||
|
||||
QSet<ZoneCardKey> keys(oldCounts.keyBegin(), oldCounts.keyEnd());
|
||||
keys.unite(QSet<ZoneCardKey>(newInfos.keyBegin(), newInfos.keyEnd()));
|
||||
|
||||
DeckDiffResult result;
|
||||
for (const ZoneCardKey &key : keys) {
|
||||
const int oldCount = oldCounts.value(key, 0);
|
||||
const ZoneCardInfo newInfo = newInfos.value(key, ZoneCardInfo());
|
||||
if (oldCount != newInfo.count) {
|
||||
DeckCardChange change;
|
||||
change.zone = key.first;
|
||||
change.cardName = key.second;
|
||||
change.oldCount = oldCount;
|
||||
change.newCount = newInfo.count;
|
||||
change.cardSetShortName = newInfo.cardSetShortName;
|
||||
change.cardSetNumber = newInfo.cardSetNumber;
|
||||
change.cardProviderId = newInfo.cardProviderId;
|
||||
result.changes.append(change);
|
||||
}
|
||||
}
|
||||
std::sort(result.changes.begin(), result.changes.end(), changeLessThan);
|
||||
return result;
|
||||
}
|
||||
|
||||
bool canAutoMerge(const DeckDiffResult &localChanges, const DeckDiffResult &remoteChanges)
|
||||
{
|
||||
QSet<ZoneCardKey> localKeys;
|
||||
for (const DeckCardChange &change : localChanges.changes) {
|
||||
localKeys.insert({change.zone, change.cardName});
|
||||
}
|
||||
for (const DeckCardChange &change : remoteChanges.changes) {
|
||||
if (localKeys.contains({change.zone, change.cardName})) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
DeckList merge(const DeckList &baseDeck, const DeckDiffResult &localChanges, const DeckDiffResult &remoteChanges)
|
||||
{
|
||||
QMap<ZoneCardKey, int> counts = zoneCardCounts(baseDeck);
|
||||
QMap<ZoneCardKey, DeckCardChange> printings;
|
||||
for (const DeckCardChange &change : localChanges.changes) {
|
||||
counts[{change.zone, change.cardName}] = change.newCount;
|
||||
printings[{change.zone, change.cardName}] = change;
|
||||
}
|
||||
for (const DeckCardChange &change : remoteChanges.changes) {
|
||||
counts[{change.zone, change.cardName}] = change.newCount;
|
||||
printings[{change.zone, change.cardName}] = change;
|
||||
}
|
||||
|
||||
DeckList merged = baseDeck;
|
||||
QMap<ZoneCardKey, QList<DecklistCardNode *>> nodesByKey;
|
||||
merged.forEachCard([&nodesByKey](InnerDecklistNode *zone, DecklistCardNode *card) {
|
||||
nodesByKey[{zone->getName(), card->getName()}].append(card);
|
||||
});
|
||||
|
||||
for (auto it = counts.begin(); it != counts.end(); ++it) {
|
||||
const ZoneCardKey &key = it.key();
|
||||
const int target = qMax(0, it.value());
|
||||
QList<DecklistCardNode *> nodes = nodesByKey.value(key);
|
||||
if (target == 0) {
|
||||
for (DecklistCardNode *node : nodes) {
|
||||
merged.getTree()->deleteNode(node);
|
||||
}
|
||||
} else if (nodes.isEmpty()) {
|
||||
const DeckCardChange printing = printings.value(key);
|
||||
merged.getTree()->addCard(printing.cardName, target, printing.zone, -1, printing.cardSetShortName,
|
||||
printing.cardSetNumber, printing.cardProviderId, true);
|
||||
} else {
|
||||
nodes.first()->setNumber(target);
|
||||
for (int i = 1; i < nodes.size(); ++i) {
|
||||
merged.getTree()->deleteNode(nodes.at(i));
|
||||
}
|
||||
}
|
||||
}
|
||||
merged.refreshDeckHash();
|
||||
return merged;
|
||||
}
|
||||
} // namespace DeckDiff
|
||||
72
libcockatrice_deck_list/libcockatrice/deck_list/deck_diff.h
Normal file
72
libcockatrice_deck_list/libcockatrice/deck_list/deck_diff.h
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
/**
|
||||
* @file deck_diff.h
|
||||
* @brief Card-level diff and three-way merge for deck collection sync.
|
||||
*/
|
||||
|
||||
#ifndef DECK_DIFF_H
|
||||
#define DECK_DIFF_H
|
||||
|
||||
#include <QList>
|
||||
#include <QString>
|
||||
|
||||
class DeckList;
|
||||
|
||||
/** @brief A single card-count change within one zone. */
|
||||
struct DeckCardChange
|
||||
{
|
||||
QString zone; ///< Zone name, e.g. "main".
|
||||
QString cardName; ///< Card name.
|
||||
int oldCount = 0; ///< Copies in the old deck.
|
||||
int newCount = 0; ///< Copies in the new deck.
|
||||
QString cardSetShortName; ///< Printing set carried from the new side, empty when unchanged.
|
||||
QString cardSetNumber; ///< Printing collector number carried from the new side.
|
||||
QString cardProviderId; ///< Printing provider id carried from the new side.
|
||||
/** @brief Copies added (positive) or removed (negative). */
|
||||
[[nodiscard]] int delta() const
|
||||
{
|
||||
return newCount - oldCount;
|
||||
}
|
||||
};
|
||||
|
||||
/** @brief Card-level difference between two decks, sorted by zone then card name. */
|
||||
struct DeckDiffResult
|
||||
{
|
||||
QList<DeckCardChange> changes;
|
||||
/** @brief True when both decks hold the same cards in the same zones. */
|
||||
[[nodiscard]] bool isEmpty() const
|
||||
{
|
||||
return changes.isEmpty();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @namespace DeckDiff
|
||||
* @brief Pure card-count diff and three-way merge for deck sync.
|
||||
*
|
||||
* Compares card names and copy counts per zone only; a merged deck keeps
|
||||
* base printings for existing cards and carries the changed side's printing
|
||||
* for newly added cards.
|
||||
*/
|
||||
namespace DeckDiff
|
||||
{
|
||||
/** @brief Diffs oldDeck against newDeck. */
|
||||
DeckDiffResult diff(const DeckList &oldDeck, const DeckList &newDeck);
|
||||
/**
|
||||
* @brief True when both change sets touch disjoint (zone, card) keys and a merge needs no arbitration.
|
||||
*/
|
||||
bool canAutoMerge(const DeckDiffResult &localChanges, const DeckDiffResult &remoteChanges);
|
||||
/**
|
||||
* @brief Applies both change sets onto a copy of baseDeck.
|
||||
*
|
||||
* @precondition localChanges and remoteChanges were both computed with
|
||||
* diff() against baseDeck. Stale or foreign change sets silently
|
||||
* resurrect or delete cards.
|
||||
*
|
||||
* Counts are clamped at zero. On overlapping keys the remote change wins;
|
||||
* callers offer this result as "Use Merge" only when canAutoMerge is true.
|
||||
* Metadata and sideboard plans come from baseDeck.
|
||||
*/
|
||||
DeckList merge(const DeckList &baseDeck, const DeckDiffResult &localChanges, const DeckDiffResult &remoteChanges);
|
||||
} // namespace DeckDiff
|
||||
|
||||
#endif // DECK_DIFF_H
|
||||
|
|
@ -14,4 +14,7 @@ message Command_DeckUpload {
|
|||
reserved 5, 6, 8;
|
||||
reserved "banner_card_name", "banner_card_provider", "tags";
|
||||
optional string color_identity = 7;
|
||||
// Stable per-deck identity for two-way collection sync. Sent on first
|
||||
// synced upload, echoed back in ServerInfo_DeckStorage_File afterwards.
|
||||
optional string guid = 9;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,4 +6,9 @@ message Response_DeckDownload {
|
|||
optional Response_DeckDownload ext = 1007;
|
||||
}
|
||||
optional string deck = 1;
|
||||
// Sync identity echoed so the client can update its sidecar without an
|
||||
// extra list fetch.
|
||||
optional string guid = 2;
|
||||
optional string hash = 3;
|
||||
optional uint64 last_modified = 4;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,15 @@ message ServerInfo_DeckStorage_File {
|
|||
// Tag names associated with the deck. Empty for decks uploaded before the
|
||||
// tags column existed.
|
||||
repeated string tags = 6;
|
||||
// Stable per-deck identity for two-way collection sync. Empty for decks
|
||||
// uploaded before the sync migration until they are re-uploaded.
|
||||
optional string guid = 7;
|
||||
// Content hash (DeckList::getDeckHash) for change detection without
|
||||
// downloading the full list.
|
||||
optional string hash = 8;
|
||||
// Last modification time, seconds since Unix epoch. Drives newest-wins
|
||||
// default and conflict-dialog recency highlight.
|
||||
optional uint64 last_modified = 9;
|
||||
}
|
||||
|
||||
message ServerInfo_DeckStorage_Folder {
|
||||
|
|
|
|||
32
servatrice/migrations/servatrice_0037_to_0038.sql
Normal file
32
servatrice/migrations/servatrice_0037_to_0038.sql
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
-- Servatrice db migration from version 37 to version 38
|
||||
|
||||
-- Deck collection sync + versioning.
|
||||
--
|
||||
-- 1. Stable per-deck identity and change detection for two-way sync:
|
||||
-- guid links a local file to its server row across renames, hash detects
|
||||
-- content changes without downloading, last_modified drives newest-wins
|
||||
-- default and conflict highlighting, deleted implements tombstones so a
|
||||
-- delete on one device does not resurrect from the other.
|
||||
-- 2. Limited per-deck version history: every hash-changing upload inserts a
|
||||
-- row; the server prunes beyond the configured retention (default 20).
|
||||
|
||||
ALTER TABLE `cockatrice_decklist_files`
|
||||
ADD COLUMN `guid` char(36) NULL AFTER `tags`,
|
||||
ADD COLUMN `hash` char(40) NULL AFTER `guid`,
|
||||
ADD COLUMN `last_modified` datetime NULL AFTER `hash`,
|
||||
ADD COLUMN `deleted` tinyint(1) NOT NULL DEFAULT 0 AFTER `last_modified`;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `cockatrice_deck_versions` (
|
||||
`id` int(7) unsigned zerofill NOT NULL auto_increment,
|
||||
`deck_id` int(7) unsigned zerofill NOT NULL,
|
||||
`version_no` int(7) unsigned NOT NULL,
|
||||
`content` text NOT NULL,
|
||||
`hash` char(40) NULL,
|
||||
`created_at` datetime NOT NULL,
|
||||
`created_by_clientid` varchar(15) NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `deck_id` (`deck_id`),
|
||||
FOREIGN KEY(`deck_id`) REFERENCES `cockatrice_decklist_files`(`id`) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
UPDATE cockatrice_schema_version SET version=38 WHERE version=37;
|
||||
|
|
@ -479,3 +479,12 @@ max_decks_per_share=50
|
|||
; A value of 0 disables the limit.
|
||||
; Default: 50
|
||||
max_shares_per_day=50
|
||||
|
||||
|
||||
[deck_sync]
|
||||
|
||||
; Maximum number of stored versions kept per deck. Each upload with changed
|
||||
; content adds a version; older versions beyond this limit are pruned.
|
||||
; Minimum enforced: 1.
|
||||
; Default: 20
|
||||
max_versions=20
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ CREATE TABLE IF NOT EXISTS `cockatrice_schema_version` (
|
|||
PRIMARY KEY (`version`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
INSERT INTO cockatrice_schema_version VALUES(37);
|
||||
INSERT INTO cockatrice_schema_version VALUES(38);
|
||||
|
||||
-- users and user data tables
|
||||
CREATE TABLE IF NOT EXISTS `cockatrice_users` (
|
||||
|
|
@ -71,11 +71,30 @@ CREATE TABLE IF NOT EXISTS `cockatrice_decklist_files` (
|
|||
`banner_card_provider` varchar(32) NULL,
|
||||
`color_identity` varchar(5) NULL,
|
||||
`tags` text NULL,
|
||||
`guid` char(36) NULL,
|
||||
`hash` char(40) NULL,
|
||||
`last_modified` datetime NULL,
|
||||
`deleted` tinyint(1) NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `FolderPlusUser` (`id_folder`,`id_user`),
|
||||
FOREIGN KEY(`id_user`) REFERENCES `cockatrice_users`(`id`) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- Per-deck version history for collection sync.
|
||||
-- One row per hash-changing upload; pruned beyond retention (default 20).
|
||||
CREATE TABLE IF NOT EXISTS `cockatrice_deck_versions` (
|
||||
`id` int(7) unsigned zerofill NOT NULL auto_increment,
|
||||
`deck_id` int(7) unsigned zerofill NOT NULL,
|
||||
`version_no` int(7) unsigned NOT NULL,
|
||||
`content` text NOT NULL,
|
||||
`hash` char(40) NULL,
|
||||
`created_at` datetime NOT NULL,
|
||||
`created_by_clientid` varchar(15) NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `deck_id` (`deck_id`),
|
||||
FOREIGN KEY(`deck_id`) REFERENCES `cockatrice_decklist_files`(`id`) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- Temporary deck shares: a named bundle of decks that can be fetched by
|
||||
-- anyone who knows the (unguessable) token, until the share expires.
|
||||
CREATE TABLE IF NOT EXISTS `cockatrice_deck_share` (
|
||||
|
|
|
|||
|
|
@ -1122,6 +1122,11 @@ int Servatrice::getDeckShareMaxSharesPerDay() const
|
|||
return settingsCache->value("deck_share/max_shares_per_day", 50).toInt();
|
||||
}
|
||||
|
||||
int Servatrice::getDeckSyncMaxVersions() const
|
||||
{
|
||||
return qMax(1, settingsCache->value("deck_sync/max_versions", 20).toInt());
|
||||
}
|
||||
|
||||
int Servatrice::getNumberOfTCPPools() const
|
||||
{
|
||||
return settingsCache->value("server/number_pools", 1).toInt();
|
||||
|
|
|
|||
|
|
@ -292,6 +292,7 @@ public:
|
|||
int getDeckShareCleanupInterval() const;
|
||||
int getDeckShareMaxDecksPerShare() const;
|
||||
int getDeckShareMaxSharesPerDay() const;
|
||||
int getDeckSyncMaxVersions() const;
|
||||
int getMaxUsersPerAddress() const;
|
||||
int getMessageCountingInterval() const override;
|
||||
int getMaxMessageCountPerInterval() const override;
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@
|
|||
|
||||
class QSqlQuery;
|
||||
|
||||
#define DATABASE_SCHEMA_VERSION 37
|
||||
#define DATABASE_SCHEMA_VERSION 38
|
||||
|
||||
class Servatrice;
|
||||
|
||||
|
|
|
|||
|
|
@ -70,6 +70,7 @@
|
|||
#include <QString>
|
||||
#include <QStringLiteral>
|
||||
#include <QTextStream>
|
||||
#include <QUuid>
|
||||
#include <QVariant>
|
||||
#include <QtGlobal>
|
||||
#include <algorithm>
|
||||
|
|
@ -681,8 +682,8 @@ bool AbstractServerSocketInterface::deckListHelper(int folderId,
|
|||
}
|
||||
|
||||
query = sqlInterface->prepareQuery("select id, name, upload_time, is_public, banner_card_name, "
|
||||
"banner_card_provider, color_identity, tags from {prefix}_decklist_files where "
|
||||
"id_folder = :id_folder and id_user = :id_user");
|
||||
"banner_card_provider, color_identity, tags, guid, hash, last_modified from "
|
||||
"{prefix}_decklist_files where id_folder = :id_folder and id_user = :id_user");
|
||||
query->bindValue(":id_folder", folderId);
|
||||
query->bindValue(":id_user", userId);
|
||||
if (!sqlInterface->execSqlQuery(query)) {
|
||||
|
|
@ -708,6 +709,12 @@ bool AbstractServerSocketInterface::deckListHelper(int folderId,
|
|||
for (const QString &tag : deserializeDeckTags(query->value(7).toString())) {
|
||||
newFile->add_tags(tag.toStdString());
|
||||
}
|
||||
newFile->set_guid(query->value(8).toString().toStdString());
|
||||
newFile->set_hash(query->value(9).toString().toStdString());
|
||||
const QDateTime lastModified = query->value(10).toDateTime();
|
||||
if (lastModified.isValid()) {
|
||||
newFile->set_last_modified(lastModified.toSecsSinceEpoch());
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
|
|
@ -1014,6 +1021,38 @@ QString sanitizeColorIdentity(const QString &colorIdentity)
|
|||
}
|
||||
} // namespace
|
||||
|
||||
bool AbstractServerSocketInterface::storeDeckVersion(int deckId, const QString &content, const QString &hash)
|
||||
{
|
||||
QSqlQuery *maxQuery =
|
||||
sqlInterface->prepareQuery("select coalesce(max(version_no), 0) from {prefix}_deck_versions where deck_id = "
|
||||
":deck_id");
|
||||
maxQuery->bindValue(":deck_id", deckId);
|
||||
if (!sqlInterface->execSqlQuery(maxQuery) || !maxQuery->next()) {
|
||||
return false;
|
||||
}
|
||||
const int nextVersion = maxQuery->value(0).toInt() + 1;
|
||||
|
||||
QSqlQuery *insertQuery =
|
||||
sqlInterface->prepareQuery("insert into {prefix}_deck_versions (deck_id, version_no, content, hash, "
|
||||
"created_at, created_by_clientid) values(:deck_id, :version_no, :content, :hash, "
|
||||
"NOW(), :created_by_clientid)");
|
||||
insertQuery->bindValue(":deck_id", deckId);
|
||||
insertQuery->bindValue(":version_no", nextVersion);
|
||||
insertQuery->bindValue(":content", content);
|
||||
insertQuery->bindValue(":hash", hash);
|
||||
insertQuery->bindValue(":created_by_clientid", QString::fromStdString(userInfo->clientid()));
|
||||
if (!sqlInterface->execSqlQuery(insertQuery)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
QSqlQuery *pruneQuery = sqlInterface->prepareQuery("delete from {prefix}_deck_versions where deck_id = :deck_id "
|
||||
"and version_no <= :min_kept_version");
|
||||
pruneQuery->bindValue(":deck_id", deckId);
|
||||
pruneQuery->bindValue(":min_kept_version", nextVersion - servatrice->getDeckSyncMaxVersions());
|
||||
sqlInterface->execSqlQuery(pruneQuery);
|
||||
return true;
|
||||
}
|
||||
|
||||
Response::ResponseCode AbstractServerSocketInterface::cmdDeckUpload(const Command_DeckUpload &cmd,
|
||||
ResponseContainer &rc)
|
||||
{
|
||||
|
|
@ -1049,6 +1088,11 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckUpload(const Comman
|
|||
const QString bannerCardProvider = deck.getBannerCard().providerId.left(32);
|
||||
const QString tagsJson = serializeDeckTags(deck.getTags());
|
||||
const QString colorIdentity = sanitizeColorIdentity(nameFromStdString(cmd.color_identity()));
|
||||
const QString deckHash = deck.getDeckHash();
|
||||
QString guid = cmd.has_guid() ? nameFromStdString(cmd.guid()).left(36) : QString();
|
||||
if (QUuid::fromString(guid).isNull()) {
|
||||
guid = QUuid::createUuid().toString(QUuid::WithoutBraces);
|
||||
}
|
||||
|
||||
if (cmd.has_path()) {
|
||||
int folderId = getDeckPathId(nameFromStdString(cmd.path()));
|
||||
|
|
@ -1059,8 +1103,9 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckUpload(const Comman
|
|||
QSqlQuery *query =
|
||||
sqlInterface->prepareQuery("insert into {prefix}_decklist_files (id_folder, id_user, name, upload_time, "
|
||||
"content, is_public, banner_card_name, banner_card_provider, color_identity, "
|
||||
"tags) values(:id_folder, :id_user, :name, NOW(), :content, :is_public, "
|
||||
":banner_card_name, :banner_card_provider, :color_identity, :tags)");
|
||||
"tags, guid, hash, last_modified) values(:id_folder, :id_user, :name, NOW(), "
|
||||
":content, :is_public, :banner_card_name, :banner_card_provider, "
|
||||
":color_identity, :tags, :guid, :hash, NOW())");
|
||||
query->bindValue(":id_folder", folderId);
|
||||
query->bindValue(":id_user", userInfo->id());
|
||||
query->bindValue(":name", deckName);
|
||||
|
|
@ -1070,21 +1115,63 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckUpload(const Comman
|
|||
query->bindValue(":banner_card_provider", bannerCardProvider);
|
||||
query->bindValue(":color_identity", colorIdentity);
|
||||
query->bindValue(":tags", tagsJson);
|
||||
query->bindValue(":guid", guid);
|
||||
query->bindValue(":hash", deckHash);
|
||||
if (!sqlInterface->execSqlQuery(query)) {
|
||||
return Response::RespContextError;
|
||||
}
|
||||
const int deckId = query->lastInsertId().toInt();
|
||||
if (!storeDeckVersion(deckId, deckStr, deckHash)) {
|
||||
QSqlQuery *cleanupQuery = sqlInterface->prepareQuery("delete from {prefix}_decklist_files where id = :id");
|
||||
cleanupQuery->bindValue(":id", deckId);
|
||||
sqlInterface->execSqlQuery(cleanupQuery);
|
||||
return Response::RespContextError;
|
||||
}
|
||||
|
||||
QSqlQuery *stampQuery =
|
||||
sqlInterface->prepareQuery("select unix_timestamp(upload_time), unix_timestamp(last_modified) from "
|
||||
"{prefix}_decklist_files where id = :id and id_user = :id_user");
|
||||
stampQuery->bindValue(":id", deckId);
|
||||
stampQuery->bindValue(":id_user", userInfo->id());
|
||||
if (!sqlInterface->execSqlQuery(stampQuery) || !stampQuery->next()) {
|
||||
QSqlQuery *cleanupQuery = sqlInterface->prepareQuery("delete from {prefix}_decklist_files where id = :id");
|
||||
cleanupQuery->bindValue(":id", deckId);
|
||||
sqlInterface->execSqlQuery(cleanupQuery);
|
||||
return Response::RespContextError;
|
||||
}
|
||||
|
||||
Response_DeckUpload *re = new Response_DeckUpload;
|
||||
ServerInfo_DeckStorage_TreeItem *fileInfo = re->mutable_new_file();
|
||||
fileInfo->set_id(query->lastInsertId().toInt());
|
||||
fileInfo->set_id(deckId);
|
||||
fileInfo->set_name(deckName.toStdString());
|
||||
fileInfo->mutable_file()->set_creation_time(QDateTime::currentDateTime().toSecsSinceEpoch());
|
||||
fileInfo->mutable_file()->set_creation_time(stampQuery->value(0).toUInt());
|
||||
fileInfo->mutable_file()->set_is_public(cmd.has_is_public() && cmd.is_public());
|
||||
fileInfo->mutable_file()->set_guid(guid.toStdString());
|
||||
fileInfo->mutable_file()->set_hash(deckHash.toStdString());
|
||||
fileInfo->mutable_file()->set_last_modified(stampQuery->value(1).toULongLong());
|
||||
rc.setResponseExtension(re);
|
||||
} else if (cmd.has_deck_id()) {
|
||||
QSqlQuery *currentQuery =
|
||||
sqlInterface->prepareQuery("select guid, hash from {prefix}_decklist_files where id = :id and id_user = "
|
||||
":id_user");
|
||||
currentQuery->bindValue(":id", cmd.deck_id());
|
||||
currentQuery->bindValue(":id_user", userInfo->id());
|
||||
if (!sqlInterface->execSqlQuery(currentQuery)) {
|
||||
return Response::RespContextError;
|
||||
}
|
||||
if (!currentQuery->next()) {
|
||||
return Response::RespNameNotFound;
|
||||
}
|
||||
if (!QUuid::fromString(currentQuery->value(0).toString()).isNull()) {
|
||||
guid = currentQuery->value(0).toString();
|
||||
} else if (cmd.has_guid() && !QUuid::fromString(nameFromStdString(cmd.guid()).left(36)).isNull()) {
|
||||
guid = nameFromStdString(cmd.guid()).left(36);
|
||||
}
|
||||
const QString oldHash = currentQuery->value(1).toString();
|
||||
|
||||
QString updateQuery = "update {prefix}_decklist_files set name=:name, upload_time=NOW(), content=:content, "
|
||||
"banner_card_name=:banner_card_name, banner_card_provider=:banner_card_provider, "
|
||||
"color_identity=:color_identity, tags=:tags";
|
||||
"color_identity=:color_identity, tags=:tags, guid=:guid, hash=:hash, last_modified=NOW()";
|
||||
if (cmd.has_is_public()) {
|
||||
updateQuery += ", is_public=:is_public";
|
||||
}
|
||||
|
|
@ -1099,33 +1186,56 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckUpload(const Comman
|
|||
query->bindValue(":banner_card_provider", bannerCardProvider);
|
||||
query->bindValue(":color_identity", colorIdentity);
|
||||
query->bindValue(":tags", tagsJson);
|
||||
query->bindValue(":guid", guid);
|
||||
query->bindValue(":hash", deckHash);
|
||||
if (cmd.has_is_public()) {
|
||||
query->bindValue(":is_public", cmd.is_public() ? 1 : 0);
|
||||
}
|
||||
if (!sqlInterface->execSqlQuery(query)) {
|
||||
return Response::RespContextError;
|
||||
}
|
||||
|
||||
if (query->numRowsAffected() == 0) {
|
||||
return Response::RespNameNotFound;
|
||||
}
|
||||
|
||||
if (oldHash != deckHash) {
|
||||
if (!storeDeckVersion(cmd.deck_id(), deckStr, deckHash)) {
|
||||
return Response::RespContextError;
|
||||
}
|
||||
} else {
|
||||
// Self-heal a version gap left by an earlier failed version write:
|
||||
// a same-content re-upload must still leave a version behind.
|
||||
QSqlQuery *versionQuery =
|
||||
sqlInterface->prepareQuery("select count(*) from {prefix}_deck_versions where deck_id = :deck_id and "
|
||||
"hash = :hash");
|
||||
versionQuery->bindValue(":deck_id", cmd.deck_id());
|
||||
versionQuery->bindValue(":hash", deckHash);
|
||||
if (!sqlInterface->execSqlQuery(versionQuery) || !versionQuery->next()) {
|
||||
return Response::RespContextError;
|
||||
}
|
||||
if (versionQuery->value(0).toInt() == 0 && !storeDeckVersion(cmd.deck_id(), deckStr, deckHash)) {
|
||||
return Response::RespContextError;
|
||||
}
|
||||
}
|
||||
|
||||
QSqlQuery *visibilityQuery =
|
||||
sqlInterface->prepareQuery("select is_public from {prefix}_decklist_files where id = :id and "
|
||||
"id_user = :id_user");
|
||||
sqlInterface->prepareQuery("select is_public, unix_timestamp(upload_time), unix_timestamp(last_modified) "
|
||||
"from {prefix}_decklist_files where id = :id and id_user = :id_user");
|
||||
visibilityQuery->bindValue(":id", cmd.deck_id());
|
||||
visibilityQuery->bindValue(":id_user", userInfo->id());
|
||||
if (!sqlInterface->execSqlQuery(visibilityQuery)) {
|
||||
if (!sqlInterface->execSqlQuery(visibilityQuery) || !visibilityQuery->next()) {
|
||||
return Response::RespContextError;
|
||||
}
|
||||
const bool isPublic = visibilityQuery->next() && visibilityQuery->value(0).toBool();
|
||||
|
||||
Response_DeckUpload *re = new Response_DeckUpload;
|
||||
ServerInfo_DeckStorage_TreeItem *fileInfo = re->mutable_new_file();
|
||||
fileInfo->set_id(cmd.deck_id());
|
||||
fileInfo->set_name(deckName.toStdString());
|
||||
fileInfo->mutable_file()->set_creation_time(QDateTime::currentDateTime().toSecsSinceEpoch());
|
||||
fileInfo->mutable_file()->set_is_public(isPublic);
|
||||
fileInfo->mutable_file()->set_creation_time(visibilityQuery->value(1).toUInt());
|
||||
fileInfo->mutable_file()->set_is_public(visibilityQuery->value(0).toBool());
|
||||
fileInfo->mutable_file()->set_guid(guid.toStdString());
|
||||
fileInfo->mutable_file()->set_hash(deckHash.toStdString());
|
||||
fileInfo->mutable_file()->set_last_modified(visibilityQuery->value(2).toULongLong());
|
||||
rc.setResponseExtension(re);
|
||||
} else {
|
||||
return Response::RespInvalidData;
|
||||
|
|
@ -1147,11 +1257,29 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckDownload(const Comm
|
|||
} catch (Response::ResponseCode &r) {
|
||||
return r;
|
||||
}
|
||||
const QString deckNative = deck->writeToString_Native();
|
||||
delete deck;
|
||||
|
||||
QSqlQuery *syncQuery =
|
||||
sqlInterface->prepareQuery("select guid, hash, last_modified from {prefix}_decklist_files where id = :id and "
|
||||
"id_user = :id_user");
|
||||
syncQuery->bindValue(":id", cmd.deck_id());
|
||||
syncQuery->bindValue(":id_user", userInfo->id());
|
||||
if (!sqlInterface->execSqlQuery(syncQuery)) {
|
||||
return Response::RespContextError;
|
||||
}
|
||||
|
||||
Response_DeckDownload *re = new Response_DeckDownload;
|
||||
re->set_deck(deck->writeToString_Native().toStdString());
|
||||
re->set_deck(deckNative.toStdString());
|
||||
if (syncQuery->next()) {
|
||||
re->set_guid(syncQuery->value(0).toString().toStdString());
|
||||
re->set_hash(syncQuery->value(1).toString().toStdString());
|
||||
const QDateTime lastModified = syncQuery->value(2).toDateTime();
|
||||
if (lastModified.isValid()) {
|
||||
re->set_last_modified(lastModified.toSecsSinceEpoch());
|
||||
}
|
||||
}
|
||||
rc.setResponseExtension(re);
|
||||
delete deck;
|
||||
|
||||
return Response::RespOk;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -154,6 +154,7 @@ private:
|
|||
Response::ResponseCode cmdDeckDelDir(const Command_DeckDelDir &cmd, ResponseContainer &rc);
|
||||
Response::ResponseCode cmdDeckDel(const Command_DeckDel &cmd, ResponseContainer &rc);
|
||||
Response::ResponseCode cmdDeckUpload(const Command_DeckUpload &cmd, ResponseContainer &rc);
|
||||
bool storeDeckVersion(int deckId, const QString &content, const QString &hash);
|
||||
DeckList *getDeckFromDatabase(int deckId);
|
||||
Response::ResponseCode cmdDeckDownload(const Command_DeckDownload &cmd, ResponseContainer &rc);
|
||||
Response::ResponseCode cmdDeckDownloadPublic(const Command_DeckDownloadPublic &cmd, ResponseContainer &rc);
|
||||
|
|
|
|||
|
|
@ -27,6 +27,9 @@ add_test(NAME loader_local_matching_test COMMAND loader_local_matching_test)
|
|||
|
||||
add_test(NAME deck_hash_performance_test COMMAND deck_hash_performance_test)
|
||||
set_tests_properties(deck_hash_performance_test PROPERTIES TIMEOUT 15)
|
||||
add_test(NAME deck_diff_test COMMAND deck_diff_test)
|
||||
add_test(NAME deck_sync_profile_test COMMAND deck_sync_profile_test)
|
||||
add_test(NAME deck_sync_sidecar_test COMMAND deck_sync_sidecar_test)
|
||||
|
||||
# Find GTest
|
||||
|
||||
|
|
@ -37,6 +40,17 @@ add_executable(test_age_formatting test_age_formatting.cpp)
|
|||
add_executable(password_hash_test password_hash_test.cpp)
|
||||
add_executable(playmat_resolver_test playmat_resolver_test.cpp)
|
||||
add_executable(deck_hash_performance_test deck_hash_performance_test.cpp)
|
||||
add_executable(deck_diff_test deck_diff_test.cpp)
|
||||
add_executable(
|
||||
deck_sync_profile_test ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/deck_sync/deck_sync_profile.cpp
|
||||
deck_sync_profile_test.cpp
|
||||
)
|
||||
target_include_directories(deck_sync_profile_test PRIVATE ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/deck_sync)
|
||||
add_executable(
|
||||
deck_sync_sidecar_test ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/deck_sync/deck_sync_sidecar.cpp
|
||||
deck_sync_sidecar_test.cpp
|
||||
)
|
||||
target_include_directories(deck_sync_sidecar_test PRIVATE ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/deck_sync)
|
||||
add_executable(server_card_counter_test server_card_counter_test.cpp)
|
||||
add_executable(server_counter_test server_counter_test.cpp)
|
||||
add_executable(server_rate_limiter_test server_rate_limiter_test.cpp)
|
||||
|
|
@ -137,6 +151,9 @@ if(NOT GTEST_FOUND)
|
|||
add_dependencies(password_hash_test gtest)
|
||||
add_dependencies(playmat_resolver_test gtest)
|
||||
add_dependencies(deck_hash_performance_test gtest)
|
||||
add_dependencies(deck_diff_test gtest)
|
||||
add_dependencies(deck_sync_profile_test gtest)
|
||||
add_dependencies(deck_sync_sidecar_test gtest)
|
||||
add_dependencies(server_card_counter_test gtest)
|
||||
add_dependencies(server_counter_test gtest)
|
||||
add_dependencies(server_rate_limiter_test gtest)
|
||||
|
|
@ -175,6 +192,12 @@ target_link_libraries(
|
|||
deck_hash_performance_test libcockatrice_deck_list libcockatrice_utility Threads::Threads ${GTEST_BOTH_LIBRARIES}
|
||||
${TEST_QT_MODULES}
|
||||
)
|
||||
target_link_libraries(
|
||||
deck_diff_test libcockatrice_deck_list libcockatrice_utility Threads::Threads ${GTEST_BOTH_LIBRARIES}
|
||||
${TEST_QT_MODULES}
|
||||
)
|
||||
target_link_libraries(deck_sync_profile_test Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES})
|
||||
target_link_libraries(deck_sync_sidecar_test Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES})
|
||||
target_link_libraries(
|
||||
server_card_counter_test libcockatrice_network Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES}
|
||||
)
|
||||
|
|
|
|||
181
tests/deck_diff_test.cpp
Normal file
181
tests/deck_diff_test.cpp
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
#include "gtest/gtest.h"
|
||||
#include <QList>
|
||||
#include <QString>
|
||||
#include <libcockatrice/deck_list/deck_diff.h>
|
||||
#include <libcockatrice/deck_list/deck_list.h>
|
||||
#include <libcockatrice/deck_list/tree/deck_list_card_node.h>
|
||||
#include <libcockatrice/deck_list/tree/inner_deck_list_node.h>
|
||||
|
||||
namespace
|
||||
{
|
||||
DeckList makeDeck(std::initializer_list<std::pair<const char *, int>> mainCards,
|
||||
std::initializer_list<std::pair<const char *, int>> sideCards = {})
|
||||
{
|
||||
DeckList deck;
|
||||
for (const auto &[name, count] : mainCards) {
|
||||
for (int i = 0; i < count; ++i) {
|
||||
deck.addCard(name, "main");
|
||||
}
|
||||
}
|
||||
for (const auto &[name, count] : sideCards) {
|
||||
for (int i = 0; i < count; ++i) {
|
||||
deck.addCard(name, "side");
|
||||
}
|
||||
}
|
||||
return deck;
|
||||
}
|
||||
|
||||
int countOf(const DeckList &deck, const char *zone, const char *name)
|
||||
{
|
||||
int count = 0;
|
||||
deck.forEachCard([&](InnerDecklistNode *zoneNode, DecklistCardNode *card) {
|
||||
if (zoneNode->getName() == zone && card->getName() == name) {
|
||||
count += card->getNumber();
|
||||
}
|
||||
});
|
||||
return count;
|
||||
}
|
||||
|
||||
QList<DecklistCardNode *> nodesOf(const DeckList &deck, const char *zone, const char *name)
|
||||
{
|
||||
QList<DecklistCardNode *> nodes;
|
||||
deck.forEachCard([&](InnerDecklistNode *zoneNode, DecklistCardNode *card) {
|
||||
if (zoneNode->getName() == zone && card->getName() == name) {
|
||||
nodes.append(card);
|
||||
}
|
||||
});
|
||||
return nodes;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
TEST(DeckDiffTest, IdenticalDecksHaveEmptyDiff)
|
||||
{
|
||||
DeckList first = makeDeck({{"Lightning Bolt", 4}});
|
||||
DeckList second = makeDeck({{"Lightning Bolt", 4}});
|
||||
EXPECT_TRUE(DeckDiff::diff(first, second).isEmpty());
|
||||
}
|
||||
|
||||
TEST(DeckDiffTest, ChangesAreSortedByZoneThenName)
|
||||
{
|
||||
DeckList oldDeck = makeDeck({{"Mountain", 20}, {"Lightning Bolt", 4}}, {{"Shock", 2}});
|
||||
DeckList newDeck = makeDeck({{"Mountain", 19}, {"Shock", 5}}, {{"Shock", 1}, {"Teferi", 1}});
|
||||
|
||||
DeckDiffResult result = DeckDiff::diff(oldDeck, newDeck);
|
||||
ASSERT_EQ(result.changes.size(), 5);
|
||||
EXPECT_EQ(result.changes.at(0).zone, "main");
|
||||
EXPECT_EQ(result.changes.at(0).cardName, "Lightning Bolt");
|
||||
EXPECT_EQ(result.changes.at(1).cardName, "Mountain");
|
||||
EXPECT_EQ(result.changes.at(2).cardName, "Shock");
|
||||
EXPECT_EQ(result.changes.at(3).zone, "side");
|
||||
EXPECT_EQ(result.changes.at(3).cardName, "Shock");
|
||||
EXPECT_EQ(result.changes.at(4).cardName, "Teferi");
|
||||
}
|
||||
|
||||
TEST(DeckDiffTest, ReportsAddedAndRemovedCopies)
|
||||
{
|
||||
DeckList oldDeck = makeDeck({{"Lightning Bolt", 4}, {"Mountain", 20}});
|
||||
DeckList newDeck = makeDeck({{"Lightning Bolt", 2}, {"Shock", 4}, {"Mountain", 20}});
|
||||
|
||||
DeckDiffResult result = DeckDiff::diff(oldDeck, newDeck);
|
||||
ASSERT_EQ(result.changes.size(), 2);
|
||||
EXPECT_EQ(result.changes.at(0).cardName, "Lightning Bolt");
|
||||
EXPECT_EQ(result.changes.at(0).oldCount, 4);
|
||||
EXPECT_EQ(result.changes.at(0).newCount, 2);
|
||||
EXPECT_EQ(result.changes.at(0).delta(), -2);
|
||||
EXPECT_EQ(result.changes.at(1).cardName, "Shock");
|
||||
EXPECT_EQ(result.changes.at(1).oldCount, 0);
|
||||
EXPECT_EQ(result.changes.at(1).newCount, 4);
|
||||
}
|
||||
|
||||
TEST(DeckDiffTest, ZoneMoveIsTwoChanges)
|
||||
{
|
||||
DeckList oldDeck = makeDeck({{"Teferi", 1}});
|
||||
DeckList newDeck = makeDeck({}, {{"Teferi", 1}});
|
||||
|
||||
DeckDiffResult result = DeckDiff::diff(oldDeck, newDeck);
|
||||
ASSERT_EQ(result.changes.size(), 2);
|
||||
EXPECT_EQ(result.changes.at(0).zone, "main");
|
||||
EXPECT_EQ(result.changes.at(0).newCount, 0);
|
||||
EXPECT_EQ(result.changes.at(1).zone, "side");
|
||||
EXPECT_EQ(result.changes.at(1).oldCount, 0);
|
||||
}
|
||||
|
||||
TEST(DeckDiffTest, DisjointChangesCanAutoMerge)
|
||||
{
|
||||
DeckList base = makeDeck({{"Lightning Bolt", 4}, {"Mountain", 20}});
|
||||
DeckList local = makeDeck({{"Lightning Bolt", 4}, {"Mountain", 18}});
|
||||
DeckList remote = makeDeck({{"Shock", 2}, {"Lightning Bolt", 4}, {"Mountain", 20}});
|
||||
|
||||
DeckDiffResult localChanges = DeckDiff::diff(base, local);
|
||||
DeckDiffResult remoteChanges = DeckDiff::diff(base, remote);
|
||||
EXPECT_TRUE(DeckDiff::canAutoMerge(localChanges, remoteChanges));
|
||||
|
||||
DeckList merged = DeckDiff::merge(base, localChanges, remoteChanges);
|
||||
EXPECT_EQ(countOf(merged, "main", "Mountain"), 18);
|
||||
EXPECT_EQ(countOf(merged, "main", "Shock"), 2);
|
||||
EXPECT_EQ(countOf(merged, "main", "Lightning Bolt"), 4);
|
||||
}
|
||||
|
||||
TEST(DeckDiffTest, OverlappingChangesCannotAutoMergeAndRemoteWins)
|
||||
{
|
||||
DeckList base = makeDeck({{"Lightning Bolt", 4}});
|
||||
DeckList local = makeDeck({{"Lightning Bolt", 3}});
|
||||
DeckList remote = makeDeck({{"Lightning Bolt", 1}});
|
||||
|
||||
DeckDiffResult localChanges = DeckDiff::diff(base, local);
|
||||
DeckDiffResult remoteChanges = DeckDiff::diff(base, remote);
|
||||
EXPECT_FALSE(DeckDiff::canAutoMerge(localChanges, remoteChanges));
|
||||
|
||||
DeckList merged = DeckDiff::merge(base, localChanges, remoteChanges);
|
||||
EXPECT_EQ(countOf(merged, "main", "Lightning Bolt"), 1);
|
||||
}
|
||||
|
||||
TEST(DeckDiffTest, MergeKeepsBaseMetadata)
|
||||
{
|
||||
DeckList base = makeDeck({{"Lightning Bolt", 4}});
|
||||
base.setName("Affinity v3");
|
||||
DeckList remote = makeDeck({{"Lightning Bolt", 4}, {"Shock", 1}});
|
||||
|
||||
DeckList merged = DeckDiff::merge(base, DeckDiff::diff(base, base), DeckDiff::diff(base, remote));
|
||||
EXPECT_EQ(merged.getName(), "Affinity v3");
|
||||
EXPECT_EQ(countOf(merged, "main", "Shock"), 1);
|
||||
}
|
||||
|
||||
TEST(DeckDiffTest, MergePreservesBasePrintingAndCollapsesNodes)
|
||||
{
|
||||
DeckList base;
|
||||
base.addCard("Mountain", "main", -1, "DOM", "250")->setNumber(4);
|
||||
base.refreshDeckHash();
|
||||
|
||||
DeckList local = base;
|
||||
local.addCard("Shock", "main", -1, "M19", "156")->setNumber(2);
|
||||
local.refreshDeckHash();
|
||||
|
||||
DeckList remote = base;
|
||||
remote.addCard("Lightning Bolt", "main", -1, "M10", "146")->setNumber(4);
|
||||
remote.refreshDeckHash();
|
||||
|
||||
const DeckDiffResult localChanges = DeckDiff::diff(base, local);
|
||||
const DeckDiffResult remoteChanges = DeckDiff::diff(base, remote);
|
||||
ASSERT_TRUE(DeckDiff::canAutoMerge(localChanges, remoteChanges));
|
||||
|
||||
const DeckList merged = DeckDiff::merge(base, localChanges, remoteChanges);
|
||||
|
||||
const QList<DecklistCardNode *> mountains = nodesOf(merged, "main", "Mountain");
|
||||
ASSERT_EQ(mountains.size(), 1);
|
||||
EXPECT_EQ(mountains.first()->getNumber(), 4);
|
||||
EXPECT_EQ(mountains.first()->getCardSetShortName(), "DOM");
|
||||
EXPECT_EQ(mountains.first()->getCardCollectorNumber(), "250");
|
||||
|
||||
const QList<DecklistCardNode *> shocks = nodesOf(merged, "main", "Shock");
|
||||
ASSERT_EQ(shocks.size(), 1);
|
||||
EXPECT_EQ(shocks.first()->getNumber(), 2);
|
||||
EXPECT_EQ(shocks.first()->getCardSetShortName(), "M19");
|
||||
EXPECT_EQ(shocks.first()->getCardCollectorNumber(), "156");
|
||||
}
|
||||
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
::testing::InitGoogleTest(&argc, argv);
|
||||
return RUN_ALL_TESTS();
|
||||
}
|
||||
63
tests/deck_sync_profile_test.cpp
Normal file
63
tests/deck_sync_profile_test.cpp
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
#include "gtest/gtest.h"
|
||||
#include <QString>
|
||||
#include <deck_sync_profile.h>
|
||||
|
||||
TEST(DeckSyncProfileTest, KeyIsStable)
|
||||
{
|
||||
DeckSyncProfile first("woogerworks.com", 4747, "Skog7");
|
||||
DeckSyncProfile second("woogerworks.com", 4747, "Skog7");
|
||||
EXPECT_EQ(first.key(), second.key());
|
||||
EXPECT_FALSE(first.key().isEmpty());
|
||||
}
|
||||
|
||||
TEST(DeckSyncProfileTest, KeyIgnoresCase)
|
||||
{
|
||||
DeckSyncProfile lower("woogerworks.com", 4747, "skog7");
|
||||
DeckSyncProfile upper("WOOGERWORKS.COM", 4747, "SKOG7");
|
||||
EXPECT_EQ(lower.key(), upper.key());
|
||||
}
|
||||
|
||||
TEST(DeckSyncProfileTest, KeyDiffersPerServerAndUser)
|
||||
{
|
||||
DeckSyncProfile base("woogerworks.com", 4747, "skog7");
|
||||
EXPECT_NE(base.key(), DeckSyncProfile("other.server", 4747, "skog7").key());
|
||||
EXPECT_NE(base.key(), DeckSyncProfile("woogerworks.com", 4748, "skog7").key());
|
||||
EXPECT_NE(base.key(), DeckSyncProfile("woogerworks.com", 4747, "other").key());
|
||||
}
|
||||
|
||||
TEST(DeckSyncProfileTest, KeyIsUnambiguousAcrossSeparators)
|
||||
{
|
||||
DeckSyncProfile left("a:1", 2, "b");
|
||||
DeckSyncProfile right("a", 1, "2:b");
|
||||
EXPECT_NE(left.key(), right.key());
|
||||
EXPECT_NE(DeckSyncProfile("::1", 4747, "skog7").key(), DeckSyncProfile("::2", 4747, "skog7").key());
|
||||
}
|
||||
|
||||
TEST(DeckSyncProfileTest, InvalidProfilesHaveNoKey)
|
||||
{
|
||||
EXPECT_TRUE(DeckSyncProfile().key().isEmpty());
|
||||
EXPECT_TRUE(DeckSyncProfile("", 4747, "skog7").key().isEmpty());
|
||||
EXPECT_TRUE(DeckSyncProfile("woogerworks.com", 0, "skog7").key().isEmpty());
|
||||
}
|
||||
|
||||
TEST(DeckSyncProfileTest, KeyIgnoresSurroundingWhitespace)
|
||||
{
|
||||
DeckSyncProfile plain("woogerworks.com", 4747, "skog7");
|
||||
DeckSyncProfile padded(" woogerworks.com ", 4747, " skog7 ");
|
||||
EXPECT_EQ(plain.key(), padded.key());
|
||||
}
|
||||
|
||||
TEST(DeckSyncProfileTest, ValidityRequiresAllFields)
|
||||
{
|
||||
EXPECT_TRUE(DeckSyncProfile("woogerworks.com", 4747, "skog7").isValid());
|
||||
EXPECT_FALSE(DeckSyncProfile("", 4747, "skog7").isValid());
|
||||
EXPECT_FALSE(DeckSyncProfile("woogerworks.com", 0, "skog7").isValid());
|
||||
EXPECT_FALSE(DeckSyncProfile("woogerworks.com", 4747, "").isValid());
|
||||
EXPECT_FALSE(DeckSyncProfile().isValid());
|
||||
}
|
||||
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
::testing::InitGoogleTest(&argc, argv);
|
||||
return RUN_ALL_TESTS();
|
||||
}
|
||||
126
tests/deck_sync_sidecar_test.cpp
Normal file
126
tests/deck_sync_sidecar_test.cpp
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
#include "gtest/gtest.h"
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QString>
|
||||
#include <QTemporaryDir>
|
||||
#include <deck_sync_sidecar.h>
|
||||
|
||||
namespace
|
||||
{
|
||||
QString sidecarPath(QTemporaryDir &dir)
|
||||
{
|
||||
return dir.filePath(".sync/profilekey.json");
|
||||
}
|
||||
} // namespace
|
||||
|
||||
TEST(DeckSyncSidecarTest, MissingFileLoadsEmpty)
|
||||
{
|
||||
QTemporaryDir dir;
|
||||
ASSERT_TRUE(dir.isValid());
|
||||
DeckSyncSidecar sidecar(sidecarPath(dir));
|
||||
EXPECT_TRUE(sidecar.load());
|
||||
EXPECT_TRUE(sidecar.records().isEmpty());
|
||||
EXPECT_FALSE(sidecar.contains("affinity.cod"));
|
||||
}
|
||||
|
||||
TEST(DeckSyncSidecarTest, RoundTripsRecords)
|
||||
{
|
||||
QTemporaryDir dir;
|
||||
ASSERT_TRUE(dir.isValid());
|
||||
DeckSyncSidecar sidecar(sidecarPath(dir));
|
||||
|
||||
DeckSyncRecord record;
|
||||
record.guid = "123e4567-e89b-12d3-a456-426614174000";
|
||||
record.remoteId = 42;
|
||||
record.baseHash = "5cac19qm";
|
||||
record.lastSyncSecs = 1791739200;
|
||||
record.deleted = true;
|
||||
sidecar.set("decks/affinity.cod", record);
|
||||
EXPECT_TRUE(sidecar.save());
|
||||
|
||||
DeckSyncSidecar reloaded(sidecarPath(dir));
|
||||
EXPECT_TRUE(reloaded.load());
|
||||
EXPECT_TRUE(reloaded.contains("decks/affinity.cod"));
|
||||
DeckSyncRecord back = reloaded.get("decks/affinity.cod");
|
||||
EXPECT_EQ(back.guid, record.guid);
|
||||
EXPECT_EQ(back.remoteId, 42);
|
||||
EXPECT_EQ(back.baseHash, "5cac19qm");
|
||||
EXPECT_EQ(back.lastSyncSecs, 1791739200);
|
||||
EXPECT_TRUE(back.deleted);
|
||||
}
|
||||
|
||||
TEST(DeckSyncSidecarTest, UnknownPathReturnsDefaultRecord)
|
||||
{
|
||||
QTemporaryDir dir;
|
||||
ASSERT_TRUE(dir.isValid());
|
||||
DeckSyncSidecar sidecar(sidecarPath(dir));
|
||||
DeckSyncRecord back = sidecar.get("missing.cod");
|
||||
EXPECT_EQ(back.remoteId, -1);
|
||||
EXPECT_TRUE(back.guid.isEmpty());
|
||||
}
|
||||
|
||||
namespace
|
||||
{
|
||||
bool writeRaw(const QString &path, const QByteArray &bytes)
|
||||
{
|
||||
QDir().mkpath(QFileInfo(path).absolutePath());
|
||||
QFile file(path);
|
||||
return file.open(QIODevice::WriteOnly | QIODevice::Truncate) && file.write(bytes) == bytes.size();
|
||||
}
|
||||
} // namespace
|
||||
|
||||
TEST(DeckSyncSidecarTest, GarbageFileFailsToLoad)
|
||||
{
|
||||
QTemporaryDir dir;
|
||||
ASSERT_TRUE(dir.isValid());
|
||||
ASSERT_TRUE(writeRaw(sidecarPath(dir), "not json at all"));
|
||||
DeckSyncSidecar sidecar(sidecarPath(dir));
|
||||
EXPECT_FALSE(sidecar.load());
|
||||
}
|
||||
|
||||
TEST(DeckSyncSidecarTest, WrongRecordsShapeFailsToLoad)
|
||||
{
|
||||
QTemporaryDir dir;
|
||||
ASSERT_TRUE(dir.isValid());
|
||||
ASSERT_TRUE(writeRaw(sidecarPath(dir), R"({"records":["a.cod"]})"));
|
||||
DeckSyncSidecar missing(sidecarPath(dir));
|
||||
EXPECT_FALSE(missing.load());
|
||||
|
||||
ASSERT_TRUE(writeRaw(sidecarPath(dir), R"({"foo":1})"));
|
||||
DeckSyncSidecar other(sidecarPath(dir));
|
||||
EXPECT_FALSE(other.load());
|
||||
}
|
||||
|
||||
TEST(DeckSyncSidecarTest, NonObjectEntriesAreSkipped)
|
||||
{
|
||||
QTemporaryDir dir;
|
||||
ASSERT_TRUE(dir.isValid());
|
||||
ASSERT_TRUE(writeRaw(sidecarPath(dir), R"({"records":{"a.cod":42,"b.cod":{"guid":"g"}}})"));
|
||||
DeckSyncSidecar sidecar(sidecarPath(dir));
|
||||
EXPECT_TRUE(sidecar.load());
|
||||
EXPECT_FALSE(sidecar.contains("a.cod"));
|
||||
EXPECT_TRUE(sidecar.contains("b.cod"));
|
||||
EXPECT_EQ(sidecar.get("b.cod").guid, "g");
|
||||
}
|
||||
|
||||
TEST(DeckSyncSidecarTest, RemoveDropsRecord)
|
||||
{
|
||||
QTemporaryDir dir;
|
||||
ASSERT_TRUE(dir.isValid());
|
||||
DeckSyncSidecar sidecar(sidecarPath(dir));
|
||||
sidecar.set("a.cod", DeckSyncRecord());
|
||||
sidecar.remove("a.cod");
|
||||
EXPECT_FALSE(sidecar.contains("a.cod"));
|
||||
EXPECT_TRUE(sidecar.save());
|
||||
|
||||
DeckSyncSidecar reloaded(sidecarPath(dir));
|
||||
EXPECT_TRUE(reloaded.load());
|
||||
EXPECT_FALSE(reloaded.contains("a.cod"));
|
||||
}
|
||||
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
::testing::InitGoogleTest(&argc, argv);
|
||||
return RUN_ALL_TESTS();
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue