Skip to main content

wows_data_mgr/
constants.rs

1//! Fetch versioned game constants from the padtrack/wows-constants GitHub repo.
2//!
3//! This module is gated behind the `constants` feature to avoid pulling in
4//! octocrab/tokio when not needed.
5
6use rootcause::prelude::*;
7
8/// One entry in the repo's root `manifest.json`: the friendly version a build
9/// maps to. `version` is `major.minor`; `patch` is the third component.
10#[derive(Debug, Clone, serde::Deserialize)]
11pub struct ConstantsVersion {
12    pub version: String,
13    #[serde(default)]
14    pub patch: f64,
15}
16
17impl ConstantsVersion {
18    /// Reconstruct the full friendly version, e.g. version "15.4" + patch 0.0 -> "15.4.0".
19    pub fn friendly_version(&self) -> String {
20        format!("{}.{}", self.version, self.patch as i64)
21    }
22}
23
24/// Fetch the repo's root manifest.json mapping build number -> friendly version.
25pub async fn fetch_constants_manifest() -> Option<std::collections::BTreeMap<u32, ConstantsVersion>> {
26    use http_body_util::BodyExt;
27    use octocrab::params::repos::Reference;
28
29    let response = octocrab::instance()
30        .repos("padtrack", "wows-constants")
31        .raw_file(Reference::Branch("main".to_string()), "manifest.json")
32        .await
33        .ok()?;
34
35    let mut body = response.into_body();
36    let mut result = Vec::new();
37
38    while let Some(frame) = body.frame().await {
39        match frame {
40            Ok(frame) => {
41                if let Some(data) = frame.data_ref() {
42                    result.extend_from_slice(data);
43                }
44            }
45            Err(_) => return None,
46        }
47    }
48
49    // Manifest keys are build numbers as strings.
50    let raw: std::collections::BTreeMap<String, ConstantsVersion> = serde_json::from_slice(&result).ok()?;
51    Some(raw.into_iter().filter_map(|(k, v)| k.parse::<u32>().ok().map(|b| (b, v))).collect())
52}
53
54/// Resolve which build's constants to fetch for a replay's (build, friendly_version),
55/// given the repo manifest. Exact build wins; else the highest build whose friendly
56/// version matches; else None.
57pub fn resolve_manifest_build(
58    target_build: u32,
59    target_version: Option<&str>,
60    manifest: &std::collections::BTreeMap<u32, ConstantsVersion>,
61) -> Option<u32> {
62    if manifest.contains_key(&target_build) {
63        return Some(target_build);
64    }
65    let want = target_version?;
66    manifest.iter().filter(|(_, v)| v.friendly_version() == want).map(|(b, _)| *b).max()
67}
68
69/// Fetch versioned constants for a specific build from GitHub.
70///
71/// Resolves the build to fetch via the repo manifest (friendly-version match,
72/// so cross-region replays find the matching build), then falls back to exact
73/// build match or the nearest older build. Returns `(json_data, actual_build_fetched)`.
74pub fn fetch_versioned_constants_blocking(
75    build: u32,
76    target_version: Option<&str>,
77) -> Result<(serde_json::Value, u32), rootcause::Report> {
78    let runtime = tokio::runtime::Builder::new_current_thread()
79        .enable_all()
80        .build()
81        .attach_with(|| "Failed to create tokio runtime")?;
82
83    runtime.block_on(fetch_versioned_constants(build, target_version))
84}
85
86/// Async version of [`fetch_versioned_constants_blocking`].
87/// Use this when you already have a tokio runtime (e.g. from wows-toolkit's networking thread).
88pub async fn fetch_versioned_constants(
89    target_build: u32,
90    target_version: Option<&str>,
91) -> Result<(serde_json::Value, u32), rootcause::Report> {
92    if let Some(manifest) = fetch_constants_manifest().await
93        && let Some(resolved) = resolve_manifest_build(target_build, target_version, &manifest)
94        && let Some(data) = fetch_build(resolved).await
95    {
96        return Ok((data, resolved));
97    }
98    // Fallback: version-blind exact-then-nearest-older.
99    let available = list_available_builds().await?;
100    pick_constants(target_build, &available)
101        .await
102        .ok_or_else(|| report!("No constants found for build {target_build} or any older build"))
103}
104
105/// Select constants for `target_build` given a pre-fetched `available` list:
106/// exact match first, otherwise nearest older build. Returns `None` only when
107/// nothing usable is published upstream.
108async fn pick_constants(target_build: u32, available: &[u32]) -> Option<(serde_json::Value, u32)> {
109    if available.contains(&target_build)
110        && let Some(data) = fetch_build(target_build).await
111    {
112        return Some((data, target_build));
113    }
114
115    for &build in available.iter().rev() {
116        if build >= target_build {
117            continue;
118        }
119        if let Some(data) = fetch_build(build).await {
120            return Some((data, build));
121        }
122    }
123    None
124}
125
126/// Stateful fetcher that caches the upstream manifest and available-build list
127/// so the listing requests run once per process even when constants are fetched
128/// for many builds in a row (e.g. backfilling via `wows-data-mgr refresh-derived`).
129pub struct ConstantsFetcher {
130    runtime: tokio::runtime::Runtime,
131    manifest: Option<std::collections::BTreeMap<u32, ConstantsVersion>>,
132    available: Vec<u32>,
133}
134
135impl ConstantsFetcher {
136    /// Create a fetcher and pre-load the manifest and list of available builds.
137    pub fn new() -> Result<Self, rootcause::Report> {
138        let runtime = tokio::runtime::Builder::new_current_thread()
139            .enable_all()
140            .build()
141            .attach_with(|| "Failed to create tokio runtime")?;
142        let manifest = runtime.block_on(fetch_constants_manifest());
143        let available = runtime.block_on(list_available_builds())?;
144        Ok(Self { runtime, manifest, available })
145    }
146
147    /// Returns `(json_data, actual_build_fetched)` resolving the build via the
148    /// cached manifest (friendly-version match for `target_version`), falling
149    /// back to exact match or the nearest older build.
150    pub fn fetch(&self, target_build: u32, target_version: Option<&str>) -> Option<(serde_json::Value, u32)> {
151        if let Some(manifest) = self.manifest.as_ref()
152            && let Some(resolved) = resolve_manifest_build(target_build, target_version, manifest)
153            && let Some(data) = self.runtime.block_on(fetch_build(resolved))
154        {
155            return Some((data, resolved));
156        }
157        self.runtime.block_on(pick_constants(target_build, &self.available))
158    }
159}
160
161/// List all available build numbers from the padtrack/wows-constants repo.
162pub async fn list_available_builds() -> Result<Vec<u32>, rootcause::Report> {
163    let items = octocrab::instance()
164        .repos("padtrack", "wows-constants")
165        .get_content()
166        .path("data/versions")
167        .r#ref("main")
168        .send()
169        .await
170        .attach_with(|| "Failed to list constants builds from GitHub")?;
171
172    let mut builds: Vec<u32> =
173        items.items.iter().filter_map(|item| item.name.strip_suffix(".json")?.parse::<u32>().ok()).collect();
174    builds.sort();
175    Ok(builds)
176}
177
178/// Fetch constants JSON for a specific build number. Returns None if not found.
179pub async fn fetch_build(build: u32) -> Option<serde_json::Value> {
180    use http_body_util::BodyExt;
181    use octocrab::params::repos::Reference;
182
183    let path = format!("data/versions/{build}.json");
184    let response = octocrab::instance()
185        .repos("padtrack", "wows-constants")
186        .raw_file(Reference::Branch("main".to_string()), &path)
187        .await
188        .ok()?;
189
190    let mut body = response.into_body();
191    let mut result = Vec::new();
192
193    while let Some(frame) = body.frame().await {
194        match frame {
195            Ok(frame) => {
196                if let Some(data) = frame.data_ref() {
197                    result.extend_from_slice(data);
198                }
199            }
200            Err(_) => return None,
201        }
202    }
203
204    serde_json::from_slice(&result).ok()
205}
206
207#[cfg(test)]
208mod manifest_tests {
209    use std::collections::BTreeMap;
210
211    use super::*;
212    fn m() -> BTreeMap<u32, ConstantsVersion> {
213        let mut m = BTreeMap::new();
214        m.insert(11965230, ConstantsVersion { version: "15.1".into(), patch: 0.0 });
215        m.insert(12506899, ConstantsVersion { version: "15.4".into(), patch: 0.0 });
216        m
217    }
218    #[test]
219    fn friendly_version_reconstructs() {
220        assert_eq!(ConstantsVersion { version: "15.4".into(), patch: 0.0 }.friendly_version(), "15.4.0");
221    }
222    #[test]
223    fn exact_build_wins() {
224        assert_eq!(resolve_manifest_build(12506899, Some("15.4.0"), &m()), Some(12506899));
225    }
226    #[test]
227    fn cross_region_resolves_by_version() {
228        // CN build not in manifest, same friendly version -> RoW build.
229        assert_eq!(resolve_manifest_build(99999999, Some("15.1.0"), &m()), Some(11965230));
230    }
231    #[test]
232    fn no_match_is_none() {
233        assert_eq!(resolve_manifest_build(99999999, Some("9.9.9"), &m()), None);
234        assert_eq!(resolve_manifest_build(99999999, None, &m()), None);
235    }
236}