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!("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
220pub 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#[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 pub player_art_dir: Option<&'a Path>,
244 pub client_settings: Option<&'a Path>,
246 pub terrain_kinds: Option<&'a Path>,
248 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
266pub 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
334pub 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
369pub 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
383pub 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
405fn 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
414pub 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
466pub 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
487pub 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
525pub 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 pub force_remote: bool,
588}
589
590#[derive(Debug, Clone, Copy, PartialEq, Eq)]
592pub enum AssetSyncKind {
593 Remote,
595 LocalCacheNoRemote,
597 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 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 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 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 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
947pub 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 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 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}