Skip to main content

flatland_client_lib/
content_pack.rs

1//! Sim content packs — versioned `assets/` trees published beside client gfx.
2
3use std::collections::BTreeMap;
4use std::path::{Path, PathBuf};
5
6use anyhow::Context;
7
8use crate::asset_backend::{mime_for_path, AssetBackendConfig, AssetStore};
9use crate::assets::{sha256_file, AssetBundleIndex, AssetFileEntry};
10
11/// Relative directories under `assets/` included in the sim pack (worker ContentBundle).
12pub const SIM_PACK_ASSET_DIRS: &[&str] = &[
13    "crafting",
14    "items",
15    "world",
16    "config",
17    "npcs",
18    "loot",
19    "combat",
20    "dialogue",
21    "quests",
22    "crops",
23    "gfx/sprites",
24    "paperdoll",
25];
26
27/// Build a file index for sim content under `repo_root/assets/…`.
28pub fn build_sim_pack_index(
29    repo_root: &Path,
30    publish_rev: u64,
31    published_at: &str,
32) -> anyhow::Result<AssetBundleIndex> {
33    let assets = repo_root.join("assets");
34    let mut files = BTreeMap::new();
35    for rel_dir in SIM_PACK_ASSET_DIRS {
36        let dir = assets.join(rel_dir);
37        if dir.is_dir() {
38            walk_into(&dir, &assets, &mut files)?;
39        }
40    }
41    // Publish marker so workers know the pack rev without a separate channel.
42    let marker = assets.join(".content-publish.json");
43    if marker.is_file() {
44        let meta = std::fs::metadata(&marker)?;
45        files.insert(
46            ".content-publish.json".to_string(),
47            AssetFileEntry {
48                sha256: sha256_file(&marker)?,
49                size: meta.len(),
50            },
51        );
52    }
53    if files.is_empty() {
54        anyhow::bail!("sim pack is empty — expected content under {}", assets.display());
55    }
56    Ok(AssetBundleIndex {
57        publish_rev,
58        published_at: published_at.to_string(),
59        files,
60    })
61}
62
63fn walk_into(
64    dir: &Path,
65    assets_root: &Path,
66    files: &mut BTreeMap<String, AssetFileEntry>,
67) -> anyhow::Result<()> {
68    for entry in std::fs::read_dir(dir).with_context(|| format!("read_dir {}", dir.display()))? {
69        let entry = entry?;
70        let path = entry.path();
71        let name = entry.file_name();
72        let name_str = name.to_string_lossy();
73        if name_str.starts_with('.') || name_str == "node_modules" {
74            continue;
75        }
76        if name_str.ends_with(".map-locks.json") {
77            continue;
78        }
79        if path.is_dir() {
80            walk_into(&path, assets_root, files)?;
81            continue;
82        }
83        if !path.is_file() {
84            continue;
85        }
86        let rel = path
87            .strip_prefix(assets_root)
88            .with_context(|| format!("strip {}", path.display()))?
89            .to_string_lossy()
90            .replace('\\', "/");
91        let meta = std::fs::metadata(&path)?;
92        files.insert(
93            rel,
94            AssetFileEntry {
95                sha256: sha256_file(&path)?,
96                size: meta.len(),
97            },
98        );
99    }
100    Ok(())
101}
102
103/// Resolve an on-disk path for a sim pack relative key (`world/segments/…`).
104pub fn resolve_sim_pack_file(repo_root: &Path, relative: &str) -> PathBuf {
105    repo_root.join("assets").join(relative)
106}
107
108/// Upload sim pack files + `latest.json` via the configured asset store.
109pub async fn upload_sim_pack(
110    store: &dyn AssetStore,
111    cfg: &AssetBackendConfig,
112    repo_root: &Path,
113    index: &AssetBundleIndex,
114) -> anyhow::Result<()> {
115    for (rel, _) in &index.files {
116        let path = resolve_sim_pack_file(repo_root, rel);
117        let bytes = std::fs::read(&path)
118            .with_context(|| format!("read sim pack file {}", path.display()))?;
119        let key = cfg.sim_object_key(index.publish_rev, rel);
120        store
121            .put(&key, mime_for_path(&path), &bytes)
122            .await
123            .with_context(|| format!("upload sim {key}"))?;
124    }
125    let latest = serde_json::to_vec(index)?;
126    store
127        .put(&cfg.sim_index_object(), "application/json", &latest)
128        .await
129        .context("upload sim latest.json")?;
130    Ok(())
131}
132
133/// Download sim pack `publish_rev` into `dest_root/assets/…` (repo-shaped tree).
134pub async fn download_sim_pack(
135    store: &dyn AssetStore,
136    cfg: &AssetBackendConfig,
137    index: &AssetBundleIndex,
138    dest_root: &Path,
139) -> anyhow::Result<PathBuf> {
140    let assets_out = dest_root.join("assets");
141    std::fs::create_dir_all(&assets_out)?;
142
143    const CONCURRENCY: usize = 8;
144    let pending: Vec<(String, AssetFileEntry)> = index
145        .files
146        .iter()
147        .map(|(k, v)| (k.clone(), v.clone()))
148        .collect();
149    let total = pending.len();
150    let mut join_set = tokio::task::JoinSet::new();
151    let mut next = 0usize;
152    let done = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
153
154    // Local backend: read files from disk concurrently via blocking pool.
155    // Remote: each task uses its own HTTP client + config clone.
156    while next < pending.len() || !join_set.is_empty() {
157        while join_set.len() < CONCURRENCY && next < pending.len() {
158            let (rel, entry) = pending[next].clone();
159            next += 1;
160            let cfg = cfg.clone();
161            let assets_out = assets_out.clone();
162            let publish_rev = index.publish_rev;
163            let done = std::sync::Arc::clone(&done);
164            join_set.spawn(async move {
165                let store = crate::asset_backend::store_from_config(&cfg)?;
166                let key = cfg.sim_object_key(publish_rev, &rel);
167                let bytes = store
168                    .get(&key)
169                    .await
170                    .with_context(|| format!("download sim {key}"))?;
171                let got = {
172                    use sha2::{Digest, Sha256};
173                    hex::encode(Sha256::digest(&bytes))
174                };
175                if got != entry.sha256 {
176                    anyhow::bail!("sim pack hash mismatch for {rel}");
177                }
178                let dest = assets_out.join(&rel);
179                if let Some(parent) = dest.parent() {
180                    std::fs::create_dir_all(parent)?;
181                }
182                std::fs::write(&dest, &bytes)?;
183                let n = done.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
184                if n % 100 == 0 || n == total {
185                    tracing::info!(n, total, "sim pack download progress");
186                }
187                Ok::<(), anyhow::Error>(())
188            });
189        }
190        if let Some(res) = join_set.join_next().await {
191            res.map_err(|e| anyhow::anyhow!("sim pack download join: {e}"))??;
192        }
193    }
194    Ok(dest_root.to_path_buf())
195}
196
197/// Cache directory for a downloaded sim pack rev.
198pub fn sim_pack_cache_dir(publish_rev: u64) -> anyhow::Result<PathBuf> {
199    let base = dirs::home_dir().ok_or_else(|| anyhow::anyhow!("home directory not found"))?;
200    Ok(base
201        .join(".flatland3")
202        .join("sim-packs")
203        .join(format!("rev-{publish_rev}")))
204}
205
206/// Fetch sim `latest.json`, download pack if needed, return repo-shaped root.
207pub async fn ensure_sim_pack_cached(
208    target_rev: Option<u64>,
209) -> anyhow::Result<(u64, PathBuf)> {
210    let cfg = AssetBackendConfig::from_env();
211    let store = crate::asset_backend::store_from_config(&cfg)?;
212    let index_bytes = store.get(&cfg.sim_index_object()).await.with_context(|| {
213        format!(
214            "fetch sim latest.json ({})",
215            cfg.sim_index_object()
216        )
217    })?;
218    let index: AssetBundleIndex = serde_json::from_slice(&index_bytes)?;
219    let rev = target_rev.unwrap_or(index.publish_rev);
220    if rev != index.publish_rev {
221        anyhow::bail!(
222            "requested sim pack rev {rev} but remote latest is {}",
223            index.publish_rev
224        );
225    }
226    let dest = sim_pack_cache_dir(rev)?;
227    if sim_pack_cache_complete(&dest, &index)? {
228        return Ok((rev, dest));
229    }
230    // Incomplete / corrupt cache — wipe and re-download.
231    let _ = std::fs::remove_dir_all(&dest);
232    download_sim_pack(store.as_ref(), &cfg, &index, &dest).await?;
233    if !sim_pack_cache_complete(&dest, &index)? {
234        anyhow::bail!("sim pack rev {rev} incomplete after download");
235    }
236    Ok((rev, dest))
237}
238
239fn sim_pack_cache_complete(dest_root: &Path, index: &AssetBundleIndex) -> anyhow::Result<bool> {
240    let assets = dest_root.join("assets");
241    if !assets.is_dir() {
242        return Ok(false);
243    }
244    for (rel, entry) in &index.files {
245        let path = assets.join(rel);
246        if !path.is_file() {
247            return Ok(false);
248        }
249        let meta = std::fs::metadata(&path)?;
250        if meta.len() != entry.size && entry.size > 0 {
251            // Size mismatch — treat as incomplete (hash check is expensive for every boot).
252            return Ok(false);
253        }
254    }
255    Ok(true)
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261    use crate::asset_backend::LocalAssetStore;
262
263    #[tokio::test]
264    async fn local_sim_pack_roundtrip() {
265        let repo = match flatland_repo_fixture() {
266            Some(p) => p,
267            None => return,
268        };
269        let tmp = std::env::temp_dir().join(format!(
270            "flatland-sim-pack-test-{}",
271            std::process::id()
272        ));
273        let _ = std::fs::remove_dir_all(&tmp);
274        std::fs::create_dir_all(&tmp).unwrap();
275        let store = LocalAssetStore {
276            root: tmp.join("store"),
277        };
278        let cfg = AssetBackendConfig {
279            kind: crate::asset_backend::AssetBackendKind::Local,
280            bucket: "local".into(),
281            client_prefix: "client".into(),
282            sim_prefix: "sim".into(),
283            local_root: tmp.join("store"),
284            s3_region: "us-east-1".into(),
285            s3_endpoint: None,
286            index_url_override: None,
287        };
288        let index = build_sim_pack_index(&repo, 42, "test").expect("index");
289        assert!(!index.files.is_empty());
290        upload_sim_pack(&store, &cfg, &repo, &index)
291            .await
292            .expect("upload");
293        let dest = tmp.join("out");
294        download_sim_pack(&store, &cfg, &index, &dest)
295            .await
296            .expect("download");
297        assert!(dest.join("assets/world").is_dir() || dest.join("assets/items").is_dir());
298        let _ = std::fs::remove_dir_all(&tmp);
299    }
300
301    fn flatland_repo_fixture() -> Option<PathBuf> {
302        let mut dir = std::env::current_dir().ok()?;
303        for _ in 0..8 {
304            if dir.join("assets/world/segments").is_dir() {
305                return Some(dir);
306            }
307            if !dir.pop() {
308                break;
309            }
310        }
311        None
312    }
313}