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