| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272 |
- #include "save_storage.h"
- #include <QCoreApplication>
- #include <QDateTime>
- #include <QDir>
- #include <QFile>
- #include <QJsonDocument>
- #include <QMetaType>
- #include <QSqlDatabase>
- #include <QSqlError>
- #include <QSqlQuery>
- #include <QVariant>
- #include <qglobal.h>
- #include <qjsonarray.h>
- #include <qjsonobject.h>
- #include <qnamespace.h>
- #include <qsqldatabase.h>
- #include <qsqlerror.h>
- #include <qsqlquery.h>
- #include <qstringliteral.h>
- #include <qvariant.h>
- #include <utility>
- #include "../map/campaign_definition.h"
- #include "../map/campaign_loader.h"
- #include "../map/mission_loader.h"
- #include "utils/resource_utils.h"
- namespace Game::Systems {
- namespace {
- constexpr const char *k_driver_name = "QSQLITE";
- constexpr int k_current_schema_version = 3;
- auto build_connection_name(const SaveStorage *instance) -> QString {
- return QStringLiteral("SaveStorage_%1")
- .arg(reinterpret_cast<quintptr>(instance), 0, 16);
- }
- auto last_error_string(const QSqlError &error) -> QString {
- if (error.type() == QSqlError::NoError) {
- return {};
- }
- return error.text();
- }
- class TransactionGuard {
- public:
- explicit TransactionGuard(QSqlDatabase &database) : m_database(database) {}
- auto begin(QString *out_error) -> bool {
- if (!m_database.transaction()) {
- if (out_error != nullptr) {
- *out_error = QStringLiteral("Failed to begin transaction: %1")
- .arg(last_error_string(m_database.lastError()));
- }
- return false;
- }
- m_active = true;
- return true;
- }
- auto commit(QString *out_error) -> bool {
- if (!m_active) {
- return true;
- }
- if (!m_database.commit()) {
- if (out_error != nullptr) {
- *out_error = QStringLiteral("Failed to commit transaction: %1")
- .arg(last_error_string(m_database.lastError()));
- }
- rollback();
- return false;
- }
- m_active = false;
- return true;
- }
- void rollback() {
- if (m_active) {
- m_database.rollback();
- m_active = false;
- }
- }
- ~TransactionGuard() { rollback(); }
- private:
- QSqlDatabase &m_database;
- bool m_active = false;
- };
- } // namespace
- SaveStorage::SaveStorage(QString database_path)
- : m_database_path(std::move(database_path)),
- m_connection_name(build_connection_name(this)) {}
- SaveStorage::~SaveStorage() {
- if (m_database.isValid()) {
- if (m_database.isOpen()) {
- m_database.close();
- }
- const QString connection_name = m_connection_name;
- m_database = QSqlDatabase();
- QSqlDatabase::removeDatabase(connection_name);
- }
- }
- auto SaveStorage::initialize(QString *out_error) const -> bool {
- if (m_initialized && m_database.isValid() && m_database.isOpen()) {
- return true;
- }
- if (!open(out_error)) {
- return false;
- }
- if (!ensure_schema(out_error)) {
- return false;
- }
- m_initialized = true;
- return true;
- }
- auto SaveStorage::save_slot(const QString &slot_name, const QString &title,
- const QJsonObject &metadata,
- const QByteArray &world_state,
- const QByteArray &screenshot,
- QString *out_error) -> bool {
- if (!initialize(out_error)) {
- return false;
- }
- TransactionGuard transaction(m_database);
- if (!transaction.begin(out_error)) {
- return false;
- }
- QSqlQuery query(m_database);
- const QString insert_sql = QStringLiteral(
- "INSERT INTO saves (slot_name, title, map_name, timestamp, "
- "metadata, world_state, screenshot, created_at, updated_at) "
- "VALUES (:slot_name, :title, :map_name, :timestamp, :metadata, "
- ":world_state, :screenshot, :created_at, :updated_at) "
- "ON CONFLICT(slot_name) DO UPDATE SET "
- "title = excluded.title, "
- "map_name = excluded.map_name, "
- "timestamp = excluded.timestamp, "
- "metadata = excluded.metadata, "
- "world_state = excluded.world_state, "
- "screenshot = excluded.screenshot, "
- "updated_at = excluded.updated_at");
- if (!query.prepare(insert_sql)) {
- if (out_error != nullptr) {
- *out_error = QStringLiteral("Failed to prepare save query: %1")
- .arg(last_error_string(query.lastError()));
- }
- return false;
- }
- const QString now_iso =
- QDateTime::currentDateTimeUtc().toString(Qt::ISODateWithMs);
- QString map_name = metadata.value("map_name").toString();
- if (map_name.isEmpty()) {
- map_name = QStringLiteral("Unknown Map");
- }
- const QByteArray metadata_bytes =
- QJsonDocument(metadata).toJson(QJsonDocument::Compact);
- query.bindValue(QStringLiteral(":slot_name"), slot_name);
- query.bindValue(QStringLiteral(":title"), title);
- query.bindValue(QStringLiteral(":map_name"), map_name);
- query.bindValue(QStringLiteral(":timestamp"), now_iso);
- query.bindValue(QStringLiteral(":metadata"), metadata_bytes);
- query.bindValue(QStringLiteral(":world_state"), world_state);
- if (screenshot.isEmpty()) {
- query.bindValue(QStringLiteral(":screenshot"),
- QVariant(QMetaType::fromType<QByteArray>()));
- } else {
- query.bindValue(QStringLiteral(":screenshot"), screenshot);
- }
- query.bindValue(QStringLiteral(":created_at"), now_iso);
- query.bindValue(QStringLiteral(":updated_at"), now_iso);
- if (!query.exec()) {
- if (out_error != nullptr) {
- *out_error = QStringLiteral("Failed to persist save slot: %1")
- .arg(last_error_string(query.lastError()));
- }
- transaction.rollback();
- return false;
- }
- if (!transaction.commit(out_error)) {
- return false;
- }
- return true;
- }
- auto SaveStorage::load_slot(const QString &slot_name, QByteArray &world_state,
- QJsonObject &metadata, QByteArray &screenshot,
- QString &title, QString *out_error) -> bool {
- if (!initialize(out_error)) {
- return false;
- }
- QSqlQuery query(m_database);
- query.prepare(QStringLiteral(
- "SELECT title, metadata, world_state, screenshot FROM saves "
- "WHERE slot_name = :slot_name"));
- query.bindValue(QStringLiteral(":slot_name"), slot_name);
- if (!query.exec()) {
- if (out_error != nullptr) {
- *out_error = QStringLiteral("Failed to read save slot: %1")
- .arg(last_error_string(query.lastError()));
- }
- return false;
- }
- if (!query.next()) {
- if (out_error != nullptr) {
- *out_error = QStringLiteral("Save slot '%1' not found").arg(slot_name);
- }
- return false;
- }
- title = query.value(0).toString();
- const QByteArray metadata_bytes = query.value(1).toByteArray();
- metadata = QJsonDocument::fromJson(metadata_bytes).object();
- world_state = query.value(2).toByteArray();
- screenshot = query.value(3).toByteArray();
- return true;
- }
- auto SaveStorage::list_slots(QString *out_error) const -> QVariantList {
- QVariantList result;
- if (!initialize(out_error)) {
- return result;
- }
- QSqlQuery query(m_database);
- if (!query.exec(QStringLiteral(
- "SELECT slot_name, title, map_name, timestamp, metadata, screenshot "
- "FROM saves ORDER BY datetime(timestamp) DESC"))) {
- if (out_error != nullptr) {
- *out_error = QStringLiteral("Failed to enumerate save slots: %1")
- .arg(last_error_string(query.lastError()));
- }
- return result;
- }
- while (query.next()) {
- QVariantMap slot;
- slot.insert(QStringLiteral("slotName"), query.value(0).toString());
- slot.insert(QStringLiteral("title"), query.value(1).toString());
- slot.insert(QStringLiteral("map_name"), query.value(2).toString());
- slot.insert(QStringLiteral("timestamp"), query.value(3).toString());
- const QByteArray metadata_bytes = query.value(4).toByteArray();
- const QJsonObject metadata_obj =
- QJsonDocument::fromJson(metadata_bytes).object();
- slot.insert(QStringLiteral("metadata"), metadata_obj.toVariantMap());
- const QByteArray screenshot_bytes = query.value(5).toByteArray();
- if (!screenshot_bytes.isEmpty()) {
- slot.insert(QStringLiteral("thumbnail"),
- QString::fromLatin1(screenshot_bytes.toBase64()));
- } else {
- slot.insert(QStringLiteral("thumbnail"), QString());
- }
- if (metadata_obj.contains("playTime")) {
- slot.insert(QStringLiteral("playTime"),
- metadata_obj.value("playTime").toString());
- }
- result.append(slot);
- }
- return result;
- }
- auto SaveStorage::list_campaigns(QString *out_error) -> QVariantList {
- QVariantList result;
- if (!initialize(out_error)) {
- return result;
- }
- QStringList campaign_files;
- QStringList search_paths = {QStringLiteral("assets/campaigns"),
- QStringLiteral("../assets/campaigns"),
- QStringLiteral("../../assets/campaigns"),
- QCoreApplication::applicationDirPath() +
- QStringLiteral("/assets/campaigns"),
- QCoreApplication::applicationDirPath() +
- QStringLiteral("/../assets/campaigns")};
- bool found_filesystem = false;
- for (const QString &campaigns_path : search_paths) {
- QDir campaigns_dir(campaigns_path);
- if (campaigns_dir.exists()) {
- campaign_files = campaigns_dir.entryList(
- QStringList() << QStringLiteral("*.json"), QDir::Files);
- if (!campaign_files.isEmpty()) {
- qInfo() << "Loading campaigns from filesystem:"
- << campaigns_dir.absolutePath();
- for (const auto &campaign_file : campaign_files) {
- const QString campaign_path = campaigns_dir.filePath(campaign_file);
- Game::Campaign::CampaignDefinition campaign;
- QString error;
- if (!Game::Campaign::CampaignLoader::loadFromJsonFile(
- campaign_path, campaign, &error)) {
- qWarning() << "Failed to load campaign" << campaign_file << ":"
- << error;
- continue;
- }
- QString db_error;
- if (!ensure_campaign_in_db(campaign, &db_error)) {
- qWarning() << "Failed to initialize campaign in DB for"
- << campaign.id << ":" << db_error;
- continue;
- }
- if (!ensure_campaign_missions_in_db(campaign, &db_error)) {
- qWarning() << "Failed to initialize campaign missions in DB for"
- << campaign.id << ":" << db_error;
- continue;
- }
- QVariantList missions_progress =
- get_campaign_mission_progress(campaign.id);
- QVariantMap campaign_map;
- campaign_map.insert(QStringLiteral("id"), campaign.id);
- campaign_map.insert(QStringLiteral("title"), campaign.title);
- campaign_map.insert(QStringLiteral("description"),
- campaign.description);
- campaign_map.insert(QStringLiteral("unlocked"), true);
- bool all_completed = true;
- QVariantList missions_list;
- for (const auto &mission : campaign.missions) {
- QVariantMap mission_map;
- mission_map.insert(QStringLiteral("mission_id"),
- mission.mission_id);
- mission_map.insert(QStringLiteral("order_index"),
- mission.order_index);
- if (mission.intro_text.has_value()) {
- mission_map.insert(QStringLiteral("intro_text"),
- *mission.intro_text);
- }
- if (mission.outro_text.has_value()) {
- mission_map.insert(QStringLiteral("outro_text"),
- *mission.outro_text);
- }
- if (mission.difficulty_modifier.has_value()) {
- mission_map.insert(QStringLiteral("difficulty_modifier"),
- *mission.difficulty_modifier);
- }
- bool unlocked = mission.order_index == 0;
- bool completed = false;
- for (const QVariant &progress_var : missions_progress) {
- QVariantMap progress = progress_var.toMap();
- if (progress["mission_id"].toString() == mission.mission_id) {
- unlocked = progress["unlocked"].toBool();
- completed = progress["completed"].toBool();
- break;
- }
- }
- mission_map.insert(QStringLiteral("unlocked"), unlocked);
- mission_map.insert(QStringLiteral("completed"), completed);
- missions_list.append(mission_map);
- if (!completed) {
- all_completed = false;
- }
- }
- campaign_map.insert(QStringLiteral("completed"), all_completed);
- campaign_map.insert(QStringLiteral("missions"), missions_list);
- result.append(campaign_map);
- }
- found_filesystem = true;
- break;
- }
- }
- }
- if (!found_filesystem) {
- qInfo() << "Loading campaigns from Qt resources";
- QStringList known_campaigns = {QStringLiteral("second_punic_war")};
- for (const auto &campaign_name : known_campaigns) {
- const QString campaign_path =
- QString(":/assets/campaigns/%1.json").arg(campaign_name);
- QFile test_file(campaign_path);
- if (!test_file.exists()) {
- qWarning() << "Campaign resource does not exist:" << campaign_path;
- continue;
- }
- Game::Campaign::CampaignDefinition campaign;
- QString error;
- if (!Game::Campaign::CampaignLoader::loadFromJsonFile(campaign_path,
- campaign, &error)) {
- qWarning() << "Failed to load campaign from resources" << campaign_name
- << ":" << error;
- continue;
- }
- QString db_error;
- if (!ensure_campaign_in_db(campaign, &db_error)) {
- qWarning() << "Failed to initialize campaign in DB for" << campaign.id
- << ":" << db_error;
- continue;
- }
- if (!ensure_campaign_missions_in_db(campaign, &db_error)) {
- qWarning() << "Failed to initialize campaign missions in DB for"
- << campaign.id << ":" << db_error;
- continue;
- }
- QVariantList missions_progress =
- get_campaign_mission_progress(campaign.id);
- QVariantMap campaign_map;
- campaign_map.insert(QStringLiteral("id"), campaign.id);
- campaign_map.insert(QStringLiteral("title"), campaign.title);
- campaign_map.insert(QStringLiteral("description"), campaign.description);
- campaign_map.insert(QStringLiteral("unlocked"), true);
- bool all_completed = true;
- QVariantList missions_list;
- for (const auto &mission : campaign.missions) {
- QVariantMap mission_map;
- mission_map.insert(QStringLiteral("mission_id"), mission.mission_id);
- mission_map.insert(QStringLiteral("order_index"), mission.order_index);
- if (mission.intro_text.has_value()) {
- mission_map.insert(QStringLiteral("intro_text"), *mission.intro_text);
- }
- if (mission.outro_text.has_value()) {
- mission_map.insert(QStringLiteral("outro_text"), *mission.outro_text);
- }
- if (mission.difficulty_modifier.has_value()) {
- mission_map.insert(QStringLiteral("difficulty_modifier"),
- *mission.difficulty_modifier);
- }
- bool unlocked = mission.order_index == 0;
- bool completed = false;
- for (const QVariant &progress_var : missions_progress) {
- QVariantMap progress = progress_var.toMap();
- if (progress["mission_id"].toString() == mission.mission_id) {
- unlocked = progress["unlocked"].toBool();
- completed = progress["completed"].toBool();
- break;
- }
- }
- mission_map.insert(QStringLiteral("unlocked"), unlocked);
- mission_map.insert(QStringLiteral("completed"), completed);
- missions_list.append(mission_map);
- if (!completed) {
- all_completed = false;
- }
- }
- campaign_map.insert(QStringLiteral("completed"), all_completed);
- campaign_map.insert(QStringLiteral("missions"), missions_list);
- result.append(campaign_map);
- }
- }
- if (result.isEmpty()) {
- if (out_error != nullptr) {
- *out_error = QStringLiteral("No campaigns found");
- }
- qWarning() << "No campaigns found in filesystem or Qt resources";
- } else {
- qInfo() << "Successfully loaded" << result.size() << "campaign(s)";
- }
- return result;
- }
- auto SaveStorage::get_campaign_progress(
- const QString &campaign_id, QString *out_error) const -> QVariantMap {
- QVariantMap result;
- if (!const_cast<SaveStorage *>(this)->initialize(out_error)) {
- return result;
- }
- QSqlQuery query(m_database);
- query.prepare(QStringLiteral(
- "SELECT completed, unlocked, completed_at FROM campaign_progress "
- "WHERE campaign_id = :campaign_id"));
- query.bindValue(QStringLiteral(":campaign_id"), campaign_id);
- if (!query.exec()) {
- if (out_error != nullptr) {
- *out_error = QStringLiteral("Failed to get campaign progress: %1")
- .arg(last_error_string(query.lastError()));
- }
- return result;
- }
- if (query.next()) {
- result.insert(QStringLiteral("completed"), query.value(0).toInt() != 0);
- result.insert(QStringLiteral("unlocked"), query.value(1).toInt() != 0);
- result.insert(QStringLiteral("completedAt"), query.value(2).toString());
- }
- return result;
- }
- auto SaveStorage::mark_campaign_completed(const QString &campaign_id,
- QString *out_error) -> bool {
- if (!initialize(out_error)) {
- return false;
- }
- TransactionGuard transaction(m_database);
- if (!transaction.begin(out_error)) {
- return false;
- }
- const QString now_iso =
- QDateTime::currentDateTimeUtc().toString(Qt::ISODateWithMs);
- QSqlQuery query(m_database);
- query.prepare(
- QStringLiteral("INSERT INTO campaign_progress (campaign_id, completed, "
- "unlocked, completed_at) "
- "VALUES (:campaign_id, 1, 1, :completed_at) "
- "ON CONFLICT(campaign_id) DO UPDATE SET "
- "completed = 1, completed_at = excluded.completed_at"));
- query.bindValue(QStringLiteral(":campaign_id"), campaign_id);
- query.bindValue(QStringLiteral(":completed_at"), now_iso);
- if (!query.exec()) {
- if (out_error != nullptr) {
- *out_error = QStringLiteral("Failed to mark campaign as completed: %1")
- .arg(last_error_string(query.lastError()));
- }
- transaction.rollback();
- return false;
- }
- if (!transaction.commit(out_error)) {
- return false;
- }
- return true;
- }
- auto SaveStorage::delete_slot(const QString &slot_name,
- QString *out_error) -> bool {
- if (!initialize(out_error)) {
- return false;
- }
- TransactionGuard transaction(m_database);
- if (!transaction.begin(out_error)) {
- return false;
- }
- QSqlQuery query(m_database);
- query.prepare(
- QStringLiteral("DELETE FROM saves WHERE slot_name = :slot_name"));
- query.bindValue(QStringLiteral(":slot_name"), slot_name);
- if (!query.exec()) {
- if (out_error != nullptr) {
- *out_error = QStringLiteral("Failed to delete save slot: %1")
- .arg(last_error_string(query.lastError()));
- }
- transaction.rollback();
- return false;
- }
- if (query.numRowsAffected() == 0) {
- if (out_error != nullptr) {
- *out_error = QStringLiteral("Save slot '%1' not found").arg(slot_name);
- }
- transaction.rollback();
- return false;
- }
- if (!transaction.commit(out_error)) {
- return false;
- }
- return true;
- }
- auto SaveStorage::open(QString *out_error) const -> bool {
- if (m_database.isValid() && m_database.isOpen()) {
- return true;
- }
- if (!m_database.isValid()) {
- m_database = QSqlDatabase::addDatabase(k_driver_name, m_connection_name);
- m_database.setDatabaseName(m_database_path);
- m_database.setConnectOptions(QStringLiteral("QSQLITE_BUSY_TIMEOUT=5000"));
- }
- if (!m_database.open()) {
- if (out_error != nullptr) {
- *out_error = QStringLiteral("Failed to open save database: %1")
- .arg(last_error_string(m_database.lastError()));
- }
- return false;
- }
- QSqlQuery foreign_keys_query(m_database);
- foreign_keys_query.exec(QStringLiteral("PRAGMA foreign_keys = ON"));
- QSqlQuery journal_mode_query(m_database);
- journal_mode_query.exec(QStringLiteral("PRAGMA journal_mode=WAL"));
- return true;
- }
- auto SaveStorage::ensure_schema(QString *out_error) const -> bool {
- const int current_version = schema_version(out_error);
- if (current_version < 0) {
- return false;
- }
- if (current_version > k_current_schema_version) {
- if (out_error != nullptr) {
- *out_error =
- QStringLiteral(
- "Save database schema version %1 is newer than supported %2")
- .arg(current_version)
- .arg(k_current_schema_version);
- }
- return false;
- }
- if (current_version == k_current_schema_version) {
- return true;
- }
- TransactionGuard transaction(m_database);
- if (!transaction.begin(out_error)) {
- return false;
- }
- if (!migrate_schema(current_version, out_error)) {
- transaction.rollback();
- return false;
- }
- if (!set_schema_version(k_current_schema_version, out_error)) {
- transaction.rollback();
- return false;
- }
- if (!transaction.commit(out_error)) {
- return false;
- }
- return true;
- }
- auto SaveStorage::schema_version(QString *out_error) const -> int {
- QSqlQuery pragma_query(m_database);
- if (!pragma_query.exec(QStringLiteral("PRAGMA user_version"))) {
- if (out_error != nullptr) {
- *out_error = QStringLiteral("Failed to read schema version: %1")
- .arg(last_error_string(pragma_query.lastError()));
- }
- return -1;
- }
- if (pragma_query.next()) {
- return pragma_query.value(0).toInt();
- }
- return 0;
- }
- auto SaveStorage::set_schema_version(int version,
- QString *out_error) const -> bool {
- QSqlQuery pragma_query(m_database);
- if (!pragma_query.exec(
- QStringLiteral("PRAGMA user_version = %1").arg(version))) {
- if (out_error != nullptr) {
- *out_error = QStringLiteral("Failed to update schema version: %1")
- .arg(last_error_string(pragma_query.lastError()));
- }
- return false;
- }
- return true;
- }
- auto SaveStorage::create_base_schema(QString *out_error) const -> bool {
- QSqlQuery query(m_database);
- const QString create_sql =
- QStringLiteral("CREATE TABLE IF NOT EXISTS saves ("
- "id INTEGER PRIMARY KEY AUTOINCREMENT, "
- "slot_name TEXT UNIQUE NOT NULL, "
- "title TEXT NOT NULL, "
- "map_name TEXT, "
- "timestamp TEXT NOT NULL, "
- "metadata BLOB NOT NULL, "
- "world_state BLOB NOT NULL, "
- "screenshot BLOB, "
- "created_at TEXT NOT NULL, "
- "updated_at TEXT NOT NULL"
- ")");
- if (!query.exec(create_sql)) {
- if (out_error != nullptr) {
- *out_error = QStringLiteral("Failed to create save schema: %1")
- .arg(last_error_string(query.lastError()));
- }
- return false;
- }
- QSqlQuery index_query(m_database);
- if (!index_query.exec(QStringLiteral(
- "CREATE INDEX IF NOT EXISTS idx_saves_updated_at ON saves "
- "(updated_at DESC)"))) {
- if (out_error != nullptr) {
- *out_error = QStringLiteral("Failed to build save index: %1")
- .arg(last_error_string(index_query.lastError()));
- }
- return false;
- }
- return true;
- }
- auto SaveStorage::migrate_schema(int fromVersion,
- QString *out_error) const -> bool {
- int version = fromVersion;
- while (version < k_current_schema_version) {
- switch (version) {
- case 0:
- if (!create_base_schema(out_error)) {
- return false;
- }
- version = 1;
- break;
- case 1:
- if (!migrate_to_2(out_error)) {
- return false;
- }
- version = 2;
- break;
- case 2:
- if (!migrate_to_3(out_error)) {
- return false;
- }
- version = 3;
- break;
- default:
- if (out_error != nullptr) {
- *out_error =
- QStringLiteral("Unsupported migration path from %1").arg(version);
- }
- return false;
- }
- }
- return true;
- }
- auto SaveStorage::migrate_to_2(QString *out_error) const -> bool {
- QSqlQuery query(m_database);
- const QString create_campaigns_sql =
- QStringLiteral("CREATE TABLE IF NOT EXISTS campaigns ("
- "id TEXT PRIMARY KEY NOT NULL, "
- "title TEXT NOT NULL, "
- "description TEXT NOT NULL, "
- "map_path TEXT NOT NULL, "
- "order_index INTEGER NOT NULL DEFAULT 0"
- ")");
- if (!query.exec(create_campaigns_sql)) {
- if (out_error != nullptr) {
- *out_error = QStringLiteral("Failed to create campaigns table: %1")
- .arg(last_error_string(query.lastError()));
- }
- return false;
- }
- QSqlQuery progress_query(m_database);
- const QString create_progress_sql = QStringLiteral(
- "CREATE TABLE IF NOT EXISTS campaign_progress ("
- "campaign_id TEXT PRIMARY KEY NOT NULL, "
- "completed INTEGER NOT NULL DEFAULT 0, "
- "unlocked INTEGER NOT NULL DEFAULT 0, "
- "completed_at TEXT, "
- "FOREIGN KEY(campaign_id) REFERENCES campaigns(id) ON DELETE CASCADE"
- ")");
- if (!progress_query.exec(create_progress_sql)) {
- if (out_error != nullptr) {
- *out_error =
- QStringLiteral("Failed to create campaign_progress table: %1")
- .arg(last_error_string(progress_query.lastError()));
- }
- return false;
- }
- QSqlQuery insert_query(m_database);
- const QString insert_campaign_sql = QStringLiteral(
- "INSERT INTO campaigns (id, title, description, map_path, order_index) "
- "VALUES ('carthage_vs_rome', 'Carthage vs Rome', "
- "'Historic battle between Carthage and the Roman Republic. "
- "Command Carthaginian forces to defeat the Roman barracks.', "
- "':/assets/maps/map_rivers.json', 0)");
- if (!insert_query.exec(insert_campaign_sql)) {
- if (out_error != nullptr) {
- *out_error = QStringLiteral("Failed to insert initial campaign: %1")
- .arg(last_error_string(insert_query.lastError()));
- }
- return false;
- }
- QSqlQuery progress_insert_query(m_database);
- const QString insert_progress_sql = QStringLiteral(
- "INSERT INTO campaign_progress (campaign_id, completed, unlocked) "
- "VALUES ('carthage_vs_rome', 0, 1)");
- if (!progress_insert_query.exec(insert_progress_sql)) {
- if (out_error != nullptr) {
- *out_error =
- QStringLiteral("Failed to initialize campaign progress: %1")
- .arg(last_error_string(progress_insert_query.lastError()));
- }
- return false;
- }
- return true;
- }
- auto SaveStorage::migrate_to_3(QString *out_error) const -> bool {
- QSqlQuery query(m_database);
- const QString create_mission_progress_sql =
- QStringLiteral("CREATE TABLE IF NOT EXISTS mission_progress ("
- "id INTEGER PRIMARY KEY AUTOINCREMENT, "
- "mission_id TEXT NOT NULL, "
- "mode TEXT NOT NULL, "
- "campaign_id TEXT, "
- "completed INTEGER NOT NULL DEFAULT 0, "
- "completion_time REAL, "
- "difficulty TEXT, "
- "result TEXT, "
- "completed_at TEXT, "
- "created_at TEXT NOT NULL, "
- "updated_at TEXT NOT NULL, "
- "UNIQUE(mission_id, mode, campaign_id)"
- ")");
- if (!query.exec(create_mission_progress_sql)) {
- if (out_error != nullptr) {
- *out_error = QStringLiteral("Failed to create mission_progress table: %1")
- .arg(last_error_string(query.lastError()));
- }
- return false;
- }
- QSqlQuery missions_query(m_database);
- const QString create_campaign_missions_sql =
- QStringLiteral("CREATE TABLE IF NOT EXISTS campaign_missions ("
- "id INTEGER PRIMARY KEY AUTOINCREMENT, "
- "campaign_id TEXT NOT NULL, "
- "mission_id TEXT NOT NULL, "
- "order_index INTEGER NOT NULL, "
- "unlocked INTEGER NOT NULL DEFAULT 0, "
- "completed INTEGER NOT NULL DEFAULT 0, "
- "completed_at TEXT, "
- "UNIQUE(campaign_id, mission_id)"
- ")");
- if (!missions_query.exec(create_campaign_missions_sql)) {
- if (out_error != nullptr) {
- *out_error =
- QStringLiteral("Failed to create campaign_missions table: %1")
- .arg(last_error_string(missions_query.lastError()));
- }
- return false;
- }
- QSqlQuery index_query(m_database);
- if (!index_query.exec(QStringLiteral(
- "CREATE INDEX IF NOT EXISTS idx_mission_progress_mission_id ON "
- "mission_progress (mission_id)"))) {
- if (out_error != nullptr) {
- *out_error = QStringLiteral("Failed to create mission_progress index: %1")
- .arg(last_error_string(index_query.lastError()));
- }
- return false;
- }
- QSqlQuery campaign_index_query(m_database);
- if (!campaign_index_query.exec(QStringLiteral(
- "CREATE INDEX IF NOT EXISTS idx_campaign_missions_campaign_id ON "
- "campaign_missions (campaign_id)"))) {
- if (out_error != nullptr) {
- *out_error =
- QStringLiteral("Failed to create campaign_missions index: %1")
- .arg(last_error_string(campaign_index_query.lastError()));
- }
- return false;
- }
- return true;
- }
- auto SaveStorage::save_mission_result(
- const QString &mission_id, const QString &mode, const QString &campaign_id,
- bool completed, const QString &result, const QString &difficulty,
- float completion_time, QString *out_error) -> bool {
- if (!initialize(out_error)) {
- return false;
- }
- TransactionGuard transaction(m_database);
- if (!transaction.begin(out_error)) {
- return false;
- }
- const QString now_iso =
- QDateTime::currentDateTimeUtc().toString(Qt::ISODateWithMs);
- QSqlQuery query(m_database);
- const QString insert_sql = QStringLiteral(
- "INSERT INTO mission_progress (mission_id, mode, campaign_id, completed, "
- "completion_time, difficulty, result, completed_at, created_at, "
- "updated_at) "
- "VALUES (:mission_id, :mode, :campaign_id, :completed, :completion_time, "
- ":difficulty, :result, :completed_at, :created_at, :updated_at) "
- "ON CONFLICT(mission_id, mode, campaign_id) DO UPDATE SET "
- "completed = excluded.completed, "
- "completion_time = excluded.completion_time, "
- "difficulty = excluded.difficulty, "
- "result = excluded.result, "
- "completed_at = excluded.completed_at, "
- "updated_at = excluded.updated_at");
- if (!query.prepare(insert_sql)) {
- if (out_error != nullptr) {
- *out_error =
- QStringLiteral("Failed to prepare mission_progress insert: %1")
- .arg(last_error_string(query.lastError()));
- }
- return false;
- }
- query.bindValue(QStringLiteral(":mission_id"), mission_id);
- query.bindValue(QStringLiteral(":mode"), mode);
- if (campaign_id.isEmpty()) {
- query.bindValue(QStringLiteral(":campaign_id"), QVariant());
- } else {
- query.bindValue(QStringLiteral(":campaign_id"), campaign_id);
- }
- query.bindValue(QStringLiteral(":completed"), completed ? 1 : 0);
- query.bindValue(QStringLiteral(":completion_time"), completion_time);
- query.bindValue(QStringLiteral(":difficulty"), difficulty);
- query.bindValue(QStringLiteral(":result"), result);
- query.bindValue(QStringLiteral(":completed_at"),
- completed ? now_iso : QVariant());
- query.bindValue(QStringLiteral(":created_at"), now_iso);
- query.bindValue(QStringLiteral(":updated_at"), now_iso);
- if (!query.exec()) {
- if (out_error != nullptr) {
- *out_error = QStringLiteral("Failed to save mission result: %1")
- .arg(last_error_string(query.lastError()));
- }
- transaction.rollback();
- return false;
- }
- if (!transaction.commit(out_error)) {
- return false;
- }
- return true;
- }
- auto SaveStorage::get_mission_progress(
- const QString &mission_id, QString *out_error) const -> QVariantMap {
- QVariantMap result;
- if (!initialize(out_error)) {
- return result;
- }
- QSqlQuery query(m_database);
- query.prepare(QStringLiteral(
- "SELECT mode, campaign_id, completed, completion_time, difficulty, "
- "result, completed_at FROM mission_progress "
- "WHERE mission_id = :mission_id ORDER BY updated_at DESC LIMIT 1"));
- query.bindValue(QStringLiteral(":mission_id"), mission_id);
- if (!query.exec()) {
- if (out_error != nullptr) {
- *out_error = QStringLiteral("Failed to get mission progress: %1")
- .arg(last_error_string(query.lastError()));
- }
- return result;
- }
- if (query.next()) {
- result.insert(QStringLiteral("mode"), query.value(0).toString());
- result.insert(QStringLiteral("campaign_id"), query.value(1).toString());
- result.insert(QStringLiteral("completed"), query.value(2).toInt() != 0);
- result.insert(QStringLiteral("completion_time"), query.value(3).toDouble());
- result.insert(QStringLiteral("difficulty"), query.value(4).toString());
- result.insert(QStringLiteral("result"), query.value(5).toString());
- result.insert(QStringLiteral("completed_at"), query.value(6).toString());
- }
- return result;
- }
- auto SaveStorage::get_campaign_mission_progress(
- const QString &campaign_id, QString *out_error) const -> QVariantList {
- QVariantList result;
- if (!initialize(out_error)) {
- return result;
- }
- QSqlQuery query(m_database);
- query.prepare(QStringLiteral(
- "SELECT mission_id, order_index, unlocked, completed, completed_at "
- "FROM campaign_missions "
- "WHERE campaign_id = :campaign_id ORDER BY order_index ASC"));
- query.bindValue(QStringLiteral(":campaign_id"), campaign_id);
- if (!query.exec()) {
- if (out_error != nullptr) {
- *out_error = QStringLiteral("Failed to get campaign mission progress: %1")
- .arg(last_error_string(query.lastError()));
- }
- return result;
- }
- while (query.next()) {
- QVariantMap mission;
- mission.insert(QStringLiteral("mission_id"), query.value(0).toString());
- mission.insert(QStringLiteral("order_index"), query.value(1).toInt());
- mission.insert(QStringLiteral("unlocked"), query.value(2).toInt() != 0);
- mission.insert(QStringLiteral("completed"), query.value(3).toInt() != 0);
- mission.insert(QStringLiteral("completed_at"), query.value(4).toString());
- result.append(mission);
- }
- return result;
- }
- auto SaveStorage::ensure_campaign_missions_in_db(
- const Game::Campaign::CampaignDefinition &campaign,
- QString *out_error) -> bool {
- if (!initialize(out_error)) {
- return false;
- }
- TransactionGuard transaction(m_database);
- if (!transaction.begin(out_error)) {
- return false;
- }
- for (const auto &mission : campaign.missions) {
- QSqlQuery check_query(m_database);
- check_query.prepare(QStringLiteral(
- "SELECT COUNT(*) FROM campaign_missions "
- "WHERE campaign_id = :campaign_id AND mission_id = :mission_id"));
- check_query.bindValue(QStringLiteral(":campaign_id"), campaign.id);
- check_query.bindValue(QStringLiteral(":mission_id"), mission.mission_id);
- if (!check_query.exec() || !check_query.next()) {
- if (out_error != nullptr) {
- *out_error =
- QStringLiteral("Failed to check campaign mission existence: %1")
- .arg(last_error_string(check_query.lastError()));
- }
- transaction.rollback();
- return false;
- }
- int count = check_query.value(0).toInt();
- if (count == 0) {
- QSqlQuery insert_query(m_database);
- insert_query.prepare(QStringLiteral(
- "INSERT INTO campaign_missions (campaign_id, mission_id, "
- "order_index, unlocked, completed) "
- "VALUES (:campaign_id, :mission_id, :order_index, :unlocked, 0)"));
- insert_query.bindValue(QStringLiteral(":campaign_id"), campaign.id);
- insert_query.bindValue(QStringLiteral(":mission_id"), mission.mission_id);
- insert_query.bindValue(QStringLiteral(":order_index"),
- mission.order_index);
- insert_query.bindValue(QStringLiteral(":unlocked"),
- mission.order_index == 0 ? 1 : 0);
- if (!insert_query.exec()) {
- if (out_error != nullptr) {
- *out_error = QStringLiteral("Failed to insert campaign mission: %1")
- .arg(last_error_string(insert_query.lastError()));
- }
- transaction.rollback();
- return false;
- }
- }
- }
- if (!transaction.commit(out_error)) {
- return false;
- }
- return true;
- }
- auto SaveStorage::ensure_campaign_in_db(
- const Game::Campaign::CampaignDefinition &campaign,
- QString *out_error) -> bool {
- if (!initialize(out_error)) {
- return false;
- }
- TransactionGuard transaction(m_database);
- if (!transaction.begin(out_error)) {
- return false;
- }
- QSqlQuery insert_query(m_database);
- insert_query.prepare(QStringLiteral(
- "INSERT INTO campaigns (id, title, description, map_path, order_index) "
- "VALUES (:id, :title, :description, :map_path, :order_index) "
- "ON CONFLICT(id) DO UPDATE SET "
- "title = excluded.title, "
- "description = excluded.description"));
- insert_query.bindValue(QStringLiteral(":id"), campaign.id);
- insert_query.bindValue(QStringLiteral(":title"), campaign.title);
- insert_query.bindValue(QStringLiteral(":description"), campaign.description);
- insert_query.bindValue(QStringLiteral(":map_path"),
- QStringLiteral(":/assets/maps/map_rivers.json"));
- insert_query.bindValue(QStringLiteral(":order_index"), 0);
- if (!insert_query.exec()) {
- if (out_error != nullptr) {
- *out_error = QStringLiteral("Failed to insert campaign: %1")
- .arg(last_error_string(insert_query.lastError()));
- }
- transaction.rollback();
- return false;
- }
- if (!transaction.commit(out_error)) {
- return false;
- }
- return true;
- }
- auto SaveStorage::unlock_next_mission(const QString &campaign_id,
- const QString &completed_mission_id,
- QString *out_error) -> bool {
- if (!initialize(out_error)) {
- return false;
- }
- TransactionGuard transaction(m_database);
- if (!transaction.begin(out_error)) {
- return false;
- }
- const QString now_iso =
- QDateTime::currentDateTimeUtc().toString(Qt::ISODateWithMs);
- QSqlQuery update_query(m_database);
- update_query.prepare(QStringLiteral(
- "UPDATE campaign_missions SET completed = 1, completed_at = "
- ":completed_at "
- "WHERE campaign_id = :campaign_id AND mission_id = :mission_id"));
- update_query.bindValue(QStringLiteral(":completed_at"), now_iso);
- update_query.bindValue(QStringLiteral(":campaign_id"), campaign_id);
- update_query.bindValue(QStringLiteral(":mission_id"), completed_mission_id);
- if (!update_query.exec()) {
- if (out_error != nullptr) {
- *out_error = QStringLiteral("Failed to mark mission as completed: %1")
- .arg(last_error_string(update_query.lastError()));
- }
- transaction.rollback();
- return false;
- }
- QSqlQuery order_query(m_database);
- order_query.prepare(QStringLiteral(
- "SELECT order_index FROM campaign_missions "
- "WHERE campaign_id = :campaign_id AND mission_id = :mission_id"));
- order_query.bindValue(QStringLiteral(":campaign_id"), campaign_id);
- order_query.bindValue(QStringLiteral(":mission_id"), completed_mission_id);
- if (!order_query.exec() || !order_query.next()) {
- if (out_error != nullptr) {
- *out_error = QStringLiteral("Failed to find completed mission order: %1")
- .arg(last_error_string(order_query.lastError()));
- }
- transaction.rollback();
- return false;
- }
- int completed_order = order_query.value(0).toInt();
- QSqlQuery unlock_query(m_database);
- unlock_query.prepare(
- QStringLiteral("UPDATE campaign_missions SET unlocked = 1 "
- "WHERE campaign_id = :campaign_id AND order_index = "
- ":next_order_index"));
- unlock_query.bindValue(QStringLiteral(":campaign_id"), campaign_id);
- unlock_query.bindValue(QStringLiteral(":next_order_index"),
- completed_order + 1);
- if (!unlock_query.exec()) {
- if (out_error != nullptr) {
- *out_error = QStringLiteral("Failed to unlock next mission: %1")
- .arg(last_error_string(unlock_query.lastError()));
- }
- transaction.rollback();
- return false;
- }
- if (unlock_query.numRowsAffected() == 0) {
- if (out_error != nullptr) {
- *out_error =
- QStringLiteral("No next mission found to unlock (completed mission "
- "order: %1)")
- .arg(completed_order);
- }
- transaction.rollback();
- return false;
- }
- if (!transaction.commit(out_error)) {
- return false;
- }
- return true;
- }
- } // namespace Game::Systems
|