Skip to content

Commit 5e15dec

Browse files
feat(update): in-game notice when a newer release is available
Poll the GitHub releases API on a background thread and surface a one-line in-game notice on game entry when a newer release exists. - components/update/UpdateChecker: a jthread polls the hardcoded ResurrectedTrader/d2bsng releases/latest every 6h (30s settle delay), parses tag_name (non-throwing nlohmann-json), and semver-compares it against the build-baked D2BS_VERSION to latch an atomic flag. stop_token-interruptible waits so Stop() joins promptly. Reuses the V8-free api::classes::PerformHttpRequest (documented components->api exception). - GameLoop::OnSleep: on the session-entry latch transition, print the notice via game::PrintGameString. Plain ASCII, no URL. - Framework Init/Shutdown: Start()/Stop(). - tests: fake UpdateChecker shim (the test set has no nlohmann-json or HTTP engine), mirroring the CharacterState fake.
1 parent a33300d commit 5e15dec

7 files changed

Lines changed: 367 additions & 1 deletion

File tree

CLAUDE.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ d2bsng/
145145
│ │ │ ├── dde/ DDE service
146146
│ │ │ ├── exits/ Level-exit finder
147147
│ │ │ ├── profile/ Profile DTOs
148+
│ │ │ ├── update/ GitHub-release update checker (6h poll -> in-game notice)
148149
│ │ │ └── Framework.h/.cpp DLL lifecycle orchestrator
149150
│ │ └── game/ Game interface headers (NO .cpp files)
150151
│ └── lod114d/ d2bs.dll - 1.14d game implementation
@@ -219,7 +220,7 @@ These are the intended dependencies. A few deliberate exceptions are noted inlin
219220
- **utils/** depends on: standard library, Windows headers, third-party libs (spdlog, stackwalker).
220221
- **framework/game/** (interface) depends on: standard library only. NEVER on V8 or api/. NEVER on components/, with one exception: `game/Menu.h` includes `components/profile/ProfileData.h` so `Login()` can take the profile struct by const-ref rather than duplicating that DTO into the game layer.
221222
- **framework/api/** depends on: game/ interface, components/, utils/, V8.
222-
- **framework/components/** depends on: game/ interface, components/config/, utils/, V8. Exception: `components/script/` includes `api/` - the script engine is the JS-API composition root (it owns V8 isolate setup and registers the `api/` ClassRegistry + globals), and a few components reuse `api::v8_convert` instead of duplicating the V8 conversion helpers.
223+
- **framework/components/** depends on: game/ interface, components/config/, utils/, V8. Exception: `components/script/` includes `api/` - the script engine is the JS-API composition root (it owns V8 isolate setup and registers the `api/` ClassRegistry + globals), and a few components reuse `api::v8_convert` instead of duplicating the V8 conversion helpers. `components/update/` similarly reuses the V8-free `api::classes::PerformHttpRequest` (`api/classes/io/HttpEngine.h`) rather than re-implementing the WinHTTP plumbing.
223224
- **lod114d/game/** (implementation) depends on: game/ interface, utils/, and sibling port headers (imports/, hooks/, asm_thunks/). NEVER on api/ or V8. NEVER on components/, except config reads (`components/config/AppConfig.h`) and forwarding to the port-chosen console sink (`components/console/`, see "Port-chosen message sink" below).
224225

225226
### Key Design Decisions

src/framework/components/Framework.cpp

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
#include "components/profile/ProfileService.h"
1919
#include "components/script/Commands.h"
2020
#include "components/script/ScriptEngine.h"
21+
#include "components/update/UpdateChecker.h"
2122
#include "game/Bridge.h"
2223
#include "game/Console.h"
2324
#include "game/GameCallbacks.h"
@@ -108,6 +109,11 @@ void Framework::DoInitialize(HMODULE hModule) {
108109
}
109110
});
110111

112+
// Best-effort background update check (polls GitHub releases every 6h;
113+
// the game loop surfaces a notice on game entry). Independent of game
114+
// readiness, so it can start as soon as the framework is up.
115+
framework::update::UpdateChecker::Instance().Start();
116+
111117
logger_->info("d2bsng initialized");
112118
} catch (const std::exception& ex) {
113119
logger_->error("Framework::Initialize failed: {}", ex.what());
@@ -141,6 +147,10 @@ void Framework::Shutdown() {
141147
// in-flight DDE handler call finishes against a still-valid framework.
142148
dde::DdeService::Instance().Stop();
143149

150+
// Halt the background update poller (joins its thread) before the rest
151+
// of teardown so no network work outlives the framework.
152+
framework::update::UpdateChecker::Instance().Stop();
153+
144154
ScriptEngine::Instance().Shutdown();
145155
game::RemoveHooks();
146156
game::Bridge::Shutdown();

src/framework/components/gameloop/GameLoop.cpp

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
#include "components/script/ScriptEngine.h"
1414
#include "components/script/ScriptTypes.h"
1515
#include "components/speedhack/Speedhack.h"
16+
#include "components/update/UpdateChecker.h"
1617
#include "game/GameHelpers.h"
1718
#include "game/GameLock.h"
1819
#include "game/GameThread.h"
@@ -106,6 +107,18 @@ void GameLoop::OnSleep(std::chrono::milliseconds duration) {
106107
characterstate::CharacterState::Instance().OnTick(cur.state, !previous_.inSession && cur.inSession);
107108
DriveScriptLifecycle(previous_, cur);
108109

110+
// Surface an available update once per game entry. The checker polls GitHub
111+
// releases on its own thread; here we only read the latched flag (lock-free)
112+
// and print a one-line ASCII notice on the first frame of each game. No URL -
113+
// the notice just tells the user an update exists.
114+
if (!previous_.inSession && cur.inSession && update::UpdateChecker::Instance().UpdateAvailable()) {
115+
// D2 in-game message color palette index (same palette as the \xFFc
116+
// escapes; see game/Console.h). 8 = orange, an attention color distinct
117+
// from the usual white system text.
118+
constexpr int32_t UPDATE_NOTICE_COLOR = 8;
119+
d2bs::game::PrintGameString(update::UpdateChecker::Instance().Message(), UPDATE_NOTICE_COLOR);
120+
}
121+
109122
d2bs::game::GameThread::Drain();
110123

111124
previous_ = cur;
Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
#include "components/update/UpdateChecker.h"
2+
3+
#include <charconv>
4+
#include <chrono>
5+
#include <compare>
6+
#include <cstdint>
7+
#include <optional>
8+
#include <string>
9+
#include <string_view>
10+
#include <system_error>
11+
#include <utility>
12+
13+
#include <nlohmann/json.hpp>
14+
#include <spdlog/spdlog.h>
15+
16+
// The V8-free HTTP engine (WinHTTP; bypasses the game's SOCKS5 detour). Reused
17+
// here rather than duplicating the WinHTTP plumbing - this is a documented
18+
// components -> api exception (HttpEngine.h pulls in no V8 / JS headers).
19+
#include "api/classes/io/HttpEngine.h"
20+
#include "components/config/Version.h"
21+
#include "utils/threadutils.h"
22+
#include "utils/utils.h"
23+
24+
namespace d2bs::framework::update {
25+
26+
namespace {
27+
28+
using json = nlohmann::json; // NOLINT(readability-identifier-naming) - nlohmann's conventional alias spelling
29+
30+
constexpr auto CHECK_INTERVAL = std::chrono::hours{6};
31+
// Short settle delay before the first check so it doesn't pile onto the heavy
32+
// DLL-load / framework-init work.
33+
constexpr auto INITIAL_DELAY = std::chrono::seconds{30};
34+
35+
// Hardcoded: the canonical d2bsng releases endpoint. `releases/latest` returns
36+
// the newest non-draft, non-prerelease release (404 when none exist yet).
37+
constexpr std::string_view RELEASES_API_URL =
38+
"https://api.github.com/repos/ResurrectedTrader/d2bsng/releases/latest";
39+
40+
// A comparable major.minor.patch triple. The defaulted operator<=> gives the
41+
// natural precedence (major, then minor, then patch).
42+
struct SemVer {
43+
uint32_t major = 0;
44+
uint32_t minor = 0;
45+
uint32_t patch = 0;
46+
auto operator<=>(const SemVer&) const = default;
47+
};
48+
49+
// Parse the leading "major.minor.patch" of a version string. Tolerates a
50+
// leading 'v' and ignores any pre-release / build suffix ("-dev", "+meta"), so
51+
// "v2.1.0" and "2.1.0-dev" both parse. Missing trailing components default to
52+
// 0. Returns nullopt only when there is no leading numeric component at all.
53+
std::optional<SemVer> ParseSemVer(std::string_view s) {
54+
if (!s.empty() && (s.front() == 'v' || s.front() == 'V')) {
55+
s.remove_prefix(1);
56+
}
57+
// Keep only the leading run of digits and dots ("2.1.0-dev" -> "2.1.0").
58+
size_t end = 0;
59+
while (end < s.size() && ((s[end] >= '0' && s[end] <= '9') || s[end] == '.')) {
60+
++end;
61+
}
62+
s = s.substr(0, end);
63+
if (s.empty() || s.front() < '0' || s.front() > '9') {
64+
return std::nullopt;
65+
}
66+
67+
SemVer out;
68+
size_t field = 0;
69+
size_t start = 0;
70+
while (start <= s.size() && field < 3) {
71+
const size_t dot = s.find('.', start);
72+
const size_t len = (dot == std::string_view::npos) ? std::string_view::npos : dot - start;
73+
const std::string_view part = s.substr(start, len);
74+
uint32_t value = 0;
75+
if (!part.empty()) {
76+
const auto* first = part.data();
77+
const auto* last = part.data() + part.size();
78+
if (std::from_chars(first, last, value).ec != std::errc{}) {
79+
return std::nullopt;
80+
}
81+
}
82+
switch (field) {
83+
case 0:
84+
out.major = value;
85+
break;
86+
case 1:
87+
out.minor = value;
88+
break;
89+
default:
90+
out.patch = value;
91+
break;
92+
}
93+
++field;
94+
if (dot == std::string_view::npos) {
95+
break;
96+
}
97+
start = dot + 1;
98+
}
99+
return out;
100+
}
101+
102+
// Strip a single leading 'v' / 'V' for display (the GitHub tag is "v2.1.0";
103+
// the in-game notice reads "2.1.0").
104+
std::string StripTagPrefix(const std::string& tag) {
105+
if (!tag.empty() && (tag.front() == 'v' || tag.front() == 'V')) {
106+
return tag.substr(1);
107+
}
108+
return tag;
109+
}
110+
111+
} // namespace
112+
113+
UpdateChecker& UpdateChecker::Instance() {
114+
static UpdateChecker instance;
115+
return instance;
116+
}
117+
118+
UpdateChecker::UpdateChecker() {
119+
logger_ = d2bs::utils::GetLogger("update");
120+
}
121+
122+
UpdateChecker::~UpdateChecker() {
123+
Stop();
124+
}
125+
126+
void UpdateChecker::Start() {
127+
bool expected = false;
128+
if (!started_.compare_exchange_strong(expected, true)) {
129+
return; // already running
130+
}
131+
thread_ = std::jthread([this](const std::stop_token& stopToken) { Run(stopToken); });
132+
}
133+
134+
void UpdateChecker::Stop() {
135+
if (!started_.exchange(false)) {
136+
return; // not running
137+
}
138+
if (thread_.joinable()) {
139+
thread_.request_stop();
140+
cv_.notify_all(); // wake the interval wait immediately
141+
thread_.join();
142+
}
143+
}
144+
145+
std::string UpdateChecker::Message() const {
146+
if (!updateAvailable_.load(std::memory_order_acquire)) {
147+
return {};
148+
}
149+
std::lock_guard lock(mutex_);
150+
// ASCII only (renders both in-game and in the dev console). No URL by design.
151+
return "d2bsng " + latestVersion_ + " is available (running " D2BS_VERSION ")";
152+
}
153+
154+
void UpdateChecker::Run(const std::stop_token& stopToken) {
155+
d2bs::thread_utils::SetThreadDescription("d2bs update checker");
156+
157+
std::unique_lock lock(mutex_);
158+
// Interruptible settle delay before the first check. wait_for returns the
159+
// predicate result, so a true return means Stop() fired during the delay.
160+
if (cv_.wait_for(lock, stopToken, INITIAL_DELAY, [&stopToken] { return stopToken.stop_requested(); })) {
161+
return;
162+
}
163+
164+
while (!stopToken.stop_requested()) {
165+
lock.unlock();
166+
CheckOnce();
167+
lock.lock();
168+
// Sleep the interval; wakes early when Stop() requests it.
169+
cv_.wait_for(lock, stopToken, CHECK_INTERVAL, [&stopToken] { return stopToken.stop_requested(); });
170+
}
171+
}
172+
173+
bool UpdateChecker::CheckOnce() {
174+
api::classes::HttpRequest request;
175+
request.method = "GET";
176+
request.url = std::string(RELEASES_API_URL);
177+
request.headers = {
178+
// GitHub rejects API requests without a User-Agent.
179+
{"User-Agent", "d2bsng-update-checker"},
180+
{"Accept", "application/vnd.github+json"},
181+
{"X-GitHub-Api-Version", "2022-11-28"},
182+
};
183+
request.timeoutMs = 10000;
184+
request.totalTimeoutMs = 15000;
185+
186+
api::classes::HttpResponse response;
187+
const std::string error = api::classes::PerformHttpRequest(request, response);
188+
if (!error.empty()) {
189+
logger_->debug("update check: request failed ({})", error);
190+
return false;
191+
}
192+
if (response.status != 200) {
193+
// 404 = no releases published yet; anything else = transient. Either
194+
// way there's nothing to flag.
195+
logger_->debug("update check: HTTP {}", response.status);
196+
return false;
197+
}
198+
199+
const json doc = json::parse(response.body.begin(), response.body.end(), /*cb=*/nullptr, /*allow_exceptions=*/false);
200+
if (!doc.is_object()) {
201+
logger_->debug("update check: response was not a JSON object");
202+
return false;
203+
}
204+
const auto it = doc.find("tag_name");
205+
if (it == doc.end() || !it->is_string()) {
206+
logger_->debug("update check: no tag_name in response");
207+
return false;
208+
}
209+
const auto tag = it->get<std::string>();
210+
211+
const auto latest = ParseSemVer(tag);
212+
const auto current = ParseSemVer(D2BS_VERSION);
213+
if (!latest) {
214+
logger_->debug("update check: unparseable release tag '{}'", tag);
215+
return false;
216+
}
217+
if (!current) {
218+
// D2BS_VERSION is a build-baked literal; this should never happen.
219+
logger_->debug("update check: unparseable running version '{}'", D2BS_VERSION);
220+
return false;
221+
}
222+
223+
const bool newer = *latest > *current;
224+
{
225+
std::lock_guard lock(mutex_);
226+
latestVersion_ = StripTagPrefix(tag);
227+
}
228+
updateAvailable_.store(newer, std::memory_order_release);
229+
if (newer) {
230+
logger_->info("update available: {} (running {})", tag, D2BS_VERSION);
231+
} else {
232+
logger_->debug("up to date: latest {} vs running {}", tag, D2BS_VERSION);
233+
}
234+
return true;
235+
}
236+
237+
} // namespace d2bs::framework::update
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
#pragma once
2+
3+
#include <atomic>
4+
#include <condition_variable>
5+
#include <memory>
6+
#include <mutex>
7+
#include <stop_token>
8+
#include <string>
9+
#include <thread>
10+
11+
// NOLINTBEGIN(readability-identifier-naming) - spdlog::logger is upstream API naming
12+
namespace spdlog {
13+
class logger;
14+
} // namespace spdlog
15+
// NOLINTEND(readability-identifier-naming)
16+
17+
namespace d2bs::framework::update {
18+
19+
// Background poller that checks the project's GitHub releases on a fixed
20+
// interval and latches when a published release is newer than the running
21+
// build (D2BS_VERSION). The game loop reads UpdateAvailable() on game entry to
22+
// surface a one-line notice in-game. The HTTP request + JSON parse run on a
23+
// dedicated jthread (never the game thread or a V8 isolate thread);
24+
// UpdateAvailable() is a lock-free atomic read safe to call from any thread.
25+
class UpdateChecker {
26+
public:
27+
static UpdateChecker& Instance();
28+
29+
// Start the polling thread. Idempotent - a second call while running is a
30+
// no-op. The first check runs after a short settle delay, then every 6h.
31+
void Start();
32+
33+
// Request the polling thread to stop and join it. Idempotent. May block
34+
// briefly (up to the in-flight request's timeout) if a check is mid-flight.
35+
void Stop();
36+
37+
// True once a check has observed a release strictly newer than this build.
38+
// Lock-free; safe to call from the game thread.
39+
bool UpdateAvailable() const { return updateAvailable_.load(std::memory_order_acquire); }
40+
41+
// The one-line ASCII notice to show in-game (e.g. "d2bsng 2.1.0 is
42+
// available (running 2.0.0)"). Empty when no update has been found. No URL
43+
// by design - the notice only signals that an update exists.
44+
std::string Message() const;
45+
46+
UpdateChecker(const UpdateChecker&) = delete;
47+
UpdateChecker& operator=(const UpdateChecker&) = delete;
48+
UpdateChecker(UpdateChecker&&) = delete;
49+
UpdateChecker& operator=(UpdateChecker&&) = delete;
50+
51+
private:
52+
UpdateChecker();
53+
~UpdateChecker();
54+
55+
// Polling loop body: an initial settle delay, then CheckOnce() every
56+
// interval. Both waits are interruptible via the stop_token.
57+
void Run(const std::stop_token& stopToken);
58+
59+
// Perform one poll: GET the releases API, parse the latest tag, compare it
60+
// against D2BS_VERSION, and update updateAvailable_ / latestVersion_.
61+
// Returns true if the check completed (whether or not an update was found),
62+
// false on any network / parse failure.
63+
bool CheckOnce();
64+
65+
inline static std::shared_ptr<spdlog::logger> logger_;
66+
67+
mutable std::mutex mutex_; // guards latestVersion_ + the cv wait
68+
std::condition_variable_any cv_; // woken by the stop_token on Stop()
69+
std::atomic<bool> started_{false}; // Start() latch (idempotency)
70+
std::atomic<bool> updateAvailable_{false};
71+
std::string latestVersion_; // newest tag observed (no leading 'v'); guarded by mutex_
72+
// Declared last so it is destroyed first - ~jthread requests stop + joins
73+
// while mutex_/cv_ are still alive for the loop's final wakeup.
74+
std::jthread thread_;
75+
};
76+
77+
} // namespace d2bs::framework::update

0 commit comments

Comments
 (0)