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