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        std::fs::write(link.with_extension("path"), rev_dir)?;
77        std::fs::copy(
78            rev_dir.join("manifest.yaml"),
79            link.join("manifest.yaml"),
80        )
81        .ok();
82    }
83    Ok(())
84}
85
86pub fn find_repo_sprites_dir() -> Option<PathBuf> {
87    let mut dir = std::env::current_dir().ok()?;
88    for _ in 0..8 {
89        let candidate = dir.join("assets/gfx/sprites");
90        if candidate.join("manifest.yaml").is_file() {
91            return Some(candidate);
92        }
93        if !dir.pop() {
94            break;
95        }
96    }
97    None
98}
99
100/// True when the checkout sprites should win over a synced `~/.flatland3` cache.
101pub(crate) fn repo_sprites_outrank_cache(repo_rev: Option<u64>, local_rev: Option<u64>) -> bool {
102    match (repo_rev, local_rev) {
103        (Some(rr), Some(lr)) => rr >= lr,
104        (Some(_), None) => true,
105        _ => false,
106    }
107}
108
109/// Prefer the repo checkout when it is at least as new as the synced cache.
110///
111/// Local play often has a stale `~/.flatland3/assets/rev-N` from an older sync.
112/// `needs_asset_sync` already treats a matching repo as up to date, but loading
113/// still went through the stale cache — so new sheets (e.g. lodging) fell back
114/// to `other.default` after publish.
115pub fn resolve_sprites_dir() -> Option<PathBuf> {
116    if let Ok(p) = std::env::var("FLATLAND_SPRITES_DIR") {
117        let path = PathBuf::from(p);
118        if path.join("manifest.yaml").is_file() {
119            return Some(path);
120        }
121    }
122
123    let repo = find_repo_sprites_dir();
124    let repo_rev = read_repo_publish_rev();
125    let local = read_local_state().filter(|s| s.sprites_dir.join("manifest.yaml").is_file());
126    let local_rev = local.as_ref().map(|s| s.publish_rev);
127
128    if repo_sprites_outrank_cache(repo_rev, local_rev) {
129        if let Some(repo_dir) = repo {
130            return Some(repo_dir);
131        }
132    }
133    if let Some(local) = local {
134        return Some(local.sprites_dir);
135    }
136    if let Some(repo_dir) = repo {
137        return Some(repo_dir);
138    }
139
140    let current = current_symlink_path().ok()?;
141    if current.join("manifest.yaml").is_file() {
142        return Some(current);
143    }
144    None
145}
146
147pub fn assets_index_url() -> String {
148    std::env::var("FLATLAND_ASSETS_INDEX_URL").unwrap_or_else(|_| {
149        firebase_download_url(DEFAULT_FIREBASE_BUCKET, ASSETS_INDEX_OBJECT)
150    })
151}
152
153pub fn firebase_download_url(bucket: &str, object_path: &str) -> String {
154    let encoded = urlencoding_encode(object_path);
155    format!(
156        "https://firebasestorage.googleapis.com/v0/b/{bucket}/o/{encoded}?alt=media"
157    )
158}
159
160pub fn assets_storage_prefix() -> String {
161    std::env::var("FLATLAND_ASSETS_PREFIX")
162        .unwrap_or_else(|_| "flatland3/client-assets".to_string())
163}
164
165pub fn firebase_object_path_for_prefix(prefix: &str, publish_rev: u64, relative: &str) -> String {
166    format!("{prefix}/rev-{publish_rev}/{relative}")
167}
168
169pub fn firebase_object_path(publish_rev: u64, relative: &str) -> String {
170    firebase_object_path_for_prefix(&assets_storage_prefix(), publish_rev, relative)
171}
172
173fn urlencoding_encode(path: &str) -> String {
174    path.bytes()
175        .map(|b| match b {
176            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
177                (b as char).to_string()
178            }
179            _ => format!("%{b:02X}"),
180        })
181        .collect()
182}
183
184pub fn sha256_file(path: &Path) -> anyhow::Result<String> {
185    let mut file = std::fs::File::open(path)?;
186    let mut hasher = Sha256::new();
187    let mut buf = [0u8; 8192];
188    loop {
189        let n = file.read(&mut buf)?;
190        if n == 0 {
191            break;
192        }
193        hasher.update(&buf[..n]);
194    }
195    Ok(hex::encode(hasher.finalize()))
196}
197
198/// Walk `assets/gfx/sprites` and build a publish manifest (relative paths → hash).
199pub fn build_bundle_index(sprites_dir: &Path, publish_rev: u64, published_at: &str) -> anyhow::Result<AssetBundleIndex> {
200    let mut files = BTreeMap::new();
201    walk_sprites_dir(sprites_dir, sprites_dir, &mut files)?;
202    Ok(AssetBundleIndex {
203        publish_rev,
204        published_at: published_at.to_string(),
205        files,
206    })
207}
208
209fn walk_sprites_dir(
210    root: &Path,
211    dir: &Path,
212    files: &mut BTreeMap<String, AssetFileEntry>,
213) -> anyhow::Result<()> {
214    for entry in std::fs::read_dir(dir)? {
215        let entry = entry?;
216        let path = entry.path();
217        if path.is_dir() {
218            let name = entry.file_name();
219            if name == "previews" {
220                continue;
221            }
222            walk_sprites_dir(root, &path, files)?;
223            continue;
224        }
225        let rel = path
226            .strip_prefix(root)?
227            .to_string_lossy()
228            .replace('\\', "/");
229        if rel.starts_with('.') || rel.contains("/.") {
230            continue;
231        }
232        if rel.ends_with(".html") {
233            continue;
234        }
235        let meta = std::fs::metadata(&path)?;
236        files.insert(
237            rel,
238            AssetFileEntry {
239                sha256: sha256_file(&path)?,
240                size: meta.len(),
241            },
242        );
243    }
244    Ok(())
245}
246
247pub struct AssetSyncOptions {
248    pub index_url: String,
249    pub storage_prefix: String,
250    pub target_rev: Option<u64>,
251    pub quiet: bool,
252}
253
254/// Outcome of [`sync_assets`] for logging / HUD toasts.
255#[derive(Debug, Clone, Copy, PartialEq, Eq)]
256pub enum AssetSyncKind {
257    /// Downloaded or verified files against remote `latest.json`.
258    Remote,
259    /// Remote index missing; reused `~/.flatland3` cache.
260    LocalCacheNoRemote,
261    /// Remote index missing; using repo `assets/gfx/sprites` (dev checkout).
262    RepoDevNoRemote,
263}
264
265#[derive(Debug, Clone)]
266pub struct AssetSyncResult {
267    pub state: LocalAssetState,
268    pub kind: AssetSyncKind,
269}
270
271impl Default for AssetSyncOptions {
272    fn default() -> Self {
273        Self {
274            index_url: assets_index_url(),
275            storage_prefix: assets_storage_prefix(),
276            target_rev: None,
277            quiet: false,
278        }
279    }
280}
281
282pub async fn sync_assets(opts: AssetSyncOptions) -> anyhow::Result<AssetSyncResult> {
283    let client = reqwest::Client::builder()
284        .user_agent(format!("flatland-client-lib/{}", env!("CARGO_PKG_VERSION")))
285        .build()?;
286    let response = client.get(&opts.index_url).send().await?;
287    if response.status() == reqwest::StatusCode::NOT_FOUND {
288        return sync_without_remote_index(opts);
289    }
290    let index: AssetBundleIndex = response.error_for_status()?.json().await?;
291    sync_from_index(&client, opts, index).await
292}
293
294fn sync_without_remote_index(opts: AssetSyncOptions) -> anyhow::Result<AssetSyncResult> {
295    if let Some(local) = read_local_state() {
296        if local.sprites_dir.join("manifest.yaml").is_file() {
297            if !opts.quiet {
298                println!(
299                    "Remote latest.json not found; using cached publish rev {}.",
300                    local.publish_rev
301                );
302            }
303            return Ok(AssetSyncResult {
304                state: local,
305                kind: AssetSyncKind::LocalCacheNoRemote,
306            });
307        }
308    }
309    if let Some(repo) = find_repo_sprites_dir() {
310        let publish_rev = read_repo_publish_rev().unwrap_or(0);
311        if !opts.quiet {
312            println!(
313                "Remote latest.json not found; using repo sprites at {} (rev {publish_rev}).",
314                repo.display()
315            );
316        }
317        return Ok(AssetSyncResult {
318            state: LocalAssetState {
319                publish_rev,
320                sprites_dir: repo,
321            },
322            kind: AssetSyncKind::RepoDevNoRemote,
323        });
324    }
325    anyhow::bail!(
326        "Gfx asset bundle not published yet (HTTP 404 on latest.json). \
327         Server admin: flatland-admin content publish"
328    );
329}
330
331async fn sync_from_index(
332    client: &reqwest::Client,
333    opts: AssetSyncOptions,
334    index: AssetBundleIndex,
335) -> anyhow::Result<AssetSyncResult> {
336    let publish_rev = opts.target_rev.unwrap_or(index.publish_rev);
337    if publish_rev != index.publish_rev {
338        anyhow::bail!(
339            "requested publish rev {publish_rev} but remote latest is {}",
340            index.publish_rev
341        );
342    }
343
344    if let Some(local) = read_local_state() {
345        if local.publish_rev == publish_rev && local.sprites_dir.join("manifest.yaml").is_file() {
346            if verify_rev_dir(&local.sprites_dir, &index)? {
347                if !opts.quiet {
348                    println!("Assets up to date (publish rev {publish_rev}).");
349                }
350                return Ok(AssetSyncResult {
351                    state: local,
352                    kind: AssetSyncKind::Remote,
353                });
354            }
355        }
356    }
357
358    let rev_dir = sprites_dir_for_rev(publish_rev)?;
359    std::fs::create_dir_all(&rev_dir)?;
360
361    let total = index.files.len();
362    let mut done = 0usize;
363    for (rel, entry) in &index.files {
364        let dest = rev_dir.join(rel);
365        if dest.is_file() {
366            if sha256_file(&dest)? == entry.sha256 {
367                done += 1;
368                continue;
369            }
370        }
371        if let Some(parent) = dest.parent() {
372            std::fs::create_dir_all(parent)?;
373        }
374        let url = firebase_download_url(
375            DEFAULT_FIREBASE_BUCKET,
376            &firebase_object_path_for_prefix(&opts.storage_prefix, publish_rev, rel),
377        );
378        let bytes = client.get(&url).send().await?.error_for_status()?.bytes().await?;
379        if sha256_bytes(&bytes) != entry.sha256 {
380            anyhow::bail!("hash mismatch for {rel} after download");
381        }
382        std::fs::write(&dest, &bytes)?;
383        done += 1;
384        if !opts.quiet {
385            println!("  [{done}/{total}] {rel}");
386        }
387    }
388
389    activate_rev_dir(&rev_dir)?;
390    let state = LocalAssetState {
391        publish_rev,
392        sprites_dir: rev_dir,
393    };
394    write_local_state(&state)?;
395    if !opts.quiet {
396        println!(
397            "Synced publish rev {publish_rev} → {}",
398            state.sprites_dir.display()
399        );
400    }
401    Ok(AssetSyncResult {
402        state,
403        kind: AssetSyncKind::Remote,
404    })
405}
406
407pub fn needs_asset_sync(server_publish_rev: u64) -> bool {
408    if server_publish_rev == 0 {
409        return false;
410    }
411    if let Some(local) = read_local_state() {
412        if local.publish_rev >= server_publish_rev
413            && local.sprites_dir.join("manifest.yaml").is_file()
414        {
415            return false;
416        }
417    }
418    if let Some(repo_rev) = read_repo_publish_rev() {
419        if repo_rev >= server_publish_rev && find_repo_sprites_dir().is_some() {
420            return false;
421        }
422    }
423    resolve_sprites_dir().is_none()
424}
425
426/// Publish rev from `assets/.content-publish.json` when running from a dev checkout.
427pub fn read_repo_publish_rev() -> Option<u64> {
428    let sprites = find_repo_sprites_dir()?;
429    let mut dir = sprites.as_path();
430    for _ in 0..8 {
431        let marker = dir.join(".content-publish.json");
432        if marker.is_file() {
433            #[derive(Deserialize)]
434            struct Marker {
435                rev: u64,
436            }
437            let text = std::fs::read_to_string(marker).ok()?;
438            let m: Marker = serde_json::from_str(&text).ok()?;
439            return Some(m.rev);
440        }
441        dir = dir.parent()?;
442    }
443    None
444}
445
446fn verify_rev_dir(dir: &Path, index: &AssetBundleIndex) -> anyhow::Result<bool> {
447    for (rel, entry) in &index.files {
448        let path = dir.join(rel);
449        if !path.is_file() {
450            return Ok(false);
451        }
452        if sha256_file(&path)? != entry.sha256 {
453            return Ok(false);
454        }
455    }
456    Ok(true)
457}
458
459fn sha256_bytes(bytes: &[u8]) -> String {
460    let mut hasher = Sha256::new();
461    hasher.update(bytes);
462    hex::encode(hasher.finalize())
463}
464
465#[cfg(test)]
466mod tests {
467    use super::*;
468    use std::fs;
469
470    #[test]
471    fn firebase_url_encodes_slashes() {
472        let url = firebase_download_url("bucket", "flatland3/client-assets/latest.json");
473        assert!(url.contains("%2F"));
474        assert!(url.contains("alt=media"));
475    }
476
477    #[test]
478    fn build_index_hashes_files() {
479        let dir = std::env::temp_dir().join(format!("flatland-assets-test-{}", std::process::id()));
480        let _ = fs::remove_dir_all(&dir);
481        fs::create_dir_all(dir.join("cells")).expect("dir");
482        fs::write(dir.join("manifest.yaml"), "version: 1\n").expect("write");
483        fs::write(dir.join("cells/a.png"), b"png").expect("write");
484        let index = build_bundle_index(&dir, 3, "2026-01-01T00:00:00Z").expect("index");
485        assert_eq!(index.publish_rev, 3);
486        assert_eq!(index.files.len(), 2);
487        let _ = fs::remove_dir_all(&dir);
488    }
489
490    #[test]
491    fn needs_sync_false_when_repo_matches_server_rev() {
492        if read_repo_publish_rev().is_some() && find_repo_sprites_dir().is_some() {
493            let rev = read_repo_publish_rev().unwrap();
494            assert!(!needs_asset_sync(rev));
495        }
496    }
497
498    #[test]
499    fn repo_outranks_stale_synced_cache() {
500        assert!(repo_sprites_outrank_cache(Some(85), Some(75)));
501        assert!(repo_sprites_outrank_cache(Some(75), Some(75)));
502        assert!(!repo_sprites_outrank_cache(Some(70), Some(75)));
503        assert!(repo_sprites_outrank_cache(Some(85), None));
504        assert!(!repo_sprites_outrank_cache(None, Some(75)));
505    }
506}