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(crate) fn repo_sprites_outrank_cache(repo_rev: Option<u64>, local_rev: Option<u64>) -> bool {
109 match (repo_rev, local_rev) {
110 (Some(rr), Some(lr)) => rr >= lr,
111 (Some(_), None) => true,
112 _ => false,
113 }
114}
115
116pub fn resolve_sprites_dir() -> Option<PathBuf> {
123 if let Ok(p) = std::env::var("FLATLAND_SPRITES_DIR") {
124 let path = PathBuf::from(p);
125 if path.join("manifest.yaml").is_file() {
126 return Some(path);
127 }
128 }
129
130 let repo = find_repo_sprites_dir();
131 let repo_rev = read_repo_publish_rev();
132 let local = read_local_state().filter(|s| s.sprites_dir.join("manifest.yaml").is_file());
133 let local_rev = local.as_ref().map(|s| s.publish_rev);
134
135 if should_prefer_repo_assets() && repo_sprites_outrank_cache(repo_rev, local_rev) {
136 if let Some(repo_dir) = repo {
137 return Some(repo_dir);
138 }
139 }
140 if let Some(local) = local {
141 return Some(local.sprites_dir);
142 }
143 if let Some(repo_dir) = repo {
144 return Some(repo_dir);
145 }
146
147 let current = current_symlink_path().ok()?;
148 if current.join("manifest.yaml").is_file() {
149 return Some(current);
150 }
151 None
152}
153
154pub fn assets_index_url() -> String {
155 std::env::var("FLATLAND_ASSETS_INDEX_URL").unwrap_or_else(|_| {
156 firebase_download_url(DEFAULT_FIREBASE_BUCKET, ASSETS_INDEX_OBJECT)
157 })
158}
159
160pub fn firebase_download_url(bucket: &str, object_path: &str) -> String {
161 let encoded = urlencoding_encode(object_path);
162 format!(
163 "https://firebasestorage.googleapis.com/v0/b/{bucket}/o/{encoded}?alt=media"
164 )
165}
166
167pub fn assets_storage_prefix() -> String {
168 std::env::var("FLATLAND_ASSETS_PREFIX")
169 .unwrap_or_else(|_| "flatland3/client-assets".to_string())
170}
171
172pub fn firebase_object_path_for_prefix(prefix: &str, publish_rev: u64, relative: &str) -> String {
173 format!("{prefix}/rev-{publish_rev}/{relative}")
174}
175
176pub fn firebase_object_path(publish_rev: u64, relative: &str) -> String {
177 firebase_object_path_for_prefix(&assets_storage_prefix(), publish_rev, relative)
178}
179
180fn urlencoding_encode(path: &str) -> String {
181 path.bytes()
182 .map(|b| match b {
183 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
184 (b as char).to_string()
185 }
186 _ => format!("%{b:02X}"),
187 })
188 .collect()
189}
190
191pub fn sha256_file(path: &Path) -> anyhow::Result<String> {
192 let mut file = std::fs::File::open(path)?;
193 let mut hasher = Sha256::new();
194 let mut buf = [0u8; 8192];
195 loop {
196 let n = file.read(&mut buf)?;
197 if n == 0 {
198 break;
199 }
200 hasher.update(&buf[..n]);
201 }
202 Ok(hex::encode(hasher.finalize()))
203}
204
205pub fn build_bundle_index(sprites_dir: &Path, publish_rev: u64, published_at: &str) -> anyhow::Result<AssetBundleIndex> {
207 build_client_bundle_index(&ClientBundleSources {
208 sprites_dir,
209 ..ClientBundleSources::empty()
210 }, publish_rev, published_at)
211}
212
213#[derive(Debug, Clone, Copy)]
215pub struct ClientBundleSources<'a> {
216 pub sprites_dir: &'a Path,
217 pub paperdoll_dir: Option<&'a Path>,
218 pub player_presentation: Option<&'a Path>,
219 pub player_art_dir: Option<&'a Path>,
221 pub client_settings: Option<&'a Path>,
223 pub terrain_kinds: Option<&'a Path>,
225}
226
227impl<'a> ClientBundleSources<'a> {
228 pub fn empty() -> Self {
229 Self {
230 sprites_dir: Path::new(""),
231 paperdoll_dir: None,
232 player_presentation: None,
233 player_art_dir: None,
234 client_settings: None,
235 terrain_kinds: None,
236 }
237 }
238}
239
240pub fn build_client_bundle_index(
243 sources: &ClientBundleSources<'_>,
244 publish_rev: u64,
245 published_at: &str,
246) -> anyhow::Result<AssetBundleIndex> {
247 let mut files = BTreeMap::new();
248 walk_asset_dir(sources.sprites_dir, sources.sprites_dir, "", &mut files)?;
249 if let Some(paperdoll) = sources.paperdoll_dir {
250 if paperdoll.is_dir() {
251 walk_asset_dir(paperdoll, paperdoll, "paperdoll/", &mut files)?;
252 }
253 }
254 if let Some(player_art) = sources.player_art_dir {
255 if player_art.is_dir() {
256 walk_asset_dir(player_art, player_art, "player/", &mut files)?;
257 }
258 }
259 insert_single_file(&mut files, "player-presentation.yaml", sources.player_presentation)?;
260 insert_single_file(
261 &mut files,
262 "config/client-settings.yaml",
263 sources.client_settings,
264 )?;
265 insert_single_file(
266 &mut files,
267 "presentation/terrain-kinds.yaml",
268 sources.terrain_kinds,
269 )?;
270 Ok(AssetBundleIndex {
271 publish_rev,
272 published_at: published_at.to_string(),
273 files,
274 })
275}
276
277fn insert_single_file(
278 files: &mut BTreeMap<String, AssetFileEntry>,
279 key: &str,
280 path: Option<&Path>,
281) -> anyhow::Result<()> {
282 let Some(path) = path else {
283 return Ok(());
284 };
285 if !path.is_file() {
286 return Ok(());
287 }
288 let meta = std::fs::metadata(path)?;
289 files.insert(
290 key.to_string(),
291 AssetFileEntry {
292 sha256: sha256_file(path)?,
293 size: meta.len(),
294 },
295 );
296 Ok(())
297}
298
299pub fn resolve_bundle_file_path(sources: &ClientBundleSources<'_>, rel: &str) -> PathBuf {
301 if rel == "player-presentation.yaml" {
302 if let Some(path) = sources.player_presentation {
303 return path.to_path_buf();
304 }
305 }
306 if rel == "config/client-settings.yaml" {
307 if let Some(path) = sources.client_settings {
308 return path.to_path_buf();
309 }
310 }
311 if rel == "presentation/terrain-kinds.yaml" {
312 if let Some(path) = sources.terrain_kinds {
313 return path.to_path_buf();
314 }
315 }
316 if let Some(rest) = rel.strip_prefix("paperdoll/") {
317 if let Some(root) = sources.paperdoll_dir {
318 return root.join(rest);
319 }
320 }
321 if let Some(rest) = rel.strip_prefix("player/") {
322 if let Some(root) = sources.player_art_dir {
323 return root.join(rest);
324 }
325 }
326 sources.sprites_dir.join(rel)
327}
328
329pub fn synced_rev_dir() -> Option<PathBuf> {
331 if let Some(local) = read_local_state() {
332 if local.sprites_dir.join("manifest.yaml").is_file() {
333 return Some(local.sprites_dir);
334 }
335 }
336 let current = current_symlink_path().ok()?;
337 if current.join("manifest.yaml").is_file() {
338 return Some(current);
339 }
340 None
341}
342
343pub fn resolve_paperdoll_dir() -> Option<PathBuf> {
345 if let Ok(p) = std::env::var("FLATLAND_PAPERDOLL_DIR") {
346 let path = PathBuf::from(p);
347 if path.is_dir() {
348 return Some(path);
349 }
350 }
351 if let Ok(assets) = std::env::var("FLATLAND_ASSETS") {
352 let path = PathBuf::from(assets).join("paperdoll");
353 if path.is_dir() {
354 return Some(path);
355 }
356 }
357 if let Some(sprites) = resolve_sprites_dir() {
358 let path = sprites.join("paperdoll");
359 if path.is_dir() {
360 return Some(path);
361 }
362 }
363 if let Some(rev) = synced_rev_dir() {
364 let path = rev.join("paperdoll");
365 if path.is_dir() {
366 return Some(path);
367 }
368 }
369 let mut dir = std::env::current_dir().ok()?;
370 for _ in 0..8 {
371 let candidate = dir.join("assets/paperdoll");
372 if candidate.is_dir() {
373 return Some(candidate);
374 }
375 if !dir.pop() {
376 break;
377 }
378 }
379 None
380}
381
382pub fn should_prefer_repo_assets() -> bool {
387 if std::env::var_os("FLATLAND_FORCE_ASSET_SYNC").is_some() {
388 return false;
389 }
390 if std::env::var_os("FLATLAND_USE_REPO_ASSETS").is_some() {
391 return true;
392 }
393 let Ok(exe) = std::env::current_exe() else {
394 return false;
395 };
396 exe.components().any(|c| c.as_os_str() == "target") && find_repo_sprites_dir().is_some()
397}
398
399fn walk_asset_dir(
400 root: &Path,
401 dir: &Path,
402 prefix: &str,
403 files: &mut BTreeMap<String, AssetFileEntry>,
404) -> anyhow::Result<()> {
405 for entry in std::fs::read_dir(dir)? {
406 let entry = entry?;
407 let path = entry.path();
408 if path.is_dir() {
409 let name = entry.file_name();
410 if name == "previews" {
411 continue;
412 }
413 walk_asset_dir(root, &path, prefix, files)?;
414 continue;
415 }
416 let rel = path
417 .strip_prefix(root)?
418 .to_string_lossy()
419 .replace('\\', "/");
420 if rel.starts_with('.') || rel.contains("/.") {
421 continue;
422 }
423 if rel.ends_with(".html") {
424 continue;
425 }
426 let meta = std::fs::metadata(&path)?;
427 files.insert(
428 format!("{prefix}{rel}"),
429 AssetFileEntry {
430 sha256: sha256_file(&path)?,
431 size: meta.len(),
432 },
433 );
434 }
435 Ok(())
436}
437
438pub struct AssetSyncOptions {
439 pub index_url: String,
440 pub storage_prefix: String,
441 pub target_rev: Option<u64>,
442 pub quiet: bool,
443 pub force_remote: bool,
445}
446
447#[derive(Debug, Clone, Copy, PartialEq, Eq)]
449pub enum AssetSyncKind {
450 Remote,
452 LocalCacheNoRemote,
454 RepoDevNoRemote,
456}
457
458#[derive(Debug, Clone)]
459pub struct AssetSyncResult {
460 pub state: LocalAssetState,
461 pub kind: AssetSyncKind,
462}
463
464impl Default for AssetSyncOptions {
465 fn default() -> Self {
466 Self {
467 index_url: assets_index_url(),
468 storage_prefix: assets_storage_prefix(),
469 target_rev: None,
470 quiet: false,
471 force_remote: false,
472 }
473 }
474}
475
476pub async fn sync_assets(opts: AssetSyncOptions) -> anyhow::Result<AssetSyncResult> {
477 if !opts.force_remote && should_prefer_repo_assets() {
481 if let Some(repo_dir) = find_repo_sprites_dir() {
482 if let Some(publish_rev) = read_repo_publish_rev() {
483 if opts.target_rev.map_or(true, |t| publish_rev >= t) {
484 if !opts.quiet {
485 println!(
486 "Using repo sprites at {} (rev {publish_rev}); set FLATLAND_FORCE_ASSET_SYNC=1 to pull remote.",
487 repo_dir.display()
488 );
489 }
490 return Ok(AssetSyncResult {
491 state: LocalAssetState {
492 publish_rev,
493 sprites_dir: repo_dir,
494 },
495 kind: AssetSyncKind::RepoDevNoRemote,
496 });
497 }
498 }
499 }
500 }
501
502 let client = reqwest::Client::builder()
503 .user_agent(format!("flatland-client-lib/{}", env!("CARGO_PKG_VERSION")))
504 .connect_timeout(SYNC_CONNECT_TIMEOUT)
505 .timeout(SYNC_REQUEST_TIMEOUT)
506 .pool_max_idle_per_host(SYNC_CONCURRENCY)
507 .build()?;
508 let response = client.get(&opts.index_url).send().await?;
509 if response.status() == reqwest::StatusCode::NOT_FOUND {
510 return sync_without_remote_index(opts);
511 }
512 let index: AssetBundleIndex = response.error_for_status()?.json().await?;
513 if index.files.is_empty() {
514 anyhow::bail!("remote asset index is empty — refuse to sync");
515 }
516 if !index.files.contains_key("manifest.yaml") {
517 anyhow::bail!("remote asset index missing manifest.yaml");
518 }
519 sync_from_index(&client, opts, index).await
520}
521
522fn sync_without_remote_index(opts: AssetSyncOptions) -> anyhow::Result<AssetSyncResult> {
523 if let Some(local) = read_local_state() {
524 if local.sprites_dir.join("manifest.yaml").is_file() {
525 if !opts.quiet {
526 println!(
527 "Remote latest.json not found; using cached publish rev {}.",
528 local.publish_rev
529 );
530 }
531 return Ok(AssetSyncResult {
532 state: local,
533 kind: AssetSyncKind::LocalCacheNoRemote,
534 });
535 }
536 }
537 if let Some(repo) = find_repo_sprites_dir() {
538 let publish_rev = read_repo_publish_rev().unwrap_or(0);
539 if !opts.quiet {
540 println!(
541 "Remote latest.json not found; using repo sprites at {} (rev {publish_rev}).",
542 repo.display()
543 );
544 }
545 return Ok(AssetSyncResult {
546 state: LocalAssetState {
547 publish_rev,
548 sprites_dir: repo,
549 },
550 kind: AssetSyncKind::RepoDevNoRemote,
551 });
552 }
553 anyhow::bail!(
554 "Gfx asset bundle not published yet (HTTP 404 on latest.json). \
555 Server admin: flatland-admin content publish"
556 );
557}
558
559async fn sync_from_index(
560 client: &reqwest::Client,
561 opts: AssetSyncOptions,
562 index: AssetBundleIndex,
563) -> anyhow::Result<AssetSyncResult> {
564 let publish_rev = opts.target_rev.unwrap_or(index.publish_rev);
565 if publish_rev != index.publish_rev {
566 anyhow::bail!(
567 "requested publish rev {publish_rev} but remote latest is {}",
568 index.publish_rev
569 );
570 }
571
572 if let Some(local) = read_local_state() {
573 if local.publish_rev == publish_rev && local.sprites_dir.join("manifest.yaml").is_file() {
574 if verify_rev_dir(&local.sprites_dir, &index)? {
575 if !opts.quiet {
576 println!(
577 "Assets up to date (publish rev {publish_rev}, {} files).",
578 index.files.len()
579 );
580 }
581 return Ok(AssetSyncResult {
582 state: local,
583 kind: AssetSyncKind::Remote,
584 });
585 }
586 if !opts.quiet {
587 println!(
588 "Cached rev {publish_rev} incomplete or corrupt — re-syncing {} files…",
589 index.files.len()
590 );
591 }
592 }
593 }
594
595 let rev_dir = sprites_dir_for_rev(publish_rev)?;
596 std::fs::create_dir_all(&rev_dir)?;
597
598 let total = index.files.len();
599 let mut pending: Vec<(String, AssetFileEntry)> = Vec::new();
600 let mut already_ok = 0usize;
601 for (rel, entry) in &index.files {
602 let dest = rev_dir.join(rel);
603 if dest.is_file() && sha256_file(&dest)? == entry.sha256 {
604 already_ok += 1;
605 continue;
606 }
607 pending.push((rel.clone(), entry.clone()));
608 }
609
610 if !opts.quiet {
611 println!(
612 "Syncing publish rev {publish_rev}: {already_ok}/{total} present, {} to download…",
613 pending.len()
614 );
615 }
616
617 download_files_concurrent(client, &opts, publish_rev, &rev_dir, pending, opts.quiet).await?;
618
619 if !verify_rev_dir(&rev_dir, &index)? {
620 anyhow::bail!(
621 "asset sync verification failed for rev {publish_rev} after download — try again"
622 );
623 }
624
625 let index_path = rev_dir.join(".bundle-index.json");
627 let _ = std::fs::write(&index_path, serde_json::to_vec_pretty(&index)?);
628
629 activate_rev_dir(&rev_dir)?;
630 let state = LocalAssetState {
631 publish_rev,
632 sprites_dir: rev_dir,
633 };
634 write_local_state(&state)?;
635 if !opts.quiet {
636 let paperdoll_n = index
637 .files
638 .keys()
639 .filter(|k| k.starts_with("paperdoll/"))
640 .count();
641 println!(
642 "Synced publish rev {publish_rev} → {} ({} files, {paperdoll_n} paperdoll)",
643 state.sprites_dir.display(),
644 index.files.len()
645 );
646 }
647 Ok(AssetSyncResult {
648 state,
649 kind: AssetSyncKind::Remote,
650 })
651}
652
653async fn download_files_concurrent(
654 client: &reqwest::Client,
655 opts: &AssetSyncOptions,
656 publish_rev: u64,
657 rev_dir: &Path,
658 pending: Vec<(String, AssetFileEntry)>,
659 quiet: bool,
660) -> anyhow::Result<()> {
661 if pending.is_empty() {
662 return Ok(());
663 }
664 let total = pending.len();
665 let client = client.clone();
666 let prefix = opts.storage_prefix.clone();
667 let rev_dir = rev_dir.to_path_buf();
668 let pending = Arc::new(pending);
669 let done = Arc::new(std::sync::atomic::AtomicUsize::new(0));
670 let mut join_set = tokio::task::JoinSet::new();
671 let mut next = 0usize;
672
673 while next < pending.len() || !join_set.is_empty() {
674 while join_set.len() < SYNC_CONCURRENCY && next < pending.len() {
675 let (rel, entry) = pending[next].clone();
676 next += 1;
677 let client = client.clone();
678 let prefix = prefix.clone();
679 let rev_dir = rev_dir.clone();
680 let done = Arc::clone(&done);
681 join_set.spawn(async move {
682 download_one_with_retries(&client, &prefix, publish_rev, &rev_dir, &rel, &entry)
683 .await?;
684 let n = done.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
685 if !quiet && (n % 25 == 0 || n == total) {
686 println!(" [{n}/{total}] synced…");
687 }
688 Ok::<(), anyhow::Error>(())
689 });
690 }
691 if let Some(res) = join_set.join_next().await {
692 res.map_err(|e| anyhow::anyhow!("asset download task join: {e}"))??;
693 }
694 }
695 Ok(())
696}
697
698async fn download_one_with_retries(
699 client: &reqwest::Client,
700 prefix: &str,
701 publish_rev: u64,
702 rev_dir: &Path,
703 rel: &str,
704 entry: &AssetFileEntry,
705) -> anyhow::Result<()> {
706 let dest = rev_dir.join(rel);
707 if let Some(parent) = dest.parent() {
708 std::fs::create_dir_all(parent)?;
709 }
710 let url = firebase_download_url(
711 DEFAULT_FIREBASE_BUCKET,
712 &firebase_object_path_for_prefix(prefix, publish_rev, rel),
713 );
714 let mut last_err = None;
715 for attempt in 1..=SYNC_ATTEMPTS {
716 match download_one(client, &url, &dest, entry).await {
717 Ok(()) => return Ok(()),
718 Err(err) => {
719 last_err = Some(err);
720 if attempt < SYNC_ATTEMPTS {
721 let backoff = Duration::from_millis(200 * 2u64.pow(attempt - 1));
722 tokio::time::sleep(backoff).await;
723 }
724 }
725 }
726 }
727 Err(last_err.unwrap_or_else(|| anyhow::anyhow!("download failed for {rel}")))
728}
729
730async fn download_one(
731 client: &reqwest::Client,
732 url: &str,
733 dest: &Path,
734 entry: &AssetFileEntry,
735) -> anyhow::Result<()> {
736 let bytes = client
737 .get(url)
738 .send()
739 .await?
740 .error_for_status()?
741 .bytes()
742 .await?;
743 if bytes.len() as u64 != entry.size && entry.size > 0 {
744 if sha256_bytes(&bytes) != entry.sha256 {
746 anyhow::bail!(
747 "size/hash mismatch (got {} bytes, expected {} / {})",
748 bytes.len(),
749 entry.size,
750 entry.sha256
751 );
752 }
753 } else if sha256_bytes(&bytes) != entry.sha256 {
754 anyhow::bail!("hash mismatch after download");
755 }
756 let tmp = dest.with_file_name(format!(
759 "{}.partial",
760 dest.file_name()
761 .and_then(|n| n.to_str())
762 .unwrap_or("asset")
763 ));
764 std::fs::write(&tmp, &bytes)?;
765 std::fs::rename(&tmp, dest)?;
766 Ok(())
767}
768
769pub fn needs_asset_sync(server_publish_rev: u64) -> bool {
770 if server_publish_rev == 0 {
771 return false;
772 }
773 if let Some(local) = read_local_state() {
774 if local.publish_rev >= server_publish_rev
775 && local.sprites_dir.join("manifest.yaml").is_file()
776 {
777 return false;
778 }
779 }
780 if should_prefer_repo_assets() {
781 if let Some(repo_rev) = read_repo_publish_rev() {
782 if repo_rev >= server_publish_rev && find_repo_sprites_dir().is_some() {
783 return false;
784 }
785 }
786 }
787 resolve_sprites_dir().is_none()
788}
789
790pub fn read_repo_publish_rev() -> Option<u64> {
792 let sprites = find_repo_sprites_dir()?;
793 let mut dir = sprites.as_path();
794 for _ in 0..8 {
795 let marker = dir.join(".content-publish.json");
796 if marker.is_file() {
797 #[derive(Deserialize)]
798 struct Marker {
799 rev: u64,
800 }
801 let text = std::fs::read_to_string(marker).ok()?;
802 let m: Marker = serde_json::from_str(&text).ok()?;
803 return Some(m.rev);
804 }
805 dir = dir.parent()?;
806 }
807 None
808}
809
810fn verify_rev_dir(dir: &Path, index: &AssetBundleIndex) -> anyhow::Result<bool> {
811 for (rel, entry) in &index.files {
812 let path = dir.join(rel);
813 if !path.is_file() {
814 return Ok(false);
815 }
816 if sha256_file(&path)? != entry.sha256 {
817 return Ok(false);
818 }
819 }
820 Ok(true)
821}
822
823fn sha256_bytes(bytes: &[u8]) -> String {
824 let mut hasher = Sha256::new();
825 hasher.update(bytes);
826 hex::encode(hasher.finalize())
827}
828
829#[cfg(test)]
830mod tests {
831 use super::*;
832 use std::fs;
833
834 #[test]
835 fn firebase_url_encodes_slashes() {
836 let url = firebase_download_url("bucket", "flatland3/client-assets/latest.json");
837 assert!(url.contains("%2F"));
838 assert!(url.contains("alt=media"));
839 }
840
841 #[test]
842 fn build_index_hashes_files() {
843 let dir = std::env::temp_dir().join(format!("flatland-assets-test-{}", std::process::id()));
844 let _ = fs::remove_dir_all(&dir);
845 fs::create_dir_all(dir.join("cells")).expect("dir");
846 fs::write(dir.join("manifest.yaml"), "version: 1\n").expect("write");
847 fs::write(dir.join("cells/a.png"), b"png").expect("write");
848 let index = build_bundle_index(&dir, 3, "2026-01-01T00:00:00Z").expect("index");
849 assert_eq!(index.publish_rev, 3);
850 assert_eq!(index.files.len(), 2);
851 let _ = fs::remove_dir_all(&dir);
852 }
853
854 #[test]
855 fn build_client_index_includes_paperdoll_prefix() {
856 let root = std::env::temp_dir().join(format!(
857 "flatland-assets-paperdoll-{}",
858 std::process::id()
859 ));
860 let sprites = root.join("sprites");
861 let paperdoll = root.join("paperdoll");
862 let _ = fs::remove_dir_all(&root);
863 fs::create_dir_all(sprites.join("cells")).unwrap();
864 fs::create_dir_all(paperdoll.join("skins")).unwrap();
865 fs::write(sprites.join("manifest.yaml"), "version: 1\n").unwrap();
866 fs::write(paperdoll.join("skins/hero.yaml"), "skin: {}\n").unwrap();
867 let sources = ClientBundleSources {
868 sprites_dir: &sprites,
869 paperdoll_dir: Some(&paperdoll),
870 ..ClientBundleSources::empty()
871 };
872 let index = build_client_bundle_index(&sources, 9, "2026-01-01T00:00:00Z").unwrap();
873 assert!(index.files.contains_key("manifest.yaml"));
874 assert!(index.files.contains_key("paperdoll/skins/hero.yaml"));
875 assert_eq!(
876 resolve_bundle_file_path(&sources, "paperdoll/skins/hero.yaml"),
877 paperdoll.join("skins/hero.yaml")
878 );
879 let _ = fs::remove_dir_all(&root);
880 }
881
882 #[test]
883 fn needs_sync_false_when_repo_matches_server_rev() {
884 if should_prefer_repo_assets()
885 && read_repo_publish_rev().is_some()
886 && find_repo_sprites_dir().is_some()
887 {
888 let rev = read_repo_publish_rev().unwrap();
889 assert!(!needs_asset_sync(rev));
890 }
891 }
892
893 #[test]
894 fn repo_outranks_stale_synced_cache() {
895 assert!(repo_sprites_outrank_cache(Some(85), Some(75)));
896 assert!(repo_sprites_outrank_cache(Some(75), Some(75)));
897 assert!(!repo_sprites_outrank_cache(Some(70), Some(75)));
898 assert!(repo_sprites_outrank_cache(Some(85), None));
899 assert!(!repo_sprites_outrank_cache(None, Some(75)));
900 }
901
902 #[test]
903 fn prefer_repo_only_from_cargo_target_or_env() {
904 assert!(should_prefer_repo_assets() || std::env::var_os("FLATLAND_FORCE_ASSET_SYNC").is_some());
906 }
907
908 #[test]
909 fn build_client_index_includes_player_presentation() {
910 let root = std::env::temp_dir().join(format!(
911 "flatland-assets-pres-{}",
912 std::process::id()
913 ));
914 let sprites = root.join("sprites");
915 let pres = root.join("player-presentation.yaml");
916 let _ = fs::remove_dir_all(&root);
917 fs::create_dir_all(&sprites).unwrap();
918 fs::write(sprites.join("manifest.yaml"), "version: 1\n").unwrap();
919 fs::write(&pres, "player_presentation:\n paperdoll_ref: hero\n").unwrap();
920 let sources = ClientBundleSources {
921 sprites_dir: &sprites,
922 player_presentation: Some(&pres),
923 ..ClientBundleSources::empty()
924 };
925 let index = build_client_bundle_index(&sources, 11, "2026-01-01T00:00:00Z").unwrap();
926 assert!(index.files.contains_key("player-presentation.yaml"));
927 assert_eq!(
928 resolve_bundle_file_path(&sources, "player-presentation.yaml"),
929 pres
930 );
931 let _ = fs::remove_dir_all(&root);
932 }
933
934 #[test]
935 fn verify_rev_dir_detects_missing_and_corrupt() {
936 let dir = std::env::temp_dir().join(format!(
937 "flatland-assets-verify-{}",
938 std::process::id()
939 ));
940 let _ = fs::remove_dir_all(&dir);
941 fs::create_dir_all(&dir).unwrap();
942 fs::write(dir.join("manifest.yaml"), "version: 1\n").unwrap();
943 let index = build_bundle_index(&dir, 1, "2026-01-01T00:00:00Z").unwrap();
944 assert!(verify_rev_dir(&dir, &index).unwrap());
945 fs::write(dir.join("extra.png"), b"x").unwrap();
946 assert!(verify_rev_dir(&dir, &index).unwrap());
948 fs::write(dir.join("manifest.yaml"), "tampered\n").unwrap();
949 assert!(!verify_rev_dir(&dir, &index).unwrap());
950 fs::remove_file(dir.join("manifest.yaml")).unwrap();
951 assert!(!verify_rev_dir(&dir, &index).unwrap());
952 let _ = fs::remove_dir_all(&dir);
953 }
954
955 #[test]
956 fn partial_download_path_keeps_full_name() {
957 let dest = PathBuf::from("/tmp/cells/hero.png");
958 let tmp = dest.with_file_name(format!(
959 "{}.partial",
960 dest.file_name().and_then(|n| n.to_str()).unwrap()
961 ));
962 assert_eq!(tmp, PathBuf::from("/tmp/cells/hero.png.partial"));
963 }
964}