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(sprites_dir, None, publish_rev, published_at)
208}
209
210pub fn build_client_bundle_index(
213 sprites_dir: &Path,
214 paperdoll_dir: Option<&Path>,
215 publish_rev: u64,
216 published_at: &str,
217) -> anyhow::Result<AssetBundleIndex> {
218 let mut files = BTreeMap::new();
219 walk_asset_dir(sprites_dir, sprites_dir, "", &mut files)?;
220 if let Some(paperdoll) = paperdoll_dir {
221 if paperdoll.is_dir() {
222 walk_asset_dir(paperdoll, paperdoll, "paperdoll/", &mut files)?;
223 }
224 }
225 Ok(AssetBundleIndex {
226 publish_rev,
227 published_at: published_at.to_string(),
228 files,
229 })
230}
231
232pub fn resolve_bundle_file_path(
234 sprites_dir: &Path,
235 paperdoll_dir: Option<&Path>,
236 rel: &str,
237) -> PathBuf {
238 if let Some(rest) = rel.strip_prefix("paperdoll/") {
239 if let Some(root) = paperdoll_dir {
240 return root.join(rest);
241 }
242 }
243 sprites_dir.join(rel)
244}
245
246pub fn resolve_paperdoll_dir() -> Option<PathBuf> {
248 if let Ok(p) = std::env::var("FLATLAND_PAPERDOLL_DIR") {
249 let path = PathBuf::from(p);
250 if path.is_dir() {
251 return Some(path);
252 }
253 }
254 if let Ok(assets) = std::env::var("FLATLAND_ASSETS") {
255 let path = PathBuf::from(assets).join("paperdoll");
256 if path.is_dir() {
257 return Some(path);
258 }
259 }
260 if let Some(sprites) = resolve_sprites_dir() {
261 let path = sprites.join("paperdoll");
262 if path.is_dir() {
263 return Some(path);
264 }
265 }
266 let mut dir = std::env::current_dir().ok()?;
267 for _ in 0..8 {
268 let candidate = dir.join("assets/paperdoll");
269 if candidate.is_dir() {
270 return Some(candidate);
271 }
272 if !dir.pop() {
273 break;
274 }
275 }
276 None
277}
278
279pub fn should_prefer_repo_assets() -> bool {
284 if std::env::var_os("FLATLAND_FORCE_ASSET_SYNC").is_some() {
285 return false;
286 }
287 if std::env::var_os("FLATLAND_USE_REPO_ASSETS").is_some() {
288 return true;
289 }
290 let Ok(exe) = std::env::current_exe() else {
291 return false;
292 };
293 exe.components().any(|c| c.as_os_str() == "target") && find_repo_sprites_dir().is_some()
294}
295
296fn walk_asset_dir(
297 root: &Path,
298 dir: &Path,
299 prefix: &str,
300 files: &mut BTreeMap<String, AssetFileEntry>,
301) -> anyhow::Result<()> {
302 for entry in std::fs::read_dir(dir)? {
303 let entry = entry?;
304 let path = entry.path();
305 if path.is_dir() {
306 let name = entry.file_name();
307 if name == "previews" {
308 continue;
309 }
310 walk_asset_dir(root, &path, prefix, files)?;
311 continue;
312 }
313 let rel = path
314 .strip_prefix(root)?
315 .to_string_lossy()
316 .replace('\\', "/");
317 if rel.starts_with('.') || rel.contains("/.") {
318 continue;
319 }
320 if rel.ends_with(".html") {
321 continue;
322 }
323 let meta = std::fs::metadata(&path)?;
324 files.insert(
325 format!("{prefix}{rel}"),
326 AssetFileEntry {
327 sha256: sha256_file(&path)?,
328 size: meta.len(),
329 },
330 );
331 }
332 Ok(())
333}
334
335pub struct AssetSyncOptions {
336 pub index_url: String,
337 pub storage_prefix: String,
338 pub target_rev: Option<u64>,
339 pub quiet: bool,
340 pub force_remote: bool,
342}
343
344#[derive(Debug, Clone, Copy, PartialEq, Eq)]
346pub enum AssetSyncKind {
347 Remote,
349 LocalCacheNoRemote,
351 RepoDevNoRemote,
353}
354
355#[derive(Debug, Clone)]
356pub struct AssetSyncResult {
357 pub state: LocalAssetState,
358 pub kind: AssetSyncKind,
359}
360
361impl Default for AssetSyncOptions {
362 fn default() -> Self {
363 Self {
364 index_url: assets_index_url(),
365 storage_prefix: assets_storage_prefix(),
366 target_rev: None,
367 quiet: false,
368 force_remote: false,
369 }
370 }
371}
372
373pub async fn sync_assets(opts: AssetSyncOptions) -> anyhow::Result<AssetSyncResult> {
374 if !opts.force_remote && should_prefer_repo_assets() {
378 if let Some(repo_dir) = find_repo_sprites_dir() {
379 if let Some(publish_rev) = read_repo_publish_rev() {
380 if opts.target_rev.map_or(true, |t| publish_rev >= t) {
381 if !opts.quiet {
382 println!(
383 "Using repo sprites at {} (rev {publish_rev}); set FLATLAND_FORCE_ASSET_SYNC=1 to pull remote.",
384 repo_dir.display()
385 );
386 }
387 return Ok(AssetSyncResult {
388 state: LocalAssetState {
389 publish_rev,
390 sprites_dir: repo_dir,
391 },
392 kind: AssetSyncKind::RepoDevNoRemote,
393 });
394 }
395 }
396 }
397 }
398
399 let client = reqwest::Client::builder()
400 .user_agent(format!("flatland-client-lib/{}", env!("CARGO_PKG_VERSION")))
401 .connect_timeout(SYNC_CONNECT_TIMEOUT)
402 .timeout(SYNC_REQUEST_TIMEOUT)
403 .pool_max_idle_per_host(SYNC_CONCURRENCY)
404 .build()?;
405 let response = client.get(&opts.index_url).send().await?;
406 if response.status() == reqwest::StatusCode::NOT_FOUND {
407 return sync_without_remote_index(opts);
408 }
409 let index: AssetBundleIndex = response.error_for_status()?.json().await?;
410 if index.files.is_empty() {
411 anyhow::bail!("remote asset index is empty — refuse to sync");
412 }
413 if !index.files.contains_key("manifest.yaml") {
414 anyhow::bail!("remote asset index missing manifest.yaml");
415 }
416 sync_from_index(&client, opts, index).await
417}
418
419fn sync_without_remote_index(opts: AssetSyncOptions) -> anyhow::Result<AssetSyncResult> {
420 if let Some(local) = read_local_state() {
421 if local.sprites_dir.join("manifest.yaml").is_file() {
422 if !opts.quiet {
423 println!(
424 "Remote latest.json not found; using cached publish rev {}.",
425 local.publish_rev
426 );
427 }
428 return Ok(AssetSyncResult {
429 state: local,
430 kind: AssetSyncKind::LocalCacheNoRemote,
431 });
432 }
433 }
434 if let Some(repo) = find_repo_sprites_dir() {
435 let publish_rev = read_repo_publish_rev().unwrap_or(0);
436 if !opts.quiet {
437 println!(
438 "Remote latest.json not found; using repo sprites at {} (rev {publish_rev}).",
439 repo.display()
440 );
441 }
442 return Ok(AssetSyncResult {
443 state: LocalAssetState {
444 publish_rev,
445 sprites_dir: repo,
446 },
447 kind: AssetSyncKind::RepoDevNoRemote,
448 });
449 }
450 anyhow::bail!(
451 "Gfx asset bundle not published yet (HTTP 404 on latest.json). \
452 Server admin: flatland-admin content publish"
453 );
454}
455
456async fn sync_from_index(
457 client: &reqwest::Client,
458 opts: AssetSyncOptions,
459 index: AssetBundleIndex,
460) -> anyhow::Result<AssetSyncResult> {
461 let publish_rev = opts.target_rev.unwrap_or(index.publish_rev);
462 if publish_rev != index.publish_rev {
463 anyhow::bail!(
464 "requested publish rev {publish_rev} but remote latest is {}",
465 index.publish_rev
466 );
467 }
468
469 if let Some(local) = read_local_state() {
470 if local.publish_rev == publish_rev && local.sprites_dir.join("manifest.yaml").is_file() {
471 if verify_rev_dir(&local.sprites_dir, &index)? {
472 if !opts.quiet {
473 println!(
474 "Assets up to date (publish rev {publish_rev}, {} files).",
475 index.files.len()
476 );
477 }
478 return Ok(AssetSyncResult {
479 state: local,
480 kind: AssetSyncKind::Remote,
481 });
482 }
483 if !opts.quiet {
484 println!(
485 "Cached rev {publish_rev} incomplete or corrupt — re-syncing {} files…",
486 index.files.len()
487 );
488 }
489 }
490 }
491
492 let rev_dir = sprites_dir_for_rev(publish_rev)?;
493 std::fs::create_dir_all(&rev_dir)?;
494
495 let total = index.files.len();
496 let mut pending: Vec<(String, AssetFileEntry)> = Vec::new();
497 let mut already_ok = 0usize;
498 for (rel, entry) in &index.files {
499 let dest = rev_dir.join(rel);
500 if dest.is_file() && sha256_file(&dest)? == entry.sha256 {
501 already_ok += 1;
502 continue;
503 }
504 pending.push((rel.clone(), entry.clone()));
505 }
506
507 if !opts.quiet {
508 println!(
509 "Syncing publish rev {publish_rev}: {already_ok}/{total} present, {} to download…",
510 pending.len()
511 );
512 }
513
514 download_files_concurrent(client, &opts, publish_rev, &rev_dir, pending, opts.quiet).await?;
515
516 if !verify_rev_dir(&rev_dir, &index)? {
517 anyhow::bail!(
518 "asset sync verification failed for rev {publish_rev} after download — try again"
519 );
520 }
521
522 let index_path = rev_dir.join(".bundle-index.json");
524 let _ = std::fs::write(&index_path, serde_json::to_vec_pretty(&index)?);
525
526 activate_rev_dir(&rev_dir)?;
527 let state = LocalAssetState {
528 publish_rev,
529 sprites_dir: rev_dir,
530 };
531 write_local_state(&state)?;
532 if !opts.quiet {
533 let paperdoll_n = index
534 .files
535 .keys()
536 .filter(|k| k.starts_with("paperdoll/"))
537 .count();
538 println!(
539 "Synced publish rev {publish_rev} → {} ({} files, {paperdoll_n} paperdoll)",
540 state.sprites_dir.display(),
541 index.files.len()
542 );
543 }
544 Ok(AssetSyncResult {
545 state,
546 kind: AssetSyncKind::Remote,
547 })
548}
549
550async fn download_files_concurrent(
551 client: &reqwest::Client,
552 opts: &AssetSyncOptions,
553 publish_rev: u64,
554 rev_dir: &Path,
555 pending: Vec<(String, AssetFileEntry)>,
556 quiet: bool,
557) -> anyhow::Result<()> {
558 if pending.is_empty() {
559 return Ok(());
560 }
561 let total = pending.len();
562 let client = client.clone();
563 let prefix = opts.storage_prefix.clone();
564 let rev_dir = rev_dir.to_path_buf();
565 let pending = Arc::new(pending);
566 let done = Arc::new(std::sync::atomic::AtomicUsize::new(0));
567 let mut join_set = tokio::task::JoinSet::new();
568 let mut next = 0usize;
569
570 while next < pending.len() || !join_set.is_empty() {
571 while join_set.len() < SYNC_CONCURRENCY && next < pending.len() {
572 let (rel, entry) = pending[next].clone();
573 next += 1;
574 let client = client.clone();
575 let prefix = prefix.clone();
576 let rev_dir = rev_dir.clone();
577 let done = Arc::clone(&done);
578 join_set.spawn(async move {
579 download_one_with_retries(&client, &prefix, publish_rev, &rev_dir, &rel, &entry)
580 .await?;
581 let n = done.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
582 if !quiet && (n % 25 == 0 || n == total) {
583 println!(" [{n}/{total}] synced…");
584 }
585 Ok::<(), anyhow::Error>(())
586 });
587 }
588 if let Some(res) = join_set.join_next().await {
589 res.map_err(|e| anyhow::anyhow!("asset download task join: {e}"))??;
590 }
591 }
592 Ok(())
593}
594
595async fn download_one_with_retries(
596 client: &reqwest::Client,
597 prefix: &str,
598 publish_rev: u64,
599 rev_dir: &Path,
600 rel: &str,
601 entry: &AssetFileEntry,
602) -> anyhow::Result<()> {
603 let dest = rev_dir.join(rel);
604 if let Some(parent) = dest.parent() {
605 std::fs::create_dir_all(parent)?;
606 }
607 let url = firebase_download_url(
608 DEFAULT_FIREBASE_BUCKET,
609 &firebase_object_path_for_prefix(prefix, publish_rev, rel),
610 );
611 let mut last_err = None;
612 for attempt in 1..=SYNC_ATTEMPTS {
613 match download_one(client, &url, &dest, entry).await {
614 Ok(()) => return Ok(()),
615 Err(err) => {
616 last_err = Some(err);
617 if attempt < SYNC_ATTEMPTS {
618 let backoff = Duration::from_millis(200 * 2u64.pow(attempt - 1));
619 tokio::time::sleep(backoff).await;
620 }
621 }
622 }
623 }
624 Err(last_err.unwrap_or_else(|| anyhow::anyhow!("download failed for {rel}")))
625}
626
627async fn download_one(
628 client: &reqwest::Client,
629 url: &str,
630 dest: &Path,
631 entry: &AssetFileEntry,
632) -> anyhow::Result<()> {
633 let bytes = client
634 .get(url)
635 .send()
636 .await?
637 .error_for_status()?
638 .bytes()
639 .await?;
640 if bytes.len() as u64 != entry.size && entry.size > 0 {
641 if sha256_bytes(&bytes) != entry.sha256 {
643 anyhow::bail!(
644 "size/hash mismatch (got {} bytes, expected {} / {})",
645 bytes.len(),
646 entry.size,
647 entry.sha256
648 );
649 }
650 } else if sha256_bytes(&bytes) != entry.sha256 {
651 anyhow::bail!("hash mismatch after download");
652 }
653 let tmp = dest.with_file_name(format!(
656 "{}.partial",
657 dest.file_name()
658 .and_then(|n| n.to_str())
659 .unwrap_or("asset")
660 ));
661 std::fs::write(&tmp, &bytes)?;
662 std::fs::rename(&tmp, dest)?;
663 Ok(())
664}
665
666pub fn needs_asset_sync(server_publish_rev: u64) -> bool {
667 if server_publish_rev == 0 {
668 return false;
669 }
670 if let Some(local) = read_local_state() {
671 if local.publish_rev >= server_publish_rev
672 && local.sprites_dir.join("manifest.yaml").is_file()
673 {
674 return false;
675 }
676 }
677 if should_prefer_repo_assets() {
678 if let Some(repo_rev) = read_repo_publish_rev() {
679 if repo_rev >= server_publish_rev && find_repo_sprites_dir().is_some() {
680 return false;
681 }
682 }
683 }
684 resolve_sprites_dir().is_none()
685}
686
687pub fn read_repo_publish_rev() -> Option<u64> {
689 let sprites = find_repo_sprites_dir()?;
690 let mut dir = sprites.as_path();
691 for _ in 0..8 {
692 let marker = dir.join(".content-publish.json");
693 if marker.is_file() {
694 #[derive(Deserialize)]
695 struct Marker {
696 rev: u64,
697 }
698 let text = std::fs::read_to_string(marker).ok()?;
699 let m: Marker = serde_json::from_str(&text).ok()?;
700 return Some(m.rev);
701 }
702 dir = dir.parent()?;
703 }
704 None
705}
706
707fn verify_rev_dir(dir: &Path, index: &AssetBundleIndex) -> anyhow::Result<bool> {
708 for (rel, entry) in &index.files {
709 let path = dir.join(rel);
710 if !path.is_file() {
711 return Ok(false);
712 }
713 if sha256_file(&path)? != entry.sha256 {
714 return Ok(false);
715 }
716 }
717 Ok(true)
718}
719
720fn sha256_bytes(bytes: &[u8]) -> String {
721 let mut hasher = Sha256::new();
722 hasher.update(bytes);
723 hex::encode(hasher.finalize())
724}
725
726#[cfg(test)]
727mod tests {
728 use super::*;
729 use std::fs;
730
731 #[test]
732 fn firebase_url_encodes_slashes() {
733 let url = firebase_download_url("bucket", "flatland3/client-assets/latest.json");
734 assert!(url.contains("%2F"));
735 assert!(url.contains("alt=media"));
736 }
737
738 #[test]
739 fn build_index_hashes_files() {
740 let dir = std::env::temp_dir().join(format!("flatland-assets-test-{}", std::process::id()));
741 let _ = fs::remove_dir_all(&dir);
742 fs::create_dir_all(dir.join("cells")).expect("dir");
743 fs::write(dir.join("manifest.yaml"), "version: 1\n").expect("write");
744 fs::write(dir.join("cells/a.png"), b"png").expect("write");
745 let index = build_bundle_index(&dir, 3, "2026-01-01T00:00:00Z").expect("index");
746 assert_eq!(index.publish_rev, 3);
747 assert_eq!(index.files.len(), 2);
748 let _ = fs::remove_dir_all(&dir);
749 }
750
751 #[test]
752 fn build_client_index_includes_paperdoll_prefix() {
753 let root = std::env::temp_dir().join(format!(
754 "flatland-assets-paperdoll-{}",
755 std::process::id()
756 ));
757 let sprites = root.join("sprites");
758 let paperdoll = root.join("paperdoll");
759 let _ = fs::remove_dir_all(&root);
760 fs::create_dir_all(sprites.join("cells")).unwrap();
761 fs::create_dir_all(paperdoll.join("skins")).unwrap();
762 fs::write(sprites.join("manifest.yaml"), "version: 1\n").unwrap();
763 fs::write(paperdoll.join("skins/hero.yaml"), "skin: {}\n").unwrap();
764 let index =
765 build_client_bundle_index(&sprites, Some(&paperdoll), 9, "2026-01-01T00:00:00Z")
766 .unwrap();
767 assert!(index.files.contains_key("manifest.yaml"));
768 assert!(index.files.contains_key("paperdoll/skins/hero.yaml"));
769 assert_eq!(
770 resolve_bundle_file_path(&sprites, Some(&paperdoll), "paperdoll/skins/hero.yaml"),
771 paperdoll.join("skins/hero.yaml")
772 );
773 let _ = fs::remove_dir_all(&root);
774 }
775
776 #[test]
777 fn needs_sync_false_when_repo_matches_server_rev() {
778 if should_prefer_repo_assets()
779 && read_repo_publish_rev().is_some()
780 && find_repo_sprites_dir().is_some()
781 {
782 let rev = read_repo_publish_rev().unwrap();
783 assert!(!needs_asset_sync(rev));
784 }
785 }
786
787 #[test]
788 fn repo_outranks_stale_synced_cache() {
789 assert!(repo_sprites_outrank_cache(Some(85), Some(75)));
790 assert!(repo_sprites_outrank_cache(Some(75), Some(75)));
791 assert!(!repo_sprites_outrank_cache(Some(70), Some(75)));
792 assert!(repo_sprites_outrank_cache(Some(85), None));
793 assert!(!repo_sprites_outrank_cache(None, Some(75)));
794 }
795
796 #[test]
797 fn prefer_repo_only_from_cargo_target_or_env() {
798 assert!(should_prefer_repo_assets() || std::env::var_os("FLATLAND_FORCE_ASSET_SYNC").is_some());
800 }
801
802 #[test]
803 fn verify_rev_dir_detects_missing_and_corrupt() {
804 let dir = std::env::temp_dir().join(format!(
805 "flatland-assets-verify-{}",
806 std::process::id()
807 ));
808 let _ = fs::remove_dir_all(&dir);
809 fs::create_dir_all(&dir).unwrap();
810 fs::write(dir.join("manifest.yaml"), "version: 1\n").unwrap();
811 let index = build_bundle_index(&dir, 1, "2026-01-01T00:00:00Z").unwrap();
812 assert!(verify_rev_dir(&dir, &index).unwrap());
813 fs::write(dir.join("extra.png"), b"x").unwrap();
814 assert!(verify_rev_dir(&dir, &index).unwrap());
816 fs::write(dir.join("manifest.yaml"), "tampered\n").unwrap();
817 assert!(!verify_rev_dir(&dir, &index).unwrap());
818 fs::remove_file(dir.join("manifest.yaml")).unwrap();
819 assert!(!verify_rev_dir(&dir, &index).unwrap());
820 let _ = fs::remove_dir_all(&dir);
821 }
822
823 #[test]
824 fn partial_download_path_keeps_full_name() {
825 let dest = PathBuf::from("/tmp/cells/hero.png");
826 let tmp = dest.with_file_name(format!(
827 "{}.partial",
828 dest.file_name().and_then(|n| n.to_str()).unwrap()
829 ));
830 assert_eq!(tmp, PathBuf::from("/tmp/cells/hero.png.partial"));
831 }
832}