Skip to main content

flatland_client_lib/
assets.rs

1//! Gfx sprite bundle sync for installed clients (Firebase Storage / GCS).
2
3use std::collections::BTreeMap;
4use std::io::Read;
5use std::path::{Path, PathBuf};
6
7use serde::{Deserialize, Serialize};
8use sha2::{Digest, Sha256};
9
10pub const DEFAULT_FIREBASE_BUCKET: &str = "flatland-8911e.appspot.com";
11pub const ASSETS_INDEX_OBJECT: &str = "flatland3/client-assets/latest.json";
12
13#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
14pub struct AssetFileEntry {
15    pub sha256: String,
16    pub size: u64,
17}
18
19#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
20pub struct AssetBundleIndex {
21    /// Stable revision from `assets/.content-publish.json` (not process-local `content_rev`).
22    pub publish_rev: u64,
23    pub published_at: String,
24    pub files: BTreeMap<String, AssetFileEntry>,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize, Default)]
28pub struct LocalAssetState {
29    pub publish_rev: u64,
30    pub sprites_dir: PathBuf,
31}
32
33pub fn assets_root_dir() -> anyhow::Result<PathBuf> {
34    let base = dirs::home_dir().ok_or_else(|| anyhow::anyhow!("home directory not found"))?;
35    Ok(base.join(".flatland3").join("assets"))
36}
37
38pub fn local_state_path() -> anyhow::Result<PathBuf> {
39    Ok(assets_root_dir()?.join("state.json"))
40}
41
42pub fn read_local_state() -> Option<LocalAssetState> {
43    let path = local_state_path().ok()?;
44    let bytes = std::fs::read(path).ok()?;
45    serde_json::from_slice(&bytes).ok()
46}
47
48pub fn write_local_state(state: &LocalAssetState) -> anyhow::Result<()> {
49    let path = local_state_path()?;
50    if let Some(parent) = path.parent() {
51        std::fs::create_dir_all(parent)?;
52    }
53    std::fs::write(path, serde_json::to_vec_pretty(state)?)?;
54    Ok(())
55}
56
57pub fn sprites_dir_for_rev(publish_rev: u64) -> anyhow::Result<PathBuf> {
58    Ok(assets_root_dir()?.join(format!("rev-{publish_rev}")))
59}
60
61pub fn current_symlink_path() -> anyhow::Result<PathBuf> {
62    Ok(assets_root_dir()?.join("current"))
63}
64
65pub fn activate_rev_dir(rev_dir: &Path) -> anyhow::Result<()> {
66    let link = current_symlink_path()?;
67    if link.exists() {
68        std::fs::remove_file(&link).or_else(|_| std::fs::remove_dir_all(&link))?;
69    }
70    #[cfg(unix)]
71    {
72        std::os::unix::fs::symlink(rev_dir, &link)?;
73    }
74    #[cfg(not(unix))]
75    {
76        // Windows: record the active rev path beside `current` (no symlink required).
77        let marker = link.with_extension("path");
78        std::fs::write(&marker, rev_dir.to_string_lossy().as_bytes())?;
79        let _ = std::fs::create_dir_all(&link);
80        let _ = std::fs::copy(rev_dir.join("manifest.yaml"), link.join("manifest.yaml"));
81    }
82    Ok(())
83}
84
85pub fn find_repo_sprites_dir() -> Option<PathBuf> {
86    let mut dir = std::env::current_dir().ok()?;
87    for _ in 0..8 {
88        let candidate = dir.join("assets/gfx/sprites");
89        if candidate.join("manifest.yaml").is_file() {
90            return Some(candidate);
91        }
92        if !dir.pop() {
93            break;
94        }
95    }
96    None
97}
98
99/// True when the checkout sprites should win over a synced `~/.flatland3` cache.
100pub(crate) fn repo_sprites_outrank_cache(repo_rev: Option<u64>, local_rev: Option<u64>) -> bool {
101    match (repo_rev, local_rev) {
102        (Some(rr), Some(lr)) => rr >= lr,
103        (Some(_), None) => true,
104        _ => false,
105    }
106}
107
108/// Prefer the repo checkout when it is at least as new as the synced cache.
109///
110/// Local play often has a stale `~/.flatland3/assets/rev-N` from an older sync.
111/// `needs_asset_sync` already treats a matching repo as up to date, but loading
112/// still went through the stale cache — so new sheets (e.g. lodging) fell back
113/// to `other.default` after publish.
114pub fn resolve_sprites_dir() -> Option<PathBuf> {
115    if let Ok(p) = std::env::var("FLATLAND_SPRITES_DIR") {
116        let path = PathBuf::from(p);
117        if path.join("manifest.yaml").is_file() {
118            return Some(path);
119        }
120    }
121
122    let repo = find_repo_sprites_dir();
123    let repo_rev = read_repo_publish_rev();
124    let local = read_local_state().filter(|s| s.sprites_dir.join("manifest.yaml").is_file());
125    let local_rev = local.as_ref().map(|s| s.publish_rev);
126
127    if repo_sprites_outrank_cache(repo_rev, local_rev) {
128        if let Some(repo_dir) = repo {
129            return Some(repo_dir);
130        }
131    }
132    if let Some(local) = local {
133        return Some(local.sprites_dir);
134    }
135    if let Some(repo_dir) = repo {
136        return Some(repo_dir);
137    }
138
139    let current = current_symlink_path().ok()?;
140    if current.join("manifest.yaml").is_file() {
141        return Some(current);
142    }
143    None
144}
145
146pub fn assets_index_url() -> String {
147    std::env::var("FLATLAND_ASSETS_INDEX_URL").unwrap_or_else(|_| {
148        firebase_download_url(DEFAULT_FIREBASE_BUCKET, ASSETS_INDEX_OBJECT)
149    })
150}
151
152pub fn firebase_download_url(bucket: &str, object_path: &str) -> String {
153    let encoded = urlencoding_encode(object_path);
154    format!(
155        "https://firebasestorage.googleapis.com/v0/b/{bucket}/o/{encoded}?alt=media"
156    )
157}
158
159pub fn assets_storage_prefix() -> String {
160    std::env::var("FLATLAND_ASSETS_PREFIX")
161        .unwrap_or_else(|_| "flatland3/client-assets".to_string())
162}
163
164pub fn firebase_object_path_for_prefix(prefix: &str, publish_rev: u64, relative: &str) -> String {
165    format!("{prefix}/rev-{publish_rev}/{relative}")
166}
167
168pub fn firebase_object_path(publish_rev: u64, relative: &str) -> String {
169    firebase_object_path_for_prefix(&assets_storage_prefix(), publish_rev, relative)
170}
171
172fn urlencoding_encode(path: &str) -> String {
173    path.bytes()
174        .map(|b| match b {
175            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
176                (b as char).to_string()
177            }
178            _ => format!("%{b:02X}"),
179        })
180        .collect()
181}
182
183pub fn sha256_file(path: &Path) -> anyhow::Result<String> {
184    let mut file = std::fs::File::open(path)?;
185    let mut hasher = Sha256::new();
186    let mut buf = [0u8; 8192];
187    loop {
188        let n = file.read(&mut buf)?;
189        if n == 0 {
190            break;
191        }
192        hasher.update(&buf[..n]);
193    }
194    Ok(hex::encode(hasher.finalize()))
195}
196
197/// Walk `assets/gfx/sprites` and build a publish manifest (relative paths → hash).
198pub fn build_bundle_index(sprites_dir: &Path, publish_rev: u64, published_at: &str) -> anyhow::Result<AssetBundleIndex> {
199    build_client_bundle_index(sprites_dir, None, publish_rev, published_at)
200}
201
202/// Build the installed-client asset index: sprites at the rev root, optional
203/// `paperdoll/` tree (skeletons + skins) so clean installs can bake paperdolls.
204pub fn build_client_bundle_index(
205    sprites_dir: &Path,
206    paperdoll_dir: Option<&Path>,
207    publish_rev: u64,
208    published_at: &str,
209) -> anyhow::Result<AssetBundleIndex> {
210    let mut files = BTreeMap::new();
211    walk_asset_dir(sprites_dir, sprites_dir, "", &mut files)?;
212    if let Some(paperdoll) = paperdoll_dir {
213        if paperdoll.is_dir() {
214            walk_asset_dir(paperdoll, paperdoll, "paperdoll/", &mut files)?;
215        }
216    }
217    Ok(AssetBundleIndex {
218        publish_rev,
219        published_at: published_at.to_string(),
220        files,
221    })
222}
223
224/// Resolve on-disk path for a relative key in the client asset bundle.
225pub fn resolve_bundle_file_path(
226    sprites_dir: &Path,
227    paperdoll_dir: Option<&Path>,
228    rel: &str,
229) -> PathBuf {
230    if let Some(rest) = rel.strip_prefix("paperdoll/") {
231        if let Some(root) = paperdoll_dir {
232            return root.join(rest);
233        }
234    }
235    sprites_dir.join(rel)
236}
237
238/// Paperdoll root for installed / synced clients (`rev-N/paperdoll`) or repo checkout.
239pub fn resolve_paperdoll_dir() -> Option<PathBuf> {
240    if let Ok(p) = std::env::var("FLATLAND_PAPERDOLL_DIR") {
241        let path = PathBuf::from(p);
242        if path.is_dir() {
243            return Some(path);
244        }
245    }
246    if let Ok(assets) = std::env::var("FLATLAND_ASSETS") {
247        let path = PathBuf::from(assets).join("paperdoll");
248        if path.is_dir() {
249            return Some(path);
250        }
251    }
252    if let Some(sprites) = resolve_sprites_dir() {
253        let path = sprites.join("paperdoll");
254        if path.is_dir() {
255            return Some(path);
256        }
257    }
258    let mut dir = std::env::current_dir().ok()?;
259    for _ in 0..8 {
260        let candidate = dir.join("assets/paperdoll");
261        if candidate.is_dir() {
262            return Some(candidate);
263        }
264        if !dir.pop() {
265            break;
266        }
267    }
268    None
269}
270
271fn walk_asset_dir(
272    root: &Path,
273    dir: &Path,
274    prefix: &str,
275    files: &mut BTreeMap<String, AssetFileEntry>,
276) -> anyhow::Result<()> {
277    for entry in std::fs::read_dir(dir)? {
278        let entry = entry?;
279        let path = entry.path();
280        if path.is_dir() {
281            let name = entry.file_name();
282            if name == "previews" {
283                continue;
284            }
285            walk_asset_dir(root, &path, prefix, files)?;
286            continue;
287        }
288        let rel = path
289            .strip_prefix(root)?
290            .to_string_lossy()
291            .replace('\\', "/");
292        if rel.starts_with('.') || rel.contains("/.") {
293            continue;
294        }
295        if rel.ends_with(".html") {
296            continue;
297        }
298        let meta = std::fs::metadata(&path)?;
299        files.insert(
300            format!("{prefix}{rel}"),
301            AssetFileEntry {
302                sha256: sha256_file(&path)?,
303                size: meta.len(),
304            },
305        );
306    }
307    Ok(())
308}
309
310pub struct AssetSyncOptions {
311    pub index_url: String,
312    pub storage_prefix: String,
313    pub target_rev: Option<u64>,
314    pub quiet: bool,
315}
316
317/// Outcome of [`sync_assets`] for logging / HUD toasts.
318#[derive(Debug, Clone, Copy, PartialEq, Eq)]
319pub enum AssetSyncKind {
320    /// Downloaded or verified files against remote `latest.json`.
321    Remote,
322    /// Remote index missing; reused `~/.flatland3` cache.
323    LocalCacheNoRemote,
324    /// Remote index missing; using repo `assets/gfx/sprites` (dev checkout).
325    RepoDevNoRemote,
326}
327
328#[derive(Debug, Clone)]
329pub struct AssetSyncResult {
330    pub state: LocalAssetState,
331    pub kind: AssetSyncKind,
332}
333
334impl Default for AssetSyncOptions {
335    fn default() -> Self {
336        Self {
337            index_url: assets_index_url(),
338            storage_prefix: assets_storage_prefix(),
339            target_rev: None,
340            quiet: false,
341        }
342    }
343}
344
345pub async fn sync_assets(opts: AssetSyncOptions) -> anyhow::Result<AssetSyncResult> {
346    // Local checkout: prefer repo sprites and skip remote download so gfx play does not
347    // freeze on Firebase after login (no render loop until sync finishes).
348    if std::env::var_os("FLATLAND_FORCE_ASSET_SYNC").is_none() {
349        if let Some(repo_dir) = find_repo_sprites_dir() {
350            if let Some(publish_rev) = read_repo_publish_rev() {
351                if opts.target_rev.map_or(true, |t| publish_rev >= t) {
352                    if !opts.quiet {
353                        println!(
354                            "Using repo sprites at {} (rev {publish_rev}); set FLATLAND_FORCE_ASSET_SYNC=1 to pull remote.",
355                            repo_dir.display()
356                        );
357                    }
358                    return Ok(AssetSyncResult {
359                        state: LocalAssetState {
360                            publish_rev,
361                            sprites_dir: repo_dir,
362                        },
363                        kind: AssetSyncKind::RepoDevNoRemote,
364                    });
365                }
366            }
367        }
368    }
369
370    let client = reqwest::Client::builder()
371        .user_agent(format!("flatland-client-lib/{}", env!("CARGO_PKG_VERSION")))
372        .connect_timeout(std::time::Duration::from_secs(5))
373        .timeout(std::time::Duration::from_secs(30))
374        .build()?;
375    let response = client.get(&opts.index_url).send().await?;
376    if response.status() == reqwest::StatusCode::NOT_FOUND {
377        return sync_without_remote_index(opts);
378    }
379    let index: AssetBundleIndex = response.error_for_status()?.json().await?;
380    sync_from_index(&client, opts, index).await
381}
382
383fn sync_without_remote_index(opts: AssetSyncOptions) -> anyhow::Result<AssetSyncResult> {
384    if let Some(local) = read_local_state() {
385        if local.sprites_dir.join("manifest.yaml").is_file() {
386            if !opts.quiet {
387                println!(
388                    "Remote latest.json not found; using cached publish rev {}.",
389                    local.publish_rev
390                );
391            }
392            return Ok(AssetSyncResult {
393                state: local,
394                kind: AssetSyncKind::LocalCacheNoRemote,
395            });
396        }
397    }
398    if let Some(repo) = find_repo_sprites_dir() {
399        let publish_rev = read_repo_publish_rev().unwrap_or(0);
400        if !opts.quiet {
401            println!(
402                "Remote latest.json not found; using repo sprites at {} (rev {publish_rev}).",
403                repo.display()
404            );
405        }
406        return Ok(AssetSyncResult {
407            state: LocalAssetState {
408                publish_rev,
409                sprites_dir: repo,
410            },
411            kind: AssetSyncKind::RepoDevNoRemote,
412        });
413    }
414    anyhow::bail!(
415        "Gfx asset bundle not published yet (HTTP 404 on latest.json). \
416         Server admin: flatland-admin content publish"
417    );
418}
419
420async fn sync_from_index(
421    client: &reqwest::Client,
422    opts: AssetSyncOptions,
423    index: AssetBundleIndex,
424) -> anyhow::Result<AssetSyncResult> {
425    let publish_rev = opts.target_rev.unwrap_or(index.publish_rev);
426    if publish_rev != index.publish_rev {
427        anyhow::bail!(
428            "requested publish rev {publish_rev} but remote latest is {}",
429            index.publish_rev
430        );
431    }
432
433    if let Some(local) = read_local_state() {
434        if local.publish_rev == publish_rev && local.sprites_dir.join("manifest.yaml").is_file() {
435            if verify_rev_dir(&local.sprites_dir, &index)? {
436                if !opts.quiet {
437                    println!("Assets up to date (publish rev {publish_rev}).");
438                }
439                return Ok(AssetSyncResult {
440                    state: local,
441                    kind: AssetSyncKind::Remote,
442                });
443            }
444        }
445    }
446
447    let rev_dir = sprites_dir_for_rev(publish_rev)?;
448    std::fs::create_dir_all(&rev_dir)?;
449
450    let total = index.files.len();
451    let mut done = 0usize;
452    for (rel, entry) in &index.files {
453        let dest = rev_dir.join(rel);
454        if dest.is_file() {
455            if sha256_file(&dest)? == entry.sha256 {
456                done += 1;
457                continue;
458            }
459        }
460        if let Some(parent) = dest.parent() {
461            std::fs::create_dir_all(parent)?;
462        }
463        let url = firebase_download_url(
464            DEFAULT_FIREBASE_BUCKET,
465            &firebase_object_path_for_prefix(&opts.storage_prefix, publish_rev, rel),
466        );
467        let bytes = client.get(&url).send().await?.error_for_status()?.bytes().await?;
468        if sha256_bytes(&bytes) != entry.sha256 {
469            anyhow::bail!("hash mismatch for {rel} after download");
470        }
471        std::fs::write(&dest, &bytes)?;
472        done += 1;
473        if !opts.quiet {
474            println!("  [{done}/{total}] {rel}");
475        }
476    }
477
478    activate_rev_dir(&rev_dir)?;
479    let state = LocalAssetState {
480        publish_rev,
481        sprites_dir: rev_dir,
482    };
483    write_local_state(&state)?;
484    if !opts.quiet {
485        println!(
486            "Synced publish rev {publish_rev} → {}",
487            state.sprites_dir.display()
488        );
489    }
490    Ok(AssetSyncResult {
491        state,
492        kind: AssetSyncKind::Remote,
493    })
494}
495
496pub fn needs_asset_sync(server_publish_rev: u64) -> bool {
497    if server_publish_rev == 0 {
498        return false;
499    }
500    if let Some(local) = read_local_state() {
501        if local.publish_rev >= server_publish_rev
502            && local.sprites_dir.join("manifest.yaml").is_file()
503        {
504            return false;
505        }
506    }
507    if let Some(repo_rev) = read_repo_publish_rev() {
508        if repo_rev >= server_publish_rev && find_repo_sprites_dir().is_some() {
509            return false;
510        }
511    }
512    resolve_sprites_dir().is_none()
513}
514
515/// Publish rev from `assets/.content-publish.json` when running from a dev checkout.
516pub fn read_repo_publish_rev() -> Option<u64> {
517    let sprites = find_repo_sprites_dir()?;
518    let mut dir = sprites.as_path();
519    for _ in 0..8 {
520        let marker = dir.join(".content-publish.json");
521        if marker.is_file() {
522            #[derive(Deserialize)]
523            struct Marker {
524                rev: u64,
525            }
526            let text = std::fs::read_to_string(marker).ok()?;
527            let m: Marker = serde_json::from_str(&text).ok()?;
528            return Some(m.rev);
529        }
530        dir = dir.parent()?;
531    }
532    None
533}
534
535fn verify_rev_dir(dir: &Path, index: &AssetBundleIndex) -> anyhow::Result<bool> {
536    for (rel, entry) in &index.files {
537        let path = dir.join(rel);
538        if !path.is_file() {
539            return Ok(false);
540        }
541        if sha256_file(&path)? != entry.sha256 {
542            return Ok(false);
543        }
544    }
545    Ok(true)
546}
547
548fn sha256_bytes(bytes: &[u8]) -> String {
549    let mut hasher = Sha256::new();
550    hasher.update(bytes);
551    hex::encode(hasher.finalize())
552}
553
554#[cfg(test)]
555mod tests {
556    use super::*;
557    use std::fs;
558
559    #[test]
560    fn firebase_url_encodes_slashes() {
561        let url = firebase_download_url("bucket", "flatland3/client-assets/latest.json");
562        assert!(url.contains("%2F"));
563        assert!(url.contains("alt=media"));
564    }
565
566    #[test]
567    fn build_index_hashes_files() {
568        let dir = std::env::temp_dir().join(format!("flatland-assets-test-{}", std::process::id()));
569        let _ = fs::remove_dir_all(&dir);
570        fs::create_dir_all(dir.join("cells")).expect("dir");
571        fs::write(dir.join("manifest.yaml"), "version: 1\n").expect("write");
572        fs::write(dir.join("cells/a.png"), b"png").expect("write");
573        let index = build_bundle_index(&dir, 3, "2026-01-01T00:00:00Z").expect("index");
574        assert_eq!(index.publish_rev, 3);
575        assert_eq!(index.files.len(), 2);
576        let _ = fs::remove_dir_all(&dir);
577    }
578
579    #[test]
580    fn needs_sync_false_when_repo_matches_server_rev() {
581        if read_repo_publish_rev().is_some() && find_repo_sprites_dir().is_some() {
582            let rev = read_repo_publish_rev().unwrap();
583            assert!(!needs_asset_sync(rev));
584        }
585    }
586
587    #[test]
588    fn repo_outranks_stale_synced_cache() {
589        assert!(repo_sprites_outrank_cache(Some(85), Some(75)));
590        assert!(repo_sprites_outrank_cache(Some(75), Some(75)));
591        assert!(!repo_sprites_outrank_cache(Some(70), Some(75)));
592        assert!(repo_sprites_outrank_cache(Some(85), None));
593        assert!(!repo_sprites_outrank_cache(None, Some(75)));
594    }
595}