Skip to main content

flatland_client_lib/
assets.rs

1//! Gfx sprite + paperdoll bundle sync for installed clients (Firebase Storage / GCS).
2
3use std::collections::BTreeMap;
4use std::io::Read;
5use std::path::{Path, PathBuf};
6use std::sync::Arc;
7use std::time::Duration;
8
9use serde::{Deserialize, Serialize};
10use sha2::{Digest, Sha256};
11
12pub const DEFAULT_FIREBASE_BUCKET: &str = "flatland-8911e.appspot.com";
13pub const ASSETS_INDEX_OBJECT: &str = "flatland3/client-assets/latest.json";
14
15/// Parallel download workers for installed-client sync.
16const SYNC_CONCURRENCY: usize = 8;
17const SYNC_ATTEMPTS: u32 = 4;
18const SYNC_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
19const SYNC_REQUEST_TIMEOUT: Duration = Duration::from_secs(120);
20
21#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
22pub struct AssetFileEntry {
23    pub sha256: String,
24    pub size: u64,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
28pub struct AssetBundleIndex {
29    /// Stable revision from `assets/.content-publish.json` (not process-local `content_rev`).
30    pub publish_rev: u64,
31    pub published_at: String,
32    pub files: BTreeMap<String, AssetFileEntry>,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize, Default)]
36pub struct LocalAssetState {
37    pub publish_rev: u64,
38    pub sprites_dir: PathBuf,
39}
40
41pub fn assets_root_dir() -> anyhow::Result<PathBuf> {
42    let base = dirs::home_dir().ok_or_else(|| anyhow::anyhow!("home directory not found"))?;
43    Ok(base.join(".flatland3").join("assets"))
44}
45
46pub fn local_state_path() -> anyhow::Result<PathBuf> {
47    Ok(assets_root_dir()?.join("state.json"))
48}
49
50pub fn read_local_state() -> Option<LocalAssetState> {
51    let path = local_state_path().ok()?;
52    let bytes = std::fs::read(path).ok()?;
53    serde_json::from_slice(&bytes).ok()
54}
55
56pub fn write_local_state(state: &LocalAssetState) -> anyhow::Result<()> {
57    let path = local_state_path()?;
58    if let Some(parent) = path.parent() {
59        std::fs::create_dir_all(parent)?;
60    }
61    std::fs::write(path, serde_json::to_vec_pretty(state)?)?;
62    Ok(())
63}
64
65pub fn sprites_dir_for_rev(publish_rev: u64) -> anyhow::Result<PathBuf> {
66    Ok(assets_root_dir()?.join(format!("rev-{publish_rev}")))
67}
68
69pub fn current_symlink_path() -> anyhow::Result<PathBuf> {
70    Ok(assets_root_dir()?.join("current"))
71}
72
73pub fn activate_rev_dir(rev_dir: &Path) -> anyhow::Result<()> {
74    let link = current_symlink_path()?;
75    if link.exists() {
76        std::fs::remove_file(&link).or_else(|_| std::fs::remove_dir_all(&link))?;
77    }
78    #[cfg(unix)]
79    {
80        std::os::unix::fs::symlink(rev_dir, &link)?;
81    }
82    #[cfg(not(unix))]
83    {
84        // Windows: record the active rev path beside `current` (no symlink required).
85        let marker = link.with_extension("path");
86        std::fs::write(&marker, rev_dir.to_string_lossy().as_bytes())?;
87        let _ = std::fs::create_dir_all(&link);
88        let _ = std::fs::copy(rev_dir.join("manifest.yaml"), link.join("manifest.yaml"));
89    }
90    Ok(())
91}
92
93pub fn find_repo_sprites_dir() -> Option<PathBuf> {
94    let mut dir = std::env::current_dir().ok()?;
95    for _ in 0..8 {
96        let candidate = dir.join("assets/gfx/sprites");
97        if candidate.join("manifest.yaml").is_file() {
98            return Some(candidate);
99        }
100        if !dir.pop() {
101            break;
102        }
103    }
104    None
105}
106
107/// True when the checkout sprites should win over a synced `~/.flatland3` cache.
108pub(crate) fn repo_sprites_outrank_cache(repo_rev: Option<u64>, local_rev: Option<u64>) -> bool {
109    match (repo_rev, local_rev) {
110        (Some(rr), Some(lr)) => rr >= lr,
111        (Some(_), None) => true,
112        _ => false,
113    }
114}
115
116/// Prefer the repo checkout when it is at least as new as the synced cache.
117///
118/// Local play often has a stale `~/.flatland3/assets/rev-N` from an older sync.
119/// `needs_asset_sync` already treats a matching repo as up to date, but loading
120/// still went through the stale cache — so new sheets (e.g. lodging) fell back
121/// to `other.default` after publish.
122pub fn resolve_sprites_dir() -> Option<PathBuf> {
123    if let Ok(p) = std::env::var("FLATLAND_SPRITES_DIR") {
124        let path = PathBuf::from(p);
125        if path.join("manifest.yaml").is_file() {
126            return Some(path);
127        }
128    }
129
130    let repo = find_repo_sprites_dir();
131    let repo_rev = read_repo_publish_rev();
132    let local = read_local_state().filter(|s| s.sprites_dir.join("manifest.yaml").is_file());
133    let local_rev = local.as_ref().map(|s| s.publish_rev);
134
135    if should_prefer_repo_assets() && repo_sprites_outrank_cache(repo_rev, local_rev) {
136        if let Some(repo_dir) = repo {
137            return Some(repo_dir);
138        }
139    }
140    if let Some(local) = local {
141        return Some(local.sprites_dir);
142    }
143    if let Some(repo_dir) = repo {
144        return Some(repo_dir);
145    }
146
147    let current = current_symlink_path().ok()?;
148    if current.join("manifest.yaml").is_file() {
149        return Some(current);
150    }
151    None
152}
153
154pub fn assets_index_url() -> String {
155    std::env::var("FLATLAND_ASSETS_INDEX_URL").unwrap_or_else(|_| {
156        firebase_download_url(DEFAULT_FIREBASE_BUCKET, ASSETS_INDEX_OBJECT)
157    })
158}
159
160pub fn firebase_download_url(bucket: &str, object_path: &str) -> String {
161    let encoded = urlencoding_encode(object_path);
162    format!(
163        "https://firebasestorage.googleapis.com/v0/b/{bucket}/o/{encoded}?alt=media"
164    )
165}
166
167pub fn assets_storage_prefix() -> String {
168    std::env::var("FLATLAND_ASSETS_PREFIX")
169        .unwrap_or_else(|_| "flatland3/client-assets".to_string())
170}
171
172pub fn firebase_object_path_for_prefix(prefix: &str, publish_rev: u64, relative: &str) -> String {
173    format!("{prefix}/rev-{publish_rev}/{relative}")
174}
175
176pub fn firebase_object_path(publish_rev: u64, relative: &str) -> String {
177    firebase_object_path_for_prefix(&assets_storage_prefix(), publish_rev, relative)
178}
179
180fn urlencoding_encode(path: &str) -> String {
181    path.bytes()
182        .map(|b| match b {
183            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
184                (b as char).to_string()
185            }
186            _ => format!("%{b:02X}"),
187        })
188        .collect()
189}
190
191pub fn sha256_file(path: &Path) -> anyhow::Result<String> {
192    let mut file = std::fs::File::open(path)?;
193    let mut hasher = Sha256::new();
194    let mut buf = [0u8; 8192];
195    loop {
196        let n = file.read(&mut buf)?;
197        if n == 0 {
198            break;
199        }
200        hasher.update(&buf[..n]);
201    }
202    Ok(hex::encode(hasher.finalize()))
203}
204
205/// Walk `assets/gfx/sprites` and build a publish manifest (relative paths → hash).
206pub fn build_bundle_index(sprites_dir: &Path, publish_rev: u64, published_at: &str) -> anyhow::Result<AssetBundleIndex> {
207    build_client_bundle_index(&ClientBundleSources {
208        sprites_dir,
209        ..ClientBundleSources::empty()
210    }, publish_rev, published_at)
211}
212
213/// Roots assembled into one installed-client Firebase bundle.
214#[derive(Debug, Clone, Copy)]
215pub struct ClientBundleSources<'a> {
216    pub sprites_dir: &'a Path,
217    pub paperdoll_dir: Option<&'a Path>,
218    pub player_presentation: Option<&'a Path>,
219    /// `assets/gfx/player` → synced as `player/` (legacy atlas fallback).
220    pub player_art_dir: Option<&'a Path>,
221    /// `assets/config/client-settings.yaml` → `config/client-settings.yaml`.
222    pub client_settings: Option<&'a Path>,
223    /// `assets/world/terrain-kinds.yaml` → `presentation/terrain-kinds.yaml`.
224    pub terrain_kinds: Option<&'a Path>,
225}
226
227impl<'a> ClientBundleSources<'a> {
228    pub fn empty() -> Self {
229        Self {
230            sprites_dir: Path::new(""),
231            paperdoll_dir: None,
232            player_presentation: None,
233            player_art_dir: None,
234            client_settings: None,
235            terrain_kinds: None,
236        }
237    }
238}
239
240/// Build the installed-client asset index: sprites at the rev root plus optional
241/// paperdoll / player presentation / player art / client config trees.
242pub fn build_client_bundle_index(
243    sources: &ClientBundleSources<'_>,
244    publish_rev: u64,
245    published_at: &str,
246) -> anyhow::Result<AssetBundleIndex> {
247    let mut files = BTreeMap::new();
248    walk_asset_dir(sources.sprites_dir, sources.sprites_dir, "", &mut files)?;
249    if let Some(paperdoll) = sources.paperdoll_dir {
250        if paperdoll.is_dir() {
251            walk_asset_dir(paperdoll, paperdoll, "paperdoll/", &mut files)?;
252        }
253    }
254    if let Some(player_art) = sources.player_art_dir {
255        if player_art.is_dir() {
256            walk_asset_dir(player_art, player_art, "player/", &mut files)?;
257        }
258    }
259    insert_single_file(&mut files, "player-presentation.yaml", sources.player_presentation)?;
260    insert_single_file(
261        &mut files,
262        "config/client-settings.yaml",
263        sources.client_settings,
264    )?;
265    insert_single_file(
266        &mut files,
267        "presentation/terrain-kinds.yaml",
268        sources.terrain_kinds,
269    )?;
270    Ok(AssetBundleIndex {
271        publish_rev,
272        published_at: published_at.to_string(),
273        files,
274    })
275}
276
277fn insert_single_file(
278    files: &mut BTreeMap<String, AssetFileEntry>,
279    key: &str,
280    path: Option<&Path>,
281) -> anyhow::Result<()> {
282    let Some(path) = path else {
283        return Ok(());
284    };
285    if !path.is_file() {
286        return Ok(());
287    }
288    let meta = std::fs::metadata(path)?;
289    files.insert(
290        key.to_string(),
291        AssetFileEntry {
292            sha256: sha256_file(path)?,
293            size: meta.len(),
294        },
295    );
296    Ok(())
297}
298
299/// Resolve on-disk path for a relative key in the client asset bundle.
300pub fn resolve_bundle_file_path(sources: &ClientBundleSources<'_>, rel: &str) -> PathBuf {
301    if rel == "player-presentation.yaml" {
302        if let Some(path) = sources.player_presentation {
303            return path.to_path_buf();
304        }
305    }
306    if rel == "config/client-settings.yaml" {
307        if let Some(path) = sources.client_settings {
308            return path.to_path_buf();
309        }
310    }
311    if rel == "presentation/terrain-kinds.yaml" {
312        if let Some(path) = sources.terrain_kinds {
313            return path.to_path_buf();
314        }
315    }
316    if let Some(rest) = rel.strip_prefix("paperdoll/") {
317        if let Some(root) = sources.paperdoll_dir {
318            return root.join(rest);
319        }
320    }
321    if let Some(rest) = rel.strip_prefix("player/") {
322        if let Some(root) = sources.player_art_dir {
323            return root.join(rest);
324        }
325    }
326    sources.sprites_dir.join(rel)
327}
328
329/// Active synced rev directory (`~/.flatland3/assets/rev-N` or `current`).
330pub fn synced_rev_dir() -> Option<PathBuf> {
331    if let Some(local) = read_local_state() {
332        if local.sprites_dir.join("manifest.yaml").is_file() {
333            return Some(local.sprites_dir);
334        }
335    }
336    let current = current_symlink_path().ok()?;
337    if current.join("manifest.yaml").is_file() {
338        return Some(current);
339    }
340    None
341}
342
343/// Paperdoll root for installed / synced clients (`rev-N/paperdoll`) or repo checkout.
344pub fn resolve_paperdoll_dir() -> Option<PathBuf> {
345    if let Ok(p) = std::env::var("FLATLAND_PAPERDOLL_DIR") {
346        let path = PathBuf::from(p);
347        if path.is_dir() {
348            return Some(path);
349        }
350    }
351    if let Ok(assets) = std::env::var("FLATLAND_ASSETS") {
352        let path = PathBuf::from(assets).join("paperdoll");
353        if path.is_dir() {
354            return Some(path);
355        }
356    }
357    if let Some(sprites) = resolve_sprites_dir() {
358        let path = sprites.join("paperdoll");
359        if path.is_dir() {
360            return Some(path);
361        }
362    }
363    if let Some(rev) = synced_rev_dir() {
364        let path = rev.join("paperdoll");
365        if path.is_dir() {
366            return Some(path);
367        }
368    }
369    let mut dir = std::env::current_dir().ok()?;
370    for _ in 0..8 {
371        let candidate = dir.join("assets/paperdoll");
372        if candidate.is_dir() {
373            return Some(candidate);
374        }
375        if !dir.pop() {
376            break;
377        }
378    }
379    None
380}
381
382/// True when this process should use the git checkout sprites instead of Firebase.
383///
384/// Installed binaries (`~/.local/bin`, …) always prefer remote sync. Cargo
385/// `target/` builds keep the fast repo shortcut unless forced.
386pub fn should_prefer_repo_assets() -> bool {
387    if std::env::var_os("FLATLAND_FORCE_ASSET_SYNC").is_some() {
388        return false;
389    }
390    if std::env::var_os("FLATLAND_USE_REPO_ASSETS").is_some() {
391        return true;
392    }
393    let Ok(exe) = std::env::current_exe() else {
394        return false;
395    };
396    exe.components().any(|c| c.as_os_str() == "target") && find_repo_sprites_dir().is_some()
397}
398
399fn walk_asset_dir(
400    root: &Path,
401    dir: &Path,
402    prefix: &str,
403    files: &mut BTreeMap<String, AssetFileEntry>,
404) -> anyhow::Result<()> {
405    for entry in std::fs::read_dir(dir)? {
406        let entry = entry?;
407        let path = entry.path();
408        if path.is_dir() {
409            let name = entry.file_name();
410            if name == "previews" {
411                continue;
412            }
413            walk_asset_dir(root, &path, prefix, files)?;
414            continue;
415        }
416        let rel = path
417            .strip_prefix(root)?
418            .to_string_lossy()
419            .replace('\\', "/");
420        if rel.starts_with('.') || rel.contains("/.") {
421            continue;
422        }
423        if rel.ends_with(".html") {
424            continue;
425        }
426        let meta = std::fs::metadata(&path)?;
427        files.insert(
428            format!("{prefix}{rel}"),
429            AssetFileEntry {
430                sha256: sha256_file(&path)?,
431                size: meta.len(),
432            },
433        );
434    }
435    Ok(())
436}
437
438pub struct AssetSyncOptions {
439    pub index_url: String,
440    pub storage_prefix: String,
441    pub target_rev: Option<u64>,
442    pub quiet: bool,
443    /// When true, always fetch Firebase even from a cargo `target/` build.
444    pub force_remote: bool,
445}
446
447/// Outcome of [`sync_assets`] for logging / HUD toasts.
448#[derive(Debug, Clone, Copy, PartialEq, Eq)]
449pub enum AssetSyncKind {
450    /// Downloaded or verified files against remote `latest.json`.
451    Remote,
452    /// Remote index missing; reused `~/.flatland3` cache.
453    LocalCacheNoRemote,
454    /// Remote index missing; using repo `assets/gfx/sprites` (dev checkout).
455    RepoDevNoRemote,
456}
457
458#[derive(Debug, Clone)]
459pub struct AssetSyncResult {
460    pub state: LocalAssetState,
461    pub kind: AssetSyncKind,
462}
463
464impl Default for AssetSyncOptions {
465    fn default() -> Self {
466        Self {
467            index_url: assets_index_url(),
468            storage_prefix: assets_storage_prefix(),
469            target_rev: None,
470            quiet: false,
471            force_remote: false,
472        }
473    }
474}
475
476pub async fn sync_assets(opts: AssetSyncOptions) -> anyhow::Result<AssetSyncResult> {
477    // Cargo `target/` builds: prefer repo sprites and skip remote download so gfx
478    // play does not freeze on Firebase after login. Installed binaries always sync.
479    // Explicit `force_remote` (CLI `assets sync`) always hits Firebase.
480    if !opts.force_remote && should_prefer_repo_assets() {
481        if let Some(repo_dir) = find_repo_sprites_dir() {
482            if let Some(publish_rev) = read_repo_publish_rev() {
483                if opts.target_rev.map_or(true, |t| publish_rev >= t) {
484                    if !opts.quiet {
485                        println!(
486                            "Using repo sprites at {} (rev {publish_rev}); set FLATLAND_FORCE_ASSET_SYNC=1 to pull remote.",
487                            repo_dir.display()
488                        );
489                    }
490                    return Ok(AssetSyncResult {
491                        state: LocalAssetState {
492                            publish_rev,
493                            sprites_dir: repo_dir,
494                        },
495                        kind: AssetSyncKind::RepoDevNoRemote,
496                    });
497                }
498            }
499        }
500    }
501
502    let client = reqwest::Client::builder()
503        .user_agent(format!("flatland-client-lib/{}", env!("CARGO_PKG_VERSION")))
504        .connect_timeout(SYNC_CONNECT_TIMEOUT)
505        .timeout(SYNC_REQUEST_TIMEOUT)
506        .pool_max_idle_per_host(SYNC_CONCURRENCY)
507        .build()?;
508    let response = client.get(&opts.index_url).send().await?;
509    if response.status() == reqwest::StatusCode::NOT_FOUND {
510        return sync_without_remote_index(opts);
511    }
512    let index: AssetBundleIndex = response.error_for_status()?.json().await?;
513    if index.files.is_empty() {
514        anyhow::bail!("remote asset index is empty — refuse to sync");
515    }
516    if !index.files.contains_key("manifest.yaml") {
517        anyhow::bail!("remote asset index missing manifest.yaml");
518    }
519    sync_from_index(&client, opts, index).await
520}
521
522fn sync_without_remote_index(opts: AssetSyncOptions) -> anyhow::Result<AssetSyncResult> {
523    if let Some(local) = read_local_state() {
524        if local.sprites_dir.join("manifest.yaml").is_file() {
525            if !opts.quiet {
526                println!(
527                    "Remote latest.json not found; using cached publish rev {}.",
528                    local.publish_rev
529                );
530            }
531            return Ok(AssetSyncResult {
532                state: local,
533                kind: AssetSyncKind::LocalCacheNoRemote,
534            });
535        }
536    }
537    if let Some(repo) = find_repo_sprites_dir() {
538        let publish_rev = read_repo_publish_rev().unwrap_or(0);
539        if !opts.quiet {
540            println!(
541                "Remote latest.json not found; using repo sprites at {} (rev {publish_rev}).",
542                repo.display()
543            );
544        }
545        return Ok(AssetSyncResult {
546            state: LocalAssetState {
547                publish_rev,
548                sprites_dir: repo,
549            },
550            kind: AssetSyncKind::RepoDevNoRemote,
551        });
552    }
553    anyhow::bail!(
554        "Gfx asset bundle not published yet (HTTP 404 on latest.json). \
555         Server admin: flatland-admin content publish"
556    );
557}
558
559async fn sync_from_index(
560    client: &reqwest::Client,
561    opts: AssetSyncOptions,
562    index: AssetBundleIndex,
563) -> anyhow::Result<AssetSyncResult> {
564    let publish_rev = opts.target_rev.unwrap_or(index.publish_rev);
565    if publish_rev != index.publish_rev {
566        anyhow::bail!(
567            "requested publish rev {publish_rev} but remote latest is {}",
568            index.publish_rev
569        );
570    }
571
572    if let Some(local) = read_local_state() {
573        if local.publish_rev == publish_rev && local.sprites_dir.join("manifest.yaml").is_file() {
574            if verify_rev_dir(&local.sprites_dir, &index)? {
575                if !opts.quiet {
576                    println!(
577                        "Assets up to date (publish rev {publish_rev}, {} files).",
578                        index.files.len()
579                    );
580                }
581                return Ok(AssetSyncResult {
582                    state: local,
583                    kind: AssetSyncKind::Remote,
584                });
585            }
586            if !opts.quiet {
587                println!(
588                    "Cached rev {publish_rev} incomplete or corrupt — re-syncing {} files…",
589                    index.files.len()
590                );
591            }
592        }
593    }
594
595    let rev_dir = sprites_dir_for_rev(publish_rev)?;
596    std::fs::create_dir_all(&rev_dir)?;
597
598    let total = index.files.len();
599    let mut pending: Vec<(String, AssetFileEntry)> = Vec::new();
600    let mut already_ok = 0usize;
601    for (rel, entry) in &index.files {
602        let dest = rev_dir.join(rel);
603        if dest.is_file() && sha256_file(&dest)? == entry.sha256 {
604            already_ok += 1;
605            continue;
606        }
607        pending.push((rel.clone(), entry.clone()));
608    }
609
610    if !opts.quiet {
611        println!(
612            "Syncing publish rev {publish_rev}: {already_ok}/{total} present, {} to download…",
613            pending.len()
614        );
615    }
616
617    download_files_concurrent(client, &opts, publish_rev, &rev_dir, pending, opts.quiet).await?;
618
619    if !verify_rev_dir(&rev_dir, &index)? {
620        anyhow::bail!(
621            "asset sync verification failed for rev {publish_rev} after download — try again"
622        );
623    }
624
625    // Persist the index for debugging / future incremental tools.
626    let index_path = rev_dir.join(".bundle-index.json");
627    let _ = std::fs::write(&index_path, serde_json::to_vec_pretty(&index)?);
628
629    activate_rev_dir(&rev_dir)?;
630    let state = LocalAssetState {
631        publish_rev,
632        sprites_dir: rev_dir,
633    };
634    write_local_state(&state)?;
635    if !opts.quiet {
636        let paperdoll_n = index
637            .files
638            .keys()
639            .filter(|k| k.starts_with("paperdoll/"))
640            .count();
641        println!(
642            "Synced publish rev {publish_rev} → {} ({} files, {paperdoll_n} paperdoll)",
643            state.sprites_dir.display(),
644            index.files.len()
645        );
646    }
647    Ok(AssetSyncResult {
648        state,
649        kind: AssetSyncKind::Remote,
650    })
651}
652
653async fn download_files_concurrent(
654    client: &reqwest::Client,
655    opts: &AssetSyncOptions,
656    publish_rev: u64,
657    rev_dir: &Path,
658    pending: Vec<(String, AssetFileEntry)>,
659    quiet: bool,
660) -> anyhow::Result<()> {
661    if pending.is_empty() {
662        return Ok(());
663    }
664    let total = pending.len();
665    let client = client.clone();
666    let prefix = opts.storage_prefix.clone();
667    let rev_dir = rev_dir.to_path_buf();
668    let pending = Arc::new(pending);
669    let done = Arc::new(std::sync::atomic::AtomicUsize::new(0));
670    let mut join_set = tokio::task::JoinSet::new();
671    let mut next = 0usize;
672
673    while next < pending.len() || !join_set.is_empty() {
674        while join_set.len() < SYNC_CONCURRENCY && next < pending.len() {
675            let (rel, entry) = pending[next].clone();
676            next += 1;
677            let client = client.clone();
678            let prefix = prefix.clone();
679            let rev_dir = rev_dir.clone();
680            let done = Arc::clone(&done);
681            join_set.spawn(async move {
682                download_one_with_retries(&client, &prefix, publish_rev, &rev_dir, &rel, &entry)
683                    .await?;
684                let n = done.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
685                if !quiet && (n % 25 == 0 || n == total) {
686                    println!("  [{n}/{total}] synced…");
687                }
688                Ok::<(), anyhow::Error>(())
689            });
690        }
691        if let Some(res) = join_set.join_next().await {
692            res.map_err(|e| anyhow::anyhow!("asset download task join: {e}"))??;
693        }
694    }
695    Ok(())
696}
697
698async fn download_one_with_retries(
699    client: &reqwest::Client,
700    prefix: &str,
701    publish_rev: u64,
702    rev_dir: &Path,
703    rel: &str,
704    entry: &AssetFileEntry,
705) -> anyhow::Result<()> {
706    let dest = rev_dir.join(rel);
707    if let Some(parent) = dest.parent() {
708        std::fs::create_dir_all(parent)?;
709    }
710    let url = firebase_download_url(
711        DEFAULT_FIREBASE_BUCKET,
712        &firebase_object_path_for_prefix(prefix, publish_rev, rel),
713    );
714    let mut last_err = None;
715    for attempt in 1..=SYNC_ATTEMPTS {
716        match download_one(client, &url, &dest, entry).await {
717            Ok(()) => return Ok(()),
718            Err(err) => {
719                last_err = Some(err);
720                if attempt < SYNC_ATTEMPTS {
721                    let backoff = Duration::from_millis(200 * 2u64.pow(attempt - 1));
722                    tokio::time::sleep(backoff).await;
723                }
724            }
725        }
726    }
727    Err(last_err.unwrap_or_else(|| anyhow::anyhow!("download failed for {rel}")))
728}
729
730async fn download_one(
731    client: &reqwest::Client,
732    url: &str,
733    dest: &Path,
734    entry: &AssetFileEntry,
735) -> anyhow::Result<()> {
736    let bytes = client
737        .get(url)
738        .send()
739        .await?
740        .error_for_status()?
741        .bytes()
742        .await?;
743    if bytes.len() as u64 != entry.size && entry.size > 0 {
744        // Size mismatch is a soft warning — hash is authoritative (GCS may differ on empty).
745        if sha256_bytes(&bytes) != entry.sha256 {
746            anyhow::bail!(
747                "size/hash mismatch (got {} bytes, expected {} / {})",
748                bytes.len(),
749                entry.size,
750                entry.sha256
751            );
752        }
753    } else if sha256_bytes(&bytes) != entry.sha256 {
754        anyhow::bail!("hash mismatch after download");
755    }
756    // Keep the full filename so concurrent downloads of `a.png` / `a.yaml` never
757    // share one `.partial` path (`with_extension` would collide).
758    let tmp = dest.with_file_name(format!(
759        "{}.partial",
760        dest.file_name()
761            .and_then(|n| n.to_str())
762            .unwrap_or("asset")
763    ));
764    std::fs::write(&tmp, &bytes)?;
765    std::fs::rename(&tmp, dest)?;
766    Ok(())
767}
768
769pub fn needs_asset_sync(server_publish_rev: u64) -> bool {
770    if server_publish_rev == 0 {
771        return false;
772    }
773    if let Some(local) = read_local_state() {
774        if local.publish_rev >= server_publish_rev
775            && local.sprites_dir.join("manifest.yaml").is_file()
776        {
777            return false;
778        }
779    }
780    if should_prefer_repo_assets() {
781        if let Some(repo_rev) = read_repo_publish_rev() {
782            if repo_rev >= server_publish_rev && find_repo_sprites_dir().is_some() {
783                return false;
784            }
785        }
786    }
787    resolve_sprites_dir().is_none()
788}
789
790/// Publish rev from `assets/.content-publish.json` when running from a dev checkout.
791pub fn read_repo_publish_rev() -> Option<u64> {
792    let sprites = find_repo_sprites_dir()?;
793    let mut dir = sprites.as_path();
794    for _ in 0..8 {
795        let marker = dir.join(".content-publish.json");
796        if marker.is_file() {
797            #[derive(Deserialize)]
798            struct Marker {
799                rev: u64,
800            }
801            let text = std::fs::read_to_string(marker).ok()?;
802            let m: Marker = serde_json::from_str(&text).ok()?;
803            return Some(m.rev);
804        }
805        dir = dir.parent()?;
806    }
807    None
808}
809
810fn verify_rev_dir(dir: &Path, index: &AssetBundleIndex) -> anyhow::Result<bool> {
811    for (rel, entry) in &index.files {
812        let path = dir.join(rel);
813        if !path.is_file() {
814            return Ok(false);
815        }
816        if sha256_file(&path)? != entry.sha256 {
817            return Ok(false);
818        }
819    }
820    Ok(true)
821}
822
823fn sha256_bytes(bytes: &[u8]) -> String {
824    let mut hasher = Sha256::new();
825    hasher.update(bytes);
826    hex::encode(hasher.finalize())
827}
828
829#[cfg(test)]
830mod tests {
831    use super::*;
832    use std::fs;
833
834    #[test]
835    fn firebase_url_encodes_slashes() {
836        let url = firebase_download_url("bucket", "flatland3/client-assets/latest.json");
837        assert!(url.contains("%2F"));
838        assert!(url.contains("alt=media"));
839    }
840
841    #[test]
842    fn build_index_hashes_files() {
843        let dir = std::env::temp_dir().join(format!("flatland-assets-test-{}", std::process::id()));
844        let _ = fs::remove_dir_all(&dir);
845        fs::create_dir_all(dir.join("cells")).expect("dir");
846        fs::write(dir.join("manifest.yaml"), "version: 1\n").expect("write");
847        fs::write(dir.join("cells/a.png"), b"png").expect("write");
848        let index = build_bundle_index(&dir, 3, "2026-01-01T00:00:00Z").expect("index");
849        assert_eq!(index.publish_rev, 3);
850        assert_eq!(index.files.len(), 2);
851        let _ = fs::remove_dir_all(&dir);
852    }
853
854    #[test]
855    fn build_client_index_includes_paperdoll_prefix() {
856        let root = std::env::temp_dir().join(format!(
857            "flatland-assets-paperdoll-{}",
858            std::process::id()
859        ));
860        let sprites = root.join("sprites");
861        let paperdoll = root.join("paperdoll");
862        let _ = fs::remove_dir_all(&root);
863        fs::create_dir_all(sprites.join("cells")).unwrap();
864        fs::create_dir_all(paperdoll.join("skins")).unwrap();
865        fs::write(sprites.join("manifest.yaml"), "version: 1\n").unwrap();
866        fs::write(paperdoll.join("skins/hero.yaml"), "skin: {}\n").unwrap();
867        let sources = ClientBundleSources {
868            sprites_dir: &sprites,
869            paperdoll_dir: Some(&paperdoll),
870            ..ClientBundleSources::empty()
871        };
872        let index = build_client_bundle_index(&sources, 9, "2026-01-01T00:00:00Z").unwrap();
873        assert!(index.files.contains_key("manifest.yaml"));
874        assert!(index.files.contains_key("paperdoll/skins/hero.yaml"));
875        assert_eq!(
876            resolve_bundle_file_path(&sources, "paperdoll/skins/hero.yaml"),
877            paperdoll.join("skins/hero.yaml")
878        );
879        let _ = fs::remove_dir_all(&root);
880    }
881
882    #[test]
883    fn needs_sync_false_when_repo_matches_server_rev() {
884        if should_prefer_repo_assets()
885            && read_repo_publish_rev().is_some()
886            && find_repo_sprites_dir().is_some()
887        {
888            let rev = read_repo_publish_rev().unwrap();
889            assert!(!needs_asset_sync(rev));
890        }
891    }
892
893    #[test]
894    fn repo_outranks_stale_synced_cache() {
895        assert!(repo_sprites_outrank_cache(Some(85), Some(75)));
896        assert!(repo_sprites_outrank_cache(Some(75), Some(75)));
897        assert!(!repo_sprites_outrank_cache(Some(70), Some(75)));
898        assert!(repo_sprites_outrank_cache(Some(85), None));
899        assert!(!repo_sprites_outrank_cache(None, Some(75)));
900    }
901
902    #[test]
903    fn prefer_repo_only_from_cargo_target_or_env() {
904        // Running under cargo test → exe path contains `target`.
905        assert!(should_prefer_repo_assets() || std::env::var_os("FLATLAND_FORCE_ASSET_SYNC").is_some());
906    }
907
908    #[test]
909    fn build_client_index_includes_player_presentation() {
910        let root = std::env::temp_dir().join(format!(
911            "flatland-assets-pres-{}",
912            std::process::id()
913        ));
914        let sprites = root.join("sprites");
915        let pres = root.join("player-presentation.yaml");
916        let _ = fs::remove_dir_all(&root);
917        fs::create_dir_all(&sprites).unwrap();
918        fs::write(sprites.join("manifest.yaml"), "version: 1\n").unwrap();
919        fs::write(&pres, "player_presentation:\n  paperdoll_ref: hero\n").unwrap();
920        let sources = ClientBundleSources {
921            sprites_dir: &sprites,
922            player_presentation: Some(&pres),
923            ..ClientBundleSources::empty()
924        };
925        let index = build_client_bundle_index(&sources, 11, "2026-01-01T00:00:00Z").unwrap();
926        assert!(index.files.contains_key("player-presentation.yaml"));
927        assert_eq!(
928            resolve_bundle_file_path(&sources, "player-presentation.yaml"),
929            pres
930        );
931        let _ = fs::remove_dir_all(&root);
932    }
933
934    #[test]
935    fn verify_rev_dir_detects_missing_and_corrupt() {
936        let dir = std::env::temp_dir().join(format!(
937            "flatland-assets-verify-{}",
938            std::process::id()
939        ));
940        let _ = fs::remove_dir_all(&dir);
941        fs::create_dir_all(&dir).unwrap();
942        fs::write(dir.join("manifest.yaml"), "version: 1\n").unwrap();
943        let index = build_bundle_index(&dir, 1, "2026-01-01T00:00:00Z").unwrap();
944        assert!(verify_rev_dir(&dir, &index).unwrap());
945        fs::write(dir.join("extra.png"), b"x").unwrap();
946        // Extra files are fine — index is the contract.
947        assert!(verify_rev_dir(&dir, &index).unwrap());
948        fs::write(dir.join("manifest.yaml"), "tampered\n").unwrap();
949        assert!(!verify_rev_dir(&dir, &index).unwrap());
950        fs::remove_file(dir.join("manifest.yaml")).unwrap();
951        assert!(!verify_rev_dir(&dir, &index).unwrap());
952        let _ = fs::remove_dir_all(&dir);
953    }
954
955    #[test]
956    fn partial_download_path_keeps_full_name() {
957        let dest = PathBuf::from("/tmp/cells/hero.png");
958        let tmp = dest.with_file_name(format!(
959            "{}.partial",
960            dest.file_name().and_then(|n| n.to_str()).unwrap()
961        ));
962        assert_eq!(tmp, PathBuf::from("/tmp/cells/hero.png.partial"));
963    }
964}