save_storage.cpp 41 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272
  1. #include "save_storage.h"
  2. #include <QCoreApplication>
  3. #include <QDateTime>
  4. #include <QDir>
  5. #include <QFile>
  6. #include <QJsonDocument>
  7. #include <QMetaType>
  8. #include <QSqlDatabase>
  9. #include <QSqlError>
  10. #include <QSqlQuery>
  11. #include <QVariant>
  12. #include <qglobal.h>
  13. #include <qjsonarray.h>
  14. #include <qjsonobject.h>
  15. #include <qnamespace.h>
  16. #include <qsqldatabase.h>
  17. #include <qsqlerror.h>
  18. #include <qsqlquery.h>
  19. #include <qstringliteral.h>
  20. #include <qvariant.h>
  21. #include <utility>
  22. #include "../map/campaign_definition.h"
  23. #include "../map/campaign_loader.h"
  24. #include "../map/mission_loader.h"
  25. #include "utils/resource_utils.h"
  26. namespace Game::Systems {
  27. namespace {
  28. constexpr const char *k_driver_name = "QSQLITE";
  29. constexpr int k_current_schema_version = 3;
  30. auto build_connection_name(const SaveStorage *instance) -> QString {
  31. return QStringLiteral("SaveStorage_%1")
  32. .arg(reinterpret_cast<quintptr>(instance), 0, 16);
  33. }
  34. auto last_error_string(const QSqlError &error) -> QString {
  35. if (error.type() == QSqlError::NoError) {
  36. return {};
  37. }
  38. return error.text();
  39. }
  40. class TransactionGuard {
  41. public:
  42. explicit TransactionGuard(QSqlDatabase &database) : m_database(database) {}
  43. auto begin(QString *out_error) -> bool {
  44. if (!m_database.transaction()) {
  45. if (out_error != nullptr) {
  46. *out_error = QStringLiteral("Failed to begin transaction: %1")
  47. .arg(last_error_string(m_database.lastError()));
  48. }
  49. return false;
  50. }
  51. m_active = true;
  52. return true;
  53. }
  54. auto commit(QString *out_error) -> bool {
  55. if (!m_active) {
  56. return true;
  57. }
  58. if (!m_database.commit()) {
  59. if (out_error != nullptr) {
  60. *out_error = QStringLiteral("Failed to commit transaction: %1")
  61. .arg(last_error_string(m_database.lastError()));
  62. }
  63. rollback();
  64. return false;
  65. }
  66. m_active = false;
  67. return true;
  68. }
  69. void rollback() {
  70. if (m_active) {
  71. m_database.rollback();
  72. m_active = false;
  73. }
  74. }
  75. ~TransactionGuard() { rollback(); }
  76. private:
  77. QSqlDatabase &m_database;
  78. bool m_active = false;
  79. };
  80. } // namespace
  81. SaveStorage::SaveStorage(QString database_path)
  82. : m_database_path(std::move(database_path)),
  83. m_connection_name(build_connection_name(this)) {}
  84. SaveStorage::~SaveStorage() {
  85. if (m_database.isValid()) {
  86. if (m_database.isOpen()) {
  87. m_database.close();
  88. }
  89. const QString connection_name = m_connection_name;
  90. m_database = QSqlDatabase();
  91. QSqlDatabase::removeDatabase(connection_name);
  92. }
  93. }
  94. auto SaveStorage::initialize(QString *out_error) const -> bool {
  95. if (m_initialized && m_database.isValid() && m_database.isOpen()) {
  96. return true;
  97. }
  98. if (!open(out_error)) {
  99. return false;
  100. }
  101. if (!ensure_schema(out_error)) {
  102. return false;
  103. }
  104. m_initialized = true;
  105. return true;
  106. }
  107. auto SaveStorage::save_slot(const QString &slot_name, const QString &title,
  108. const QJsonObject &metadata,
  109. const QByteArray &world_state,
  110. const QByteArray &screenshot,
  111. QString *out_error) -> bool {
  112. if (!initialize(out_error)) {
  113. return false;
  114. }
  115. TransactionGuard transaction(m_database);
  116. if (!transaction.begin(out_error)) {
  117. return false;
  118. }
  119. QSqlQuery query(m_database);
  120. const QString insert_sql = QStringLiteral(
  121. "INSERT INTO saves (slot_name, title, map_name, timestamp, "
  122. "metadata, world_state, screenshot, created_at, updated_at) "
  123. "VALUES (:slot_name, :title, :map_name, :timestamp, :metadata, "
  124. ":world_state, :screenshot, :created_at, :updated_at) "
  125. "ON CONFLICT(slot_name) DO UPDATE SET "
  126. "title = excluded.title, "
  127. "map_name = excluded.map_name, "
  128. "timestamp = excluded.timestamp, "
  129. "metadata = excluded.metadata, "
  130. "world_state = excluded.world_state, "
  131. "screenshot = excluded.screenshot, "
  132. "updated_at = excluded.updated_at");
  133. if (!query.prepare(insert_sql)) {
  134. if (out_error != nullptr) {
  135. *out_error = QStringLiteral("Failed to prepare save query: %1")
  136. .arg(last_error_string(query.lastError()));
  137. }
  138. return false;
  139. }
  140. const QString now_iso =
  141. QDateTime::currentDateTimeUtc().toString(Qt::ISODateWithMs);
  142. QString map_name = metadata.value("map_name").toString();
  143. if (map_name.isEmpty()) {
  144. map_name = QStringLiteral("Unknown Map");
  145. }
  146. const QByteArray metadata_bytes =
  147. QJsonDocument(metadata).toJson(QJsonDocument::Compact);
  148. query.bindValue(QStringLiteral(":slot_name"), slot_name);
  149. query.bindValue(QStringLiteral(":title"), title);
  150. query.bindValue(QStringLiteral(":map_name"), map_name);
  151. query.bindValue(QStringLiteral(":timestamp"), now_iso);
  152. query.bindValue(QStringLiteral(":metadata"), metadata_bytes);
  153. query.bindValue(QStringLiteral(":world_state"), world_state);
  154. if (screenshot.isEmpty()) {
  155. query.bindValue(QStringLiteral(":screenshot"),
  156. QVariant(QMetaType::fromType<QByteArray>()));
  157. } else {
  158. query.bindValue(QStringLiteral(":screenshot"), screenshot);
  159. }
  160. query.bindValue(QStringLiteral(":created_at"), now_iso);
  161. query.bindValue(QStringLiteral(":updated_at"), now_iso);
  162. if (!query.exec()) {
  163. if (out_error != nullptr) {
  164. *out_error = QStringLiteral("Failed to persist save slot: %1")
  165. .arg(last_error_string(query.lastError()));
  166. }
  167. transaction.rollback();
  168. return false;
  169. }
  170. if (!transaction.commit(out_error)) {
  171. return false;
  172. }
  173. return true;
  174. }
  175. auto SaveStorage::load_slot(const QString &slot_name, QByteArray &world_state,
  176. QJsonObject &metadata, QByteArray &screenshot,
  177. QString &title, QString *out_error) -> bool {
  178. if (!initialize(out_error)) {
  179. return false;
  180. }
  181. QSqlQuery query(m_database);
  182. query.prepare(QStringLiteral(
  183. "SELECT title, metadata, world_state, screenshot FROM saves "
  184. "WHERE slot_name = :slot_name"));
  185. query.bindValue(QStringLiteral(":slot_name"), slot_name);
  186. if (!query.exec()) {
  187. if (out_error != nullptr) {
  188. *out_error = QStringLiteral("Failed to read save slot: %1")
  189. .arg(last_error_string(query.lastError()));
  190. }
  191. return false;
  192. }
  193. if (!query.next()) {
  194. if (out_error != nullptr) {
  195. *out_error = QStringLiteral("Save slot '%1' not found").arg(slot_name);
  196. }
  197. return false;
  198. }
  199. title = query.value(0).toString();
  200. const QByteArray metadata_bytes = query.value(1).toByteArray();
  201. metadata = QJsonDocument::fromJson(metadata_bytes).object();
  202. world_state = query.value(2).toByteArray();
  203. screenshot = query.value(3).toByteArray();
  204. return true;
  205. }
  206. auto SaveStorage::list_slots(QString *out_error) const -> QVariantList {
  207. QVariantList result;
  208. if (!initialize(out_error)) {
  209. return result;
  210. }
  211. QSqlQuery query(m_database);
  212. if (!query.exec(QStringLiteral(
  213. "SELECT slot_name, title, map_name, timestamp, metadata, screenshot "
  214. "FROM saves ORDER BY datetime(timestamp) DESC"))) {
  215. if (out_error != nullptr) {
  216. *out_error = QStringLiteral("Failed to enumerate save slots: %1")
  217. .arg(last_error_string(query.lastError()));
  218. }
  219. return result;
  220. }
  221. while (query.next()) {
  222. QVariantMap slot;
  223. slot.insert(QStringLiteral("slotName"), query.value(0).toString());
  224. slot.insert(QStringLiteral("title"), query.value(1).toString());
  225. slot.insert(QStringLiteral("map_name"), query.value(2).toString());
  226. slot.insert(QStringLiteral("timestamp"), query.value(3).toString());
  227. const QByteArray metadata_bytes = query.value(4).toByteArray();
  228. const QJsonObject metadata_obj =
  229. QJsonDocument::fromJson(metadata_bytes).object();
  230. slot.insert(QStringLiteral("metadata"), metadata_obj.toVariantMap());
  231. const QByteArray screenshot_bytes = query.value(5).toByteArray();
  232. if (!screenshot_bytes.isEmpty()) {
  233. slot.insert(QStringLiteral("thumbnail"),
  234. QString::fromLatin1(screenshot_bytes.toBase64()));
  235. } else {
  236. slot.insert(QStringLiteral("thumbnail"), QString());
  237. }
  238. if (metadata_obj.contains("playTime")) {
  239. slot.insert(QStringLiteral("playTime"),
  240. metadata_obj.value("playTime").toString());
  241. }
  242. result.append(slot);
  243. }
  244. return result;
  245. }
  246. auto SaveStorage::list_campaigns(QString *out_error) -> QVariantList {
  247. QVariantList result;
  248. if (!initialize(out_error)) {
  249. return result;
  250. }
  251. QStringList campaign_files;
  252. QStringList search_paths = {QStringLiteral("assets/campaigns"),
  253. QStringLiteral("../assets/campaigns"),
  254. QStringLiteral("../../assets/campaigns"),
  255. QCoreApplication::applicationDirPath() +
  256. QStringLiteral("/assets/campaigns"),
  257. QCoreApplication::applicationDirPath() +
  258. QStringLiteral("/../assets/campaigns")};
  259. bool found_filesystem = false;
  260. for (const QString &campaigns_path : search_paths) {
  261. QDir campaigns_dir(campaigns_path);
  262. if (campaigns_dir.exists()) {
  263. campaign_files = campaigns_dir.entryList(
  264. QStringList() << QStringLiteral("*.json"), QDir::Files);
  265. if (!campaign_files.isEmpty()) {
  266. qInfo() << "Loading campaigns from filesystem:"
  267. << campaigns_dir.absolutePath();
  268. for (const auto &campaign_file : campaign_files) {
  269. const QString campaign_path = campaigns_dir.filePath(campaign_file);
  270. Game::Campaign::CampaignDefinition campaign;
  271. QString error;
  272. if (!Game::Campaign::CampaignLoader::loadFromJsonFile(
  273. campaign_path, campaign, &error)) {
  274. qWarning() << "Failed to load campaign" << campaign_file << ":"
  275. << error;
  276. continue;
  277. }
  278. QString db_error;
  279. if (!ensure_campaign_in_db(campaign, &db_error)) {
  280. qWarning() << "Failed to initialize campaign in DB for"
  281. << campaign.id << ":" << db_error;
  282. continue;
  283. }
  284. if (!ensure_campaign_missions_in_db(campaign, &db_error)) {
  285. qWarning() << "Failed to initialize campaign missions in DB for"
  286. << campaign.id << ":" << db_error;
  287. continue;
  288. }
  289. QVariantList missions_progress =
  290. get_campaign_mission_progress(campaign.id);
  291. QVariantMap campaign_map;
  292. campaign_map.insert(QStringLiteral("id"), campaign.id);
  293. campaign_map.insert(QStringLiteral("title"), campaign.title);
  294. campaign_map.insert(QStringLiteral("description"),
  295. campaign.description);
  296. campaign_map.insert(QStringLiteral("unlocked"), true);
  297. bool all_completed = true;
  298. QVariantList missions_list;
  299. for (const auto &mission : campaign.missions) {
  300. QVariantMap mission_map;
  301. mission_map.insert(QStringLiteral("mission_id"),
  302. mission.mission_id);
  303. mission_map.insert(QStringLiteral("order_index"),
  304. mission.order_index);
  305. if (mission.intro_text.has_value()) {
  306. mission_map.insert(QStringLiteral("intro_text"),
  307. *mission.intro_text);
  308. }
  309. if (mission.outro_text.has_value()) {
  310. mission_map.insert(QStringLiteral("outro_text"),
  311. *mission.outro_text);
  312. }
  313. if (mission.difficulty_modifier.has_value()) {
  314. mission_map.insert(QStringLiteral("difficulty_modifier"),
  315. *mission.difficulty_modifier);
  316. }
  317. bool unlocked = mission.order_index == 0;
  318. bool completed = false;
  319. for (const QVariant &progress_var : missions_progress) {
  320. QVariantMap progress = progress_var.toMap();
  321. if (progress["mission_id"].toString() == mission.mission_id) {
  322. unlocked = progress["unlocked"].toBool();
  323. completed = progress["completed"].toBool();
  324. break;
  325. }
  326. }
  327. mission_map.insert(QStringLiteral("unlocked"), unlocked);
  328. mission_map.insert(QStringLiteral("completed"), completed);
  329. missions_list.append(mission_map);
  330. if (!completed) {
  331. all_completed = false;
  332. }
  333. }
  334. campaign_map.insert(QStringLiteral("completed"), all_completed);
  335. campaign_map.insert(QStringLiteral("missions"), missions_list);
  336. result.append(campaign_map);
  337. }
  338. found_filesystem = true;
  339. break;
  340. }
  341. }
  342. }
  343. if (!found_filesystem) {
  344. qInfo() << "Loading campaigns from Qt resources";
  345. QStringList known_campaigns = {QStringLiteral("second_punic_war")};
  346. for (const auto &campaign_name : known_campaigns) {
  347. const QString campaign_path =
  348. QString(":/assets/campaigns/%1.json").arg(campaign_name);
  349. QFile test_file(campaign_path);
  350. if (!test_file.exists()) {
  351. qWarning() << "Campaign resource does not exist:" << campaign_path;
  352. continue;
  353. }
  354. Game::Campaign::CampaignDefinition campaign;
  355. QString error;
  356. if (!Game::Campaign::CampaignLoader::loadFromJsonFile(campaign_path,
  357. campaign, &error)) {
  358. qWarning() << "Failed to load campaign from resources" << campaign_name
  359. << ":" << error;
  360. continue;
  361. }
  362. QString db_error;
  363. if (!ensure_campaign_in_db(campaign, &db_error)) {
  364. qWarning() << "Failed to initialize campaign in DB for" << campaign.id
  365. << ":" << db_error;
  366. continue;
  367. }
  368. if (!ensure_campaign_missions_in_db(campaign, &db_error)) {
  369. qWarning() << "Failed to initialize campaign missions in DB for"
  370. << campaign.id << ":" << db_error;
  371. continue;
  372. }
  373. QVariantList missions_progress =
  374. get_campaign_mission_progress(campaign.id);
  375. QVariantMap campaign_map;
  376. campaign_map.insert(QStringLiteral("id"), campaign.id);
  377. campaign_map.insert(QStringLiteral("title"), campaign.title);
  378. campaign_map.insert(QStringLiteral("description"), campaign.description);
  379. campaign_map.insert(QStringLiteral("unlocked"), true);
  380. bool all_completed = true;
  381. QVariantList missions_list;
  382. for (const auto &mission : campaign.missions) {
  383. QVariantMap mission_map;
  384. mission_map.insert(QStringLiteral("mission_id"), mission.mission_id);
  385. mission_map.insert(QStringLiteral("order_index"), mission.order_index);
  386. if (mission.intro_text.has_value()) {
  387. mission_map.insert(QStringLiteral("intro_text"), *mission.intro_text);
  388. }
  389. if (mission.outro_text.has_value()) {
  390. mission_map.insert(QStringLiteral("outro_text"), *mission.outro_text);
  391. }
  392. if (mission.difficulty_modifier.has_value()) {
  393. mission_map.insert(QStringLiteral("difficulty_modifier"),
  394. *mission.difficulty_modifier);
  395. }
  396. bool unlocked = mission.order_index == 0;
  397. bool completed = false;
  398. for (const QVariant &progress_var : missions_progress) {
  399. QVariantMap progress = progress_var.toMap();
  400. if (progress["mission_id"].toString() == mission.mission_id) {
  401. unlocked = progress["unlocked"].toBool();
  402. completed = progress["completed"].toBool();
  403. break;
  404. }
  405. }
  406. mission_map.insert(QStringLiteral("unlocked"), unlocked);
  407. mission_map.insert(QStringLiteral("completed"), completed);
  408. missions_list.append(mission_map);
  409. if (!completed) {
  410. all_completed = false;
  411. }
  412. }
  413. campaign_map.insert(QStringLiteral("completed"), all_completed);
  414. campaign_map.insert(QStringLiteral("missions"), missions_list);
  415. result.append(campaign_map);
  416. }
  417. }
  418. if (result.isEmpty()) {
  419. if (out_error != nullptr) {
  420. *out_error = QStringLiteral("No campaigns found");
  421. }
  422. qWarning() << "No campaigns found in filesystem or Qt resources";
  423. } else {
  424. qInfo() << "Successfully loaded" << result.size() << "campaign(s)";
  425. }
  426. return result;
  427. }
  428. auto SaveStorage::get_campaign_progress(
  429. const QString &campaign_id, QString *out_error) const -> QVariantMap {
  430. QVariantMap result;
  431. if (!const_cast<SaveStorage *>(this)->initialize(out_error)) {
  432. return result;
  433. }
  434. QSqlQuery query(m_database);
  435. query.prepare(QStringLiteral(
  436. "SELECT completed, unlocked, completed_at FROM campaign_progress "
  437. "WHERE campaign_id = :campaign_id"));
  438. query.bindValue(QStringLiteral(":campaign_id"), campaign_id);
  439. if (!query.exec()) {
  440. if (out_error != nullptr) {
  441. *out_error = QStringLiteral("Failed to get campaign progress: %1")
  442. .arg(last_error_string(query.lastError()));
  443. }
  444. return result;
  445. }
  446. if (query.next()) {
  447. result.insert(QStringLiteral("completed"), query.value(0).toInt() != 0);
  448. result.insert(QStringLiteral("unlocked"), query.value(1).toInt() != 0);
  449. result.insert(QStringLiteral("completedAt"), query.value(2).toString());
  450. }
  451. return result;
  452. }
  453. auto SaveStorage::mark_campaign_completed(const QString &campaign_id,
  454. QString *out_error) -> bool {
  455. if (!initialize(out_error)) {
  456. return false;
  457. }
  458. TransactionGuard transaction(m_database);
  459. if (!transaction.begin(out_error)) {
  460. return false;
  461. }
  462. const QString now_iso =
  463. QDateTime::currentDateTimeUtc().toString(Qt::ISODateWithMs);
  464. QSqlQuery query(m_database);
  465. query.prepare(
  466. QStringLiteral("INSERT INTO campaign_progress (campaign_id, completed, "
  467. "unlocked, completed_at) "
  468. "VALUES (:campaign_id, 1, 1, :completed_at) "
  469. "ON CONFLICT(campaign_id) DO UPDATE SET "
  470. "completed = 1, completed_at = excluded.completed_at"));
  471. query.bindValue(QStringLiteral(":campaign_id"), campaign_id);
  472. query.bindValue(QStringLiteral(":completed_at"), now_iso);
  473. if (!query.exec()) {
  474. if (out_error != nullptr) {
  475. *out_error = QStringLiteral("Failed to mark campaign as completed: %1")
  476. .arg(last_error_string(query.lastError()));
  477. }
  478. transaction.rollback();
  479. return false;
  480. }
  481. if (!transaction.commit(out_error)) {
  482. return false;
  483. }
  484. return true;
  485. }
  486. auto SaveStorage::delete_slot(const QString &slot_name,
  487. QString *out_error) -> bool {
  488. if (!initialize(out_error)) {
  489. return false;
  490. }
  491. TransactionGuard transaction(m_database);
  492. if (!transaction.begin(out_error)) {
  493. return false;
  494. }
  495. QSqlQuery query(m_database);
  496. query.prepare(
  497. QStringLiteral("DELETE FROM saves WHERE slot_name = :slot_name"));
  498. query.bindValue(QStringLiteral(":slot_name"), slot_name);
  499. if (!query.exec()) {
  500. if (out_error != nullptr) {
  501. *out_error = QStringLiteral("Failed to delete save slot: %1")
  502. .arg(last_error_string(query.lastError()));
  503. }
  504. transaction.rollback();
  505. return false;
  506. }
  507. if (query.numRowsAffected() == 0) {
  508. if (out_error != nullptr) {
  509. *out_error = QStringLiteral("Save slot '%1' not found").arg(slot_name);
  510. }
  511. transaction.rollback();
  512. return false;
  513. }
  514. if (!transaction.commit(out_error)) {
  515. return false;
  516. }
  517. return true;
  518. }
  519. auto SaveStorage::open(QString *out_error) const -> bool {
  520. if (m_database.isValid() && m_database.isOpen()) {
  521. return true;
  522. }
  523. if (!m_database.isValid()) {
  524. m_database = QSqlDatabase::addDatabase(k_driver_name, m_connection_name);
  525. m_database.setDatabaseName(m_database_path);
  526. m_database.setConnectOptions(QStringLiteral("QSQLITE_BUSY_TIMEOUT=5000"));
  527. }
  528. if (!m_database.open()) {
  529. if (out_error != nullptr) {
  530. *out_error = QStringLiteral("Failed to open save database: %1")
  531. .arg(last_error_string(m_database.lastError()));
  532. }
  533. return false;
  534. }
  535. QSqlQuery foreign_keys_query(m_database);
  536. foreign_keys_query.exec(QStringLiteral("PRAGMA foreign_keys = ON"));
  537. QSqlQuery journal_mode_query(m_database);
  538. journal_mode_query.exec(QStringLiteral("PRAGMA journal_mode=WAL"));
  539. return true;
  540. }
  541. auto SaveStorage::ensure_schema(QString *out_error) const -> bool {
  542. const int current_version = schema_version(out_error);
  543. if (current_version < 0) {
  544. return false;
  545. }
  546. if (current_version > k_current_schema_version) {
  547. if (out_error != nullptr) {
  548. *out_error =
  549. QStringLiteral(
  550. "Save database schema version %1 is newer than supported %2")
  551. .arg(current_version)
  552. .arg(k_current_schema_version);
  553. }
  554. return false;
  555. }
  556. if (current_version == k_current_schema_version) {
  557. return true;
  558. }
  559. TransactionGuard transaction(m_database);
  560. if (!transaction.begin(out_error)) {
  561. return false;
  562. }
  563. if (!migrate_schema(current_version, out_error)) {
  564. transaction.rollback();
  565. return false;
  566. }
  567. if (!set_schema_version(k_current_schema_version, out_error)) {
  568. transaction.rollback();
  569. return false;
  570. }
  571. if (!transaction.commit(out_error)) {
  572. return false;
  573. }
  574. return true;
  575. }
  576. auto SaveStorage::schema_version(QString *out_error) const -> int {
  577. QSqlQuery pragma_query(m_database);
  578. if (!pragma_query.exec(QStringLiteral("PRAGMA user_version"))) {
  579. if (out_error != nullptr) {
  580. *out_error = QStringLiteral("Failed to read schema version: %1")
  581. .arg(last_error_string(pragma_query.lastError()));
  582. }
  583. return -1;
  584. }
  585. if (pragma_query.next()) {
  586. return pragma_query.value(0).toInt();
  587. }
  588. return 0;
  589. }
  590. auto SaveStorage::set_schema_version(int version,
  591. QString *out_error) const -> bool {
  592. QSqlQuery pragma_query(m_database);
  593. if (!pragma_query.exec(
  594. QStringLiteral("PRAGMA user_version = %1").arg(version))) {
  595. if (out_error != nullptr) {
  596. *out_error = QStringLiteral("Failed to update schema version: %1")
  597. .arg(last_error_string(pragma_query.lastError()));
  598. }
  599. return false;
  600. }
  601. return true;
  602. }
  603. auto SaveStorage::create_base_schema(QString *out_error) const -> bool {
  604. QSqlQuery query(m_database);
  605. const QString create_sql =
  606. QStringLiteral("CREATE TABLE IF NOT EXISTS saves ("
  607. "id INTEGER PRIMARY KEY AUTOINCREMENT, "
  608. "slot_name TEXT UNIQUE NOT NULL, "
  609. "title TEXT NOT NULL, "
  610. "map_name TEXT, "
  611. "timestamp TEXT NOT NULL, "
  612. "metadata BLOB NOT NULL, "
  613. "world_state BLOB NOT NULL, "
  614. "screenshot BLOB, "
  615. "created_at TEXT NOT NULL, "
  616. "updated_at TEXT NOT NULL"
  617. ")");
  618. if (!query.exec(create_sql)) {
  619. if (out_error != nullptr) {
  620. *out_error = QStringLiteral("Failed to create save schema: %1")
  621. .arg(last_error_string(query.lastError()));
  622. }
  623. return false;
  624. }
  625. QSqlQuery index_query(m_database);
  626. if (!index_query.exec(QStringLiteral(
  627. "CREATE INDEX IF NOT EXISTS idx_saves_updated_at ON saves "
  628. "(updated_at DESC)"))) {
  629. if (out_error != nullptr) {
  630. *out_error = QStringLiteral("Failed to build save index: %1")
  631. .arg(last_error_string(index_query.lastError()));
  632. }
  633. return false;
  634. }
  635. return true;
  636. }
  637. auto SaveStorage::migrate_schema(int fromVersion,
  638. QString *out_error) const -> bool {
  639. int version = fromVersion;
  640. while (version < k_current_schema_version) {
  641. switch (version) {
  642. case 0:
  643. if (!create_base_schema(out_error)) {
  644. return false;
  645. }
  646. version = 1;
  647. break;
  648. case 1:
  649. if (!migrate_to_2(out_error)) {
  650. return false;
  651. }
  652. version = 2;
  653. break;
  654. case 2:
  655. if (!migrate_to_3(out_error)) {
  656. return false;
  657. }
  658. version = 3;
  659. break;
  660. default:
  661. if (out_error != nullptr) {
  662. *out_error =
  663. QStringLiteral("Unsupported migration path from %1").arg(version);
  664. }
  665. return false;
  666. }
  667. }
  668. return true;
  669. }
  670. auto SaveStorage::migrate_to_2(QString *out_error) const -> bool {
  671. QSqlQuery query(m_database);
  672. const QString create_campaigns_sql =
  673. QStringLiteral("CREATE TABLE IF NOT EXISTS campaigns ("
  674. "id TEXT PRIMARY KEY NOT NULL, "
  675. "title TEXT NOT NULL, "
  676. "description TEXT NOT NULL, "
  677. "map_path TEXT NOT NULL, "
  678. "order_index INTEGER NOT NULL DEFAULT 0"
  679. ")");
  680. if (!query.exec(create_campaigns_sql)) {
  681. if (out_error != nullptr) {
  682. *out_error = QStringLiteral("Failed to create campaigns table: %1")
  683. .arg(last_error_string(query.lastError()));
  684. }
  685. return false;
  686. }
  687. QSqlQuery progress_query(m_database);
  688. const QString create_progress_sql = QStringLiteral(
  689. "CREATE TABLE IF NOT EXISTS campaign_progress ("
  690. "campaign_id TEXT PRIMARY KEY NOT NULL, "
  691. "completed INTEGER NOT NULL DEFAULT 0, "
  692. "unlocked INTEGER NOT NULL DEFAULT 0, "
  693. "completed_at TEXT, "
  694. "FOREIGN KEY(campaign_id) REFERENCES campaigns(id) ON DELETE CASCADE"
  695. ")");
  696. if (!progress_query.exec(create_progress_sql)) {
  697. if (out_error != nullptr) {
  698. *out_error =
  699. QStringLiteral("Failed to create campaign_progress table: %1")
  700. .arg(last_error_string(progress_query.lastError()));
  701. }
  702. return false;
  703. }
  704. QSqlQuery insert_query(m_database);
  705. const QString insert_campaign_sql = QStringLiteral(
  706. "INSERT INTO campaigns (id, title, description, map_path, order_index) "
  707. "VALUES ('carthage_vs_rome', 'Carthage vs Rome', "
  708. "'Historic battle between Carthage and the Roman Republic. "
  709. "Command Carthaginian forces to defeat the Roman barracks.', "
  710. "':/assets/maps/map_rivers.json', 0)");
  711. if (!insert_query.exec(insert_campaign_sql)) {
  712. if (out_error != nullptr) {
  713. *out_error = QStringLiteral("Failed to insert initial campaign: %1")
  714. .arg(last_error_string(insert_query.lastError()));
  715. }
  716. return false;
  717. }
  718. QSqlQuery progress_insert_query(m_database);
  719. const QString insert_progress_sql = QStringLiteral(
  720. "INSERT INTO campaign_progress (campaign_id, completed, unlocked) "
  721. "VALUES ('carthage_vs_rome', 0, 1)");
  722. if (!progress_insert_query.exec(insert_progress_sql)) {
  723. if (out_error != nullptr) {
  724. *out_error =
  725. QStringLiteral("Failed to initialize campaign progress: %1")
  726. .arg(last_error_string(progress_insert_query.lastError()));
  727. }
  728. return false;
  729. }
  730. return true;
  731. }
  732. auto SaveStorage::migrate_to_3(QString *out_error) const -> bool {
  733. QSqlQuery query(m_database);
  734. const QString create_mission_progress_sql =
  735. QStringLiteral("CREATE TABLE IF NOT EXISTS mission_progress ("
  736. "id INTEGER PRIMARY KEY AUTOINCREMENT, "
  737. "mission_id TEXT NOT NULL, "
  738. "mode TEXT NOT NULL, "
  739. "campaign_id TEXT, "
  740. "completed INTEGER NOT NULL DEFAULT 0, "
  741. "completion_time REAL, "
  742. "difficulty TEXT, "
  743. "result TEXT, "
  744. "completed_at TEXT, "
  745. "created_at TEXT NOT NULL, "
  746. "updated_at TEXT NOT NULL, "
  747. "UNIQUE(mission_id, mode, campaign_id)"
  748. ")");
  749. if (!query.exec(create_mission_progress_sql)) {
  750. if (out_error != nullptr) {
  751. *out_error = QStringLiteral("Failed to create mission_progress table: %1")
  752. .arg(last_error_string(query.lastError()));
  753. }
  754. return false;
  755. }
  756. QSqlQuery missions_query(m_database);
  757. const QString create_campaign_missions_sql =
  758. QStringLiteral("CREATE TABLE IF NOT EXISTS campaign_missions ("
  759. "id INTEGER PRIMARY KEY AUTOINCREMENT, "
  760. "campaign_id TEXT NOT NULL, "
  761. "mission_id TEXT NOT NULL, "
  762. "order_index INTEGER NOT NULL, "
  763. "unlocked INTEGER NOT NULL DEFAULT 0, "
  764. "completed INTEGER NOT NULL DEFAULT 0, "
  765. "completed_at TEXT, "
  766. "UNIQUE(campaign_id, mission_id)"
  767. ")");
  768. if (!missions_query.exec(create_campaign_missions_sql)) {
  769. if (out_error != nullptr) {
  770. *out_error =
  771. QStringLiteral("Failed to create campaign_missions table: %1")
  772. .arg(last_error_string(missions_query.lastError()));
  773. }
  774. return false;
  775. }
  776. QSqlQuery index_query(m_database);
  777. if (!index_query.exec(QStringLiteral(
  778. "CREATE INDEX IF NOT EXISTS idx_mission_progress_mission_id ON "
  779. "mission_progress (mission_id)"))) {
  780. if (out_error != nullptr) {
  781. *out_error = QStringLiteral("Failed to create mission_progress index: %1")
  782. .arg(last_error_string(index_query.lastError()));
  783. }
  784. return false;
  785. }
  786. QSqlQuery campaign_index_query(m_database);
  787. if (!campaign_index_query.exec(QStringLiteral(
  788. "CREATE INDEX IF NOT EXISTS idx_campaign_missions_campaign_id ON "
  789. "campaign_missions (campaign_id)"))) {
  790. if (out_error != nullptr) {
  791. *out_error =
  792. QStringLiteral("Failed to create campaign_missions index: %1")
  793. .arg(last_error_string(campaign_index_query.lastError()));
  794. }
  795. return false;
  796. }
  797. return true;
  798. }
  799. auto SaveStorage::save_mission_result(
  800. const QString &mission_id, const QString &mode, const QString &campaign_id,
  801. bool completed, const QString &result, const QString &difficulty,
  802. float completion_time, QString *out_error) -> bool {
  803. if (!initialize(out_error)) {
  804. return false;
  805. }
  806. TransactionGuard transaction(m_database);
  807. if (!transaction.begin(out_error)) {
  808. return false;
  809. }
  810. const QString now_iso =
  811. QDateTime::currentDateTimeUtc().toString(Qt::ISODateWithMs);
  812. QSqlQuery query(m_database);
  813. const QString insert_sql = QStringLiteral(
  814. "INSERT INTO mission_progress (mission_id, mode, campaign_id, completed, "
  815. "completion_time, difficulty, result, completed_at, created_at, "
  816. "updated_at) "
  817. "VALUES (:mission_id, :mode, :campaign_id, :completed, :completion_time, "
  818. ":difficulty, :result, :completed_at, :created_at, :updated_at) "
  819. "ON CONFLICT(mission_id, mode, campaign_id) DO UPDATE SET "
  820. "completed = excluded.completed, "
  821. "completion_time = excluded.completion_time, "
  822. "difficulty = excluded.difficulty, "
  823. "result = excluded.result, "
  824. "completed_at = excluded.completed_at, "
  825. "updated_at = excluded.updated_at");
  826. if (!query.prepare(insert_sql)) {
  827. if (out_error != nullptr) {
  828. *out_error =
  829. QStringLiteral("Failed to prepare mission_progress insert: %1")
  830. .arg(last_error_string(query.lastError()));
  831. }
  832. return false;
  833. }
  834. query.bindValue(QStringLiteral(":mission_id"), mission_id);
  835. query.bindValue(QStringLiteral(":mode"), mode);
  836. if (campaign_id.isEmpty()) {
  837. query.bindValue(QStringLiteral(":campaign_id"), QVariant());
  838. } else {
  839. query.bindValue(QStringLiteral(":campaign_id"), campaign_id);
  840. }
  841. query.bindValue(QStringLiteral(":completed"), completed ? 1 : 0);
  842. query.bindValue(QStringLiteral(":completion_time"), completion_time);
  843. query.bindValue(QStringLiteral(":difficulty"), difficulty);
  844. query.bindValue(QStringLiteral(":result"), result);
  845. query.bindValue(QStringLiteral(":completed_at"),
  846. completed ? now_iso : QVariant());
  847. query.bindValue(QStringLiteral(":created_at"), now_iso);
  848. query.bindValue(QStringLiteral(":updated_at"), now_iso);
  849. if (!query.exec()) {
  850. if (out_error != nullptr) {
  851. *out_error = QStringLiteral("Failed to save mission result: %1")
  852. .arg(last_error_string(query.lastError()));
  853. }
  854. transaction.rollback();
  855. return false;
  856. }
  857. if (!transaction.commit(out_error)) {
  858. return false;
  859. }
  860. return true;
  861. }
  862. auto SaveStorage::get_mission_progress(
  863. const QString &mission_id, QString *out_error) const -> QVariantMap {
  864. QVariantMap result;
  865. if (!initialize(out_error)) {
  866. return result;
  867. }
  868. QSqlQuery query(m_database);
  869. query.prepare(QStringLiteral(
  870. "SELECT mode, campaign_id, completed, completion_time, difficulty, "
  871. "result, completed_at FROM mission_progress "
  872. "WHERE mission_id = :mission_id ORDER BY updated_at DESC LIMIT 1"));
  873. query.bindValue(QStringLiteral(":mission_id"), mission_id);
  874. if (!query.exec()) {
  875. if (out_error != nullptr) {
  876. *out_error = QStringLiteral("Failed to get mission progress: %1")
  877. .arg(last_error_string(query.lastError()));
  878. }
  879. return result;
  880. }
  881. if (query.next()) {
  882. result.insert(QStringLiteral("mode"), query.value(0).toString());
  883. result.insert(QStringLiteral("campaign_id"), query.value(1).toString());
  884. result.insert(QStringLiteral("completed"), query.value(2).toInt() != 0);
  885. result.insert(QStringLiteral("completion_time"), query.value(3).toDouble());
  886. result.insert(QStringLiteral("difficulty"), query.value(4).toString());
  887. result.insert(QStringLiteral("result"), query.value(5).toString());
  888. result.insert(QStringLiteral("completed_at"), query.value(6).toString());
  889. }
  890. return result;
  891. }
  892. auto SaveStorage::get_campaign_mission_progress(
  893. const QString &campaign_id, QString *out_error) const -> QVariantList {
  894. QVariantList result;
  895. if (!initialize(out_error)) {
  896. return result;
  897. }
  898. QSqlQuery query(m_database);
  899. query.prepare(QStringLiteral(
  900. "SELECT mission_id, order_index, unlocked, completed, completed_at "
  901. "FROM campaign_missions "
  902. "WHERE campaign_id = :campaign_id ORDER BY order_index ASC"));
  903. query.bindValue(QStringLiteral(":campaign_id"), campaign_id);
  904. if (!query.exec()) {
  905. if (out_error != nullptr) {
  906. *out_error = QStringLiteral("Failed to get campaign mission progress: %1")
  907. .arg(last_error_string(query.lastError()));
  908. }
  909. return result;
  910. }
  911. while (query.next()) {
  912. QVariantMap mission;
  913. mission.insert(QStringLiteral("mission_id"), query.value(0).toString());
  914. mission.insert(QStringLiteral("order_index"), query.value(1).toInt());
  915. mission.insert(QStringLiteral("unlocked"), query.value(2).toInt() != 0);
  916. mission.insert(QStringLiteral("completed"), query.value(3).toInt() != 0);
  917. mission.insert(QStringLiteral("completed_at"), query.value(4).toString());
  918. result.append(mission);
  919. }
  920. return result;
  921. }
  922. auto SaveStorage::ensure_campaign_missions_in_db(
  923. const Game::Campaign::CampaignDefinition &campaign,
  924. QString *out_error) -> bool {
  925. if (!initialize(out_error)) {
  926. return false;
  927. }
  928. TransactionGuard transaction(m_database);
  929. if (!transaction.begin(out_error)) {
  930. return false;
  931. }
  932. for (const auto &mission : campaign.missions) {
  933. QSqlQuery check_query(m_database);
  934. check_query.prepare(QStringLiteral(
  935. "SELECT COUNT(*) FROM campaign_missions "
  936. "WHERE campaign_id = :campaign_id AND mission_id = :mission_id"));
  937. check_query.bindValue(QStringLiteral(":campaign_id"), campaign.id);
  938. check_query.bindValue(QStringLiteral(":mission_id"), mission.mission_id);
  939. if (!check_query.exec() || !check_query.next()) {
  940. if (out_error != nullptr) {
  941. *out_error =
  942. QStringLiteral("Failed to check campaign mission existence: %1")
  943. .arg(last_error_string(check_query.lastError()));
  944. }
  945. transaction.rollback();
  946. return false;
  947. }
  948. int count = check_query.value(0).toInt();
  949. if (count == 0) {
  950. QSqlQuery insert_query(m_database);
  951. insert_query.prepare(QStringLiteral(
  952. "INSERT INTO campaign_missions (campaign_id, mission_id, "
  953. "order_index, unlocked, completed) "
  954. "VALUES (:campaign_id, :mission_id, :order_index, :unlocked, 0)"));
  955. insert_query.bindValue(QStringLiteral(":campaign_id"), campaign.id);
  956. insert_query.bindValue(QStringLiteral(":mission_id"), mission.mission_id);
  957. insert_query.bindValue(QStringLiteral(":order_index"),
  958. mission.order_index);
  959. insert_query.bindValue(QStringLiteral(":unlocked"),
  960. mission.order_index == 0 ? 1 : 0);
  961. if (!insert_query.exec()) {
  962. if (out_error != nullptr) {
  963. *out_error = QStringLiteral("Failed to insert campaign mission: %1")
  964. .arg(last_error_string(insert_query.lastError()));
  965. }
  966. transaction.rollback();
  967. return false;
  968. }
  969. }
  970. }
  971. if (!transaction.commit(out_error)) {
  972. return false;
  973. }
  974. return true;
  975. }
  976. auto SaveStorage::ensure_campaign_in_db(
  977. const Game::Campaign::CampaignDefinition &campaign,
  978. QString *out_error) -> bool {
  979. if (!initialize(out_error)) {
  980. return false;
  981. }
  982. TransactionGuard transaction(m_database);
  983. if (!transaction.begin(out_error)) {
  984. return false;
  985. }
  986. QSqlQuery insert_query(m_database);
  987. insert_query.prepare(QStringLiteral(
  988. "INSERT INTO campaigns (id, title, description, map_path, order_index) "
  989. "VALUES (:id, :title, :description, :map_path, :order_index) "
  990. "ON CONFLICT(id) DO UPDATE SET "
  991. "title = excluded.title, "
  992. "description = excluded.description"));
  993. insert_query.bindValue(QStringLiteral(":id"), campaign.id);
  994. insert_query.bindValue(QStringLiteral(":title"), campaign.title);
  995. insert_query.bindValue(QStringLiteral(":description"), campaign.description);
  996. insert_query.bindValue(QStringLiteral(":map_path"),
  997. QStringLiteral(":/assets/maps/map_rivers.json"));
  998. insert_query.bindValue(QStringLiteral(":order_index"), 0);
  999. if (!insert_query.exec()) {
  1000. if (out_error != nullptr) {
  1001. *out_error = QStringLiteral("Failed to insert campaign: %1")
  1002. .arg(last_error_string(insert_query.lastError()));
  1003. }
  1004. transaction.rollback();
  1005. return false;
  1006. }
  1007. if (!transaction.commit(out_error)) {
  1008. return false;
  1009. }
  1010. return true;
  1011. }
  1012. auto SaveStorage::unlock_next_mission(const QString &campaign_id,
  1013. const QString &completed_mission_id,
  1014. QString *out_error) -> bool {
  1015. if (!initialize(out_error)) {
  1016. return false;
  1017. }
  1018. TransactionGuard transaction(m_database);
  1019. if (!transaction.begin(out_error)) {
  1020. return false;
  1021. }
  1022. const QString now_iso =
  1023. QDateTime::currentDateTimeUtc().toString(Qt::ISODateWithMs);
  1024. QSqlQuery update_query(m_database);
  1025. update_query.prepare(QStringLiteral(
  1026. "UPDATE campaign_missions SET completed = 1, completed_at = "
  1027. ":completed_at "
  1028. "WHERE campaign_id = :campaign_id AND mission_id = :mission_id"));
  1029. update_query.bindValue(QStringLiteral(":completed_at"), now_iso);
  1030. update_query.bindValue(QStringLiteral(":campaign_id"), campaign_id);
  1031. update_query.bindValue(QStringLiteral(":mission_id"), completed_mission_id);
  1032. if (!update_query.exec()) {
  1033. if (out_error != nullptr) {
  1034. *out_error = QStringLiteral("Failed to mark mission as completed: %1")
  1035. .arg(last_error_string(update_query.lastError()));
  1036. }
  1037. transaction.rollback();
  1038. return false;
  1039. }
  1040. QSqlQuery order_query(m_database);
  1041. order_query.prepare(QStringLiteral(
  1042. "SELECT order_index FROM campaign_missions "
  1043. "WHERE campaign_id = :campaign_id AND mission_id = :mission_id"));
  1044. order_query.bindValue(QStringLiteral(":campaign_id"), campaign_id);
  1045. order_query.bindValue(QStringLiteral(":mission_id"), completed_mission_id);
  1046. if (!order_query.exec() || !order_query.next()) {
  1047. if (out_error != nullptr) {
  1048. *out_error = QStringLiteral("Failed to find completed mission order: %1")
  1049. .arg(last_error_string(order_query.lastError()));
  1050. }
  1051. transaction.rollback();
  1052. return false;
  1053. }
  1054. int completed_order = order_query.value(0).toInt();
  1055. QSqlQuery unlock_query(m_database);
  1056. unlock_query.prepare(
  1057. QStringLiteral("UPDATE campaign_missions SET unlocked = 1 "
  1058. "WHERE campaign_id = :campaign_id AND order_index = "
  1059. ":next_order_index"));
  1060. unlock_query.bindValue(QStringLiteral(":campaign_id"), campaign_id);
  1061. unlock_query.bindValue(QStringLiteral(":next_order_index"),
  1062. completed_order + 1);
  1063. if (!unlock_query.exec()) {
  1064. if (out_error != nullptr) {
  1065. *out_error = QStringLiteral("Failed to unlock next mission: %1")
  1066. .arg(last_error_string(unlock_query.lastError()));
  1067. }
  1068. transaction.rollback();
  1069. return false;
  1070. }
  1071. if (unlock_query.numRowsAffected() == 0) {
  1072. if (out_error != nullptr) {
  1073. *out_error =
  1074. QStringLiteral("No next mission found to unlock (completed mission "
  1075. "order: %1)")
  1076. .arg(completed_order);
  1077. }
  1078. transaction.rollback();
  1079. return false;
  1080. }
  1081. if (!transaction.commit(out_error)) {
  1082. return false;
  1083. }
  1084. return true;
  1085. }
  1086. } // namespace Game::Systems