diff --git a/src/core/files.cpp b/src/core/files.cpp new file mode 100644 index 0000000..4e8e81c --- /dev/null +++ b/src/core/files.cpp @@ -0,0 +1,79 @@ +#include "core/files.h" + +#include +#include +#include +#include + +#include "core/logger.h" +#include "core/util.h" + +namespace files { +namespace { + +// A function-local static, so pending writes land and the thread is joined on exit even +// when nobody calls flush(). +class Writer { +public: + ~Writer() { + { + std::lock_guard lock(mutex_); + stopping_ = true; + } + wake_.notify_one(); + if (thread_.joinable()) thread_.join(); + } + + void queue(std::string path, std::string data) { + { + std::lock_guard lock(mutex_); + pending_[std::move(path)] = std::move(data); + if (!thread_.joinable()) thread_ = std::thread([this] { run(); }); + } + wake_.notify_one(); + } + + void flush() { + std::unique_lock lock(mutex_); + idle_.wait(lock, [this] { return pending_.empty() && !writing_; }); + } + +private: + void run() { + std::unique_lock lock(mutex_); + while (true) { + wake_.wait(lock, [this] { return stopping_ || !pending_.empty(); }); + if (pending_.empty()) return; + auto next = pending_.extract(pending_.begin()); + writing_ = true; + lock.unlock(); + if (!util::writeFile(next.key(), next.mapped())) { + logging::warn("files", "could not write {}", next.key()); + } + lock.lock(); + writing_ = false; + idle_.notify_all(); + } + } + + std::mutex mutex_; + std::condition_variable wake_; + std::condition_variable idle_; + std::map pending_; + std::thread thread_; + bool writing_ = false; + bool stopping_ = false; +}; + +Writer& writer() { + static Writer instance; + return instance; +} + +} // namespace + +void writeLater(std::string path, std::string data) { writer().queue(std::move(path), std::move(data)); } + +void flush() { writer().flush(); } + +} // namespace files diff --git a/src/core/files.h b/src/core/files.h new file mode 100644 index 0000000..d958ffe --- /dev/null +++ b/src/core/files.h @@ -0,0 +1,15 @@ +#pragma once + +#include + +namespace files { + +// Queues `data` for a background thread that writes it to `path` through util::writeFile. +// A newer write to the same path replaces one still waiting, so a burst costs one write. +void writeLater(std::string path, std::string data); + +// Blocks until every queued write has landed. Anything that reads a file back first calls +// this, so it never sees an older copy than the one last queued. +void flush(); + +} // namespace files diff --git a/src/game/player.cpp b/src/game/player.cpp index 754b966..122e044 100644 --- a/src/game/player.cpp +++ b/src/game/player.cpp @@ -1,6 +1,7 @@ #include "game/player.h" #include "core/config.h" +#include "core/files.h" #include "core/logger.h" #include "core/util.h" #include "data/excel.h" @@ -125,7 +126,8 @@ void Player::save() { void Player::saveNow() { lastSaveMs_ = util::nowMs(); - savePlayerState(*this); + // Built here, where the state is consistent; written off the packet thread. + files::writeLater(core::Config::get().paths.playerFile, playerStateJson(*this)); } } // namespace game diff --git a/src/game/player_store.cpp b/src/game/player_store.cpp index ca49afa..390f1b7 100644 --- a/src/game/player_store.cpp +++ b/src/game/player_store.cpp @@ -3,6 +3,7 @@ #include #include "core/config.h" +#include "core/files.h" #include "core/logger.h" #include "core/util.h" #include "game/player.h" @@ -40,6 +41,8 @@ std::vector u32Array(const json& j, const char* key) { } // namespace bool loadPlayerState(Player& player) { + // A session that just ended may still have its save queued. + files::flush(); const std::string& path = core::Config::get().paths.playerFile; bool ok = false; std::string text = util::readFile(path, &ok); @@ -188,7 +191,7 @@ bool loadPlayerState(Player& player) { return true; } -bool savePlayerState(const Player& player) { +std::string playerStateJson(const Player& player) { const LineupBook& book = player.lineups(); json squads = json::array(); for (uint32_t i = 0; i < kSquadCount; ++i) { @@ -269,8 +272,12 @@ bool savePlayerState(const Player& player) { {"character", pity(gacha.character)}, {"lightcone", pity(gacha.lightcone)}}; + return j.dump(2); +} + +bool savePlayerState(const Player& player) { const std::string& path = core::Config::get().paths.playerFile; - if (!util::writeFile(path, j.dump(2))) { + if (!util::writeFile(path, playerStateJson(player))) { logging::warn("player", "could not write {}", path); return false; } diff --git a/src/game/player_store.h b/src/game/player_store.h index 56ffc8c..d0710d8 100644 --- a/src/game/player_store.h +++ b/src/game/player_store.h @@ -1,5 +1,7 @@ #pragma once +#include + namespace game { class Player; @@ -8,5 +10,7 @@ class Player; // Everything else the client shows comes from the srtools build or the game tables. bool loadPlayerState(Player& player); bool savePlayerState(const Player& player); +// What savePlayerState writes, for a caller that queues the write instead. +std::string playerStateJson(const Player& player); } // namespace game diff --git a/src/game/srtools.cpp b/src/game/srtools.cpp index e4157d1..cc41427 100644 --- a/src/game/srtools.cpp +++ b/src/game/srtools.cpp @@ -3,6 +3,7 @@ #include #include "core/config.h" +#include "core/files.h" #include "core/logger.h" #include "core/util.h" @@ -275,7 +276,9 @@ void SrTools::mutate(const std::function& edit, bool persist bool SrTools::writeToDisk(const SrToolsData& data) const { try { - return util::writeFile(core::Config::get().paths.srtoolsFile, toJson(data).dump(2)); + // Queued: edits come in on the packet thread, and the file is only read at startup. + files::writeLater(core::Config::get().paths.srtoolsFile, toJson(data).dump(2)); + return true; } catch (const std::exception& e) { logging::warn("srtools", "could not serialise the build: {}", e.what()); return false; @@ -311,6 +314,8 @@ std::string SrTools::upload(const std::string& body) { try { json j = json::parse(body, nullptr, true, true); if (auto wrapper = j.find("data"); wrapper != j.end() && wrapper->is_object()) j = *wrapper; + // An edit still queued must not land on top of the build replacing it. + files::flush(); if (!util::writeFile(path, j.dump(2))) return "could not write " + path; } catch (const std::exception& e) { return std::string("could not store the build: ") + e.what(); diff --git a/src/main.cpp b/src/main.cpp index 14d429a..3a84ee8 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -6,6 +6,7 @@ #include "core/config.h" #include "core/crash.h" +#include "core/files.h" #include "core/logger.h" #include "core/util.h" #include "data/excel.h" @@ -92,6 +93,7 @@ int main(int argc, char** argv) { logging::info("capysr", "shutting down"); gateway.stop(); web.stop(); + files::flush(); logging::shutdown(); return 0; } diff --git a/tests/test_main.cpp b/tests/test_main.cpp index 20faaf1..becff67 100644 --- a/tests/test_main.cpp +++ b/tests/test_main.cpp @@ -4,6 +4,7 @@ #include #include +#include "core/files.h" #include "core/logger.h" #include "core/util.h" #include "net/cmd_ids.h" @@ -318,6 +319,15 @@ void testSafeWrite() { bool ok = false; check(util::readFile(path, &ok) == "second, and longer" && ok, "with the new content whole"); check(!util::fileExists(path + ".tmp"), "leaving no temp file behind"); + + // Queued writes: the newest one for a path wins, and flush waits for it to land. + const std::string queued = "build/test-queued-write.json"; + files::writeLater(queued, "older"); + files::writeLater(queued, "newest"); + files::flush(); + check(util::readFile(queued, &ok) == "newest" && ok, "a queued write lands, newest first"); + files::flush(); + check(true, "flushing an empty queue returns"); } } // namespace