1use 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
15const 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 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 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
107pub 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
122pub(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
131pub 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
222pub 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#[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 pub player_art_dir: Option<&'a Path>,
238 pub client_settings: Option<&'a Path>,
240 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
257pub 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
316pub 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
346pub 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
360pub 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
382fn 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
391pub 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
429pub 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 pub force_remote: bool,
492}
493
494#[derive(Debug, Clone, Copy, PartialEq, Eq)]
496pub enum AssetSyncKind {
497 Remote,
499 LocalCacheNoRemote,
501 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 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 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 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 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
853pub 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 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 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}