use std::collections::BTreeMap;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
pub const DEFAULT_FIREBASE_BUCKET: &str = "flatland-8911e.appspot.com";
pub const ASSETS_INDEX_OBJECT: &str = "flatland3/client-assets/latest.json";
const SYNC_CONCURRENCY: usize = 8;
const SYNC_ATTEMPTS: u32 = 4;
const SYNC_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
const SYNC_REQUEST_TIMEOUT: Duration = Duration::from_secs(120);
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct AssetFileEntry {
pub sha256: String,
pub size: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct AssetBundleIndex {
pub publish_rev: u64,
pub published_at: String,
pub files: BTreeMap<String, AssetFileEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct LocalAssetState {
pub publish_rev: u64,
pub sprites_dir: PathBuf,
}
pub fn assets_root_dir() -> anyhow::Result<PathBuf> {
let base = dirs::home_dir().ok_or_else(|| anyhow::anyhow!("home directory not found"))?;
Ok(base.join(".flatland3").join("assets"))
}
pub fn local_state_path() -> anyhow::Result<PathBuf> {
Ok(assets_root_dir()?.join("state.json"))
}
pub fn read_local_state() -> Option<LocalAssetState> {
let path = local_state_path().ok()?;
let bytes = std::fs::read(path).ok()?;
serde_json::from_slice(&bytes).ok()
}
pub fn write_local_state(state: &LocalAssetState) -> anyhow::Result<()> {
let path = local_state_path()?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, serde_json::to_vec_pretty(state)?)?;
Ok(())
}
pub fn sprites_dir_for_rev(publish_rev: u64) -> anyhow::Result<PathBuf> {
Ok(assets_root_dir()?.join(format!("rev-{publish_rev}")))
}
pub fn current_symlink_path() -> anyhow::Result<PathBuf> {
Ok(assets_root_dir()?.join("current"))
}
pub fn activate_rev_dir(rev_dir: &Path) -> anyhow::Result<()> {
let link = current_symlink_path()?;
if link.exists() {
std::fs::remove_file(&link).or_else(|_| std::fs::remove_dir_all(&link))?;
}
#[cfg(unix)]
{
std::os::unix::fs::symlink(rev_dir, &link)?;
}
#[cfg(not(unix))]
{
let marker = link.with_extension("path");
std::fs::write(&marker, rev_dir.to_string_lossy().as_bytes())?;
let _ = std::fs::create_dir_all(&link);
let _ = std::fs::copy(rev_dir.join("manifest.yaml"), link.join("manifest.yaml"));
}
Ok(())
}
pub fn find_repo_sprites_dir() -> Option<PathBuf> {
let mut dir = std::env::current_dir().ok()?;
for _ in 0..8 {
let candidate = dir.join("assets/gfx/sprites");
if candidate.join("manifest.yaml").is_file() {
return Some(candidate);
}
if !dir.pop() {
break;
}
}
None
}
pub(crate) fn repo_sprites_outrank_cache(repo_rev: Option<u64>, local_rev: Option<u64>) -> bool {
match (repo_rev, local_rev) {
(Some(rr), Some(lr)) => rr >= lr,
(Some(_), None) => true,
_ => false,
}
}
pub fn resolve_sprites_dir() -> Option<PathBuf> {
if let Ok(p) = std::env::var("FLATLAND_SPRITES_DIR") {
let path = PathBuf::from(p);
if path.join("manifest.yaml").is_file() {
return Some(path);
}
}
let repo = find_repo_sprites_dir();
let repo_rev = read_repo_publish_rev();
let local = read_local_state().filter(|s| s.sprites_dir.join("manifest.yaml").is_file());
let local_rev = local.as_ref().map(|s| s.publish_rev);
if should_prefer_repo_assets() && repo_sprites_outrank_cache(repo_rev, local_rev) {
if let Some(repo_dir) = repo {
return Some(repo_dir);
}
}
if let Some(local) = local {
return Some(local.sprites_dir);
}
if let Some(repo_dir) = repo {
return Some(repo_dir);
}
let current = current_symlink_path().ok()?;
if current.join("manifest.yaml").is_file() {
return Some(current);
}
None
}
pub fn assets_index_url() -> String {
std::env::var("FLATLAND_ASSETS_INDEX_URL").unwrap_or_else(|_| {
firebase_download_url(DEFAULT_FIREBASE_BUCKET, ASSETS_INDEX_OBJECT)
})
}
pub fn firebase_download_url(bucket: &str, object_path: &str) -> String {
let encoded = urlencoding_encode(object_path);
format!(
"https://firebasestorage.googleapis.com/v0/b/{bucket}/o/{encoded}?alt=media"
)
}
pub fn assets_storage_prefix() -> String {
std::env::var("FLATLAND_ASSETS_PREFIX")
.unwrap_or_else(|_| "flatland3/client-assets".to_string())
}
pub fn firebase_object_path_for_prefix(prefix: &str, publish_rev: u64, relative: &str) -> String {
format!("{prefix}/rev-{publish_rev}/{relative}")
}
pub fn firebase_object_path(publish_rev: u64, relative: &str) -> String {
firebase_object_path_for_prefix(&assets_storage_prefix(), publish_rev, relative)
}
fn urlencoding_encode(path: &str) -> String {
path.bytes()
.map(|b| match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
(b as char).to_string()
}
_ => format!("%{b:02X}"),
})
.collect()
}
pub fn sha256_file(path: &Path) -> anyhow::Result<String> {
let mut file = std::fs::File::open(path)?;
let mut hasher = Sha256::new();
let mut buf = [0u8; 8192];
loop {
let n = file.read(&mut buf)?;
if n == 0 {
break;
}
hasher.update(&buf[..n]);
}
Ok(hex::encode(hasher.finalize()))
}
pub fn build_bundle_index(sprites_dir: &Path, publish_rev: u64, published_at: &str) -> anyhow::Result<AssetBundleIndex> {
build_client_bundle_index(&ClientBundleSources {
sprites_dir,
..ClientBundleSources::empty()
}, publish_rev, published_at)
}
#[derive(Debug, Clone, Copy)]
pub struct ClientBundleSources<'a> {
pub sprites_dir: &'a Path,
pub paperdoll_dir: Option<&'a Path>,
pub player_presentation: Option<&'a Path>,
pub player_art_dir: Option<&'a Path>,
pub client_settings: Option<&'a Path>,
pub terrain_kinds: Option<&'a Path>,
}
impl<'a> ClientBundleSources<'a> {
pub fn empty() -> Self {
Self {
sprites_dir: Path::new(""),
paperdoll_dir: None,
player_presentation: None,
player_art_dir: None,
client_settings: None,
terrain_kinds: None,
}
}
}
pub fn build_client_bundle_index(
sources: &ClientBundleSources<'_>,
publish_rev: u64,
published_at: &str,
) -> anyhow::Result<AssetBundleIndex> {
let mut files = BTreeMap::new();
walk_asset_dir(sources.sprites_dir, sources.sprites_dir, "", &mut files)?;
if let Some(paperdoll) = sources.paperdoll_dir {
if paperdoll.is_dir() {
walk_asset_dir(paperdoll, paperdoll, "paperdoll/", &mut files)?;
}
}
if let Some(player_art) = sources.player_art_dir {
if player_art.is_dir() {
walk_asset_dir(player_art, player_art, "player/", &mut files)?;
}
}
insert_single_file(&mut files, "player-presentation.yaml", sources.player_presentation)?;
insert_single_file(
&mut files,
"config/client-settings.yaml",
sources.client_settings,
)?;
insert_single_file(
&mut files,
"presentation/terrain-kinds.yaml",
sources.terrain_kinds,
)?;
Ok(AssetBundleIndex {
publish_rev,
published_at: published_at.to_string(),
files,
})
}
fn insert_single_file(
files: &mut BTreeMap<String, AssetFileEntry>,
key: &str,
path: Option<&Path>,
) -> anyhow::Result<()> {
let Some(path) = path else {
return Ok(());
};
if !path.is_file() {
return Ok(());
}
let meta = std::fs::metadata(path)?;
files.insert(
key.to_string(),
AssetFileEntry {
sha256: sha256_file(path)?,
size: meta.len(),
},
);
Ok(())
}
pub fn resolve_bundle_file_path(sources: &ClientBundleSources<'_>, rel: &str) -> PathBuf {
if rel == "player-presentation.yaml" {
if let Some(path) = sources.player_presentation {
return path.to_path_buf();
}
}
if rel == "config/client-settings.yaml" {
if let Some(path) = sources.client_settings {
return path.to_path_buf();
}
}
if rel == "presentation/terrain-kinds.yaml" {
if let Some(path) = sources.terrain_kinds {
return path.to_path_buf();
}
}
if let Some(rest) = rel.strip_prefix("paperdoll/") {
if let Some(root) = sources.paperdoll_dir {
return root.join(rest);
}
}
if let Some(rest) = rel.strip_prefix("player/") {
if let Some(root) = sources.player_art_dir {
return root.join(rest);
}
}
sources.sprites_dir.join(rel)
}
pub fn synced_rev_dir() -> Option<PathBuf> {
if let Some(local) = read_local_state() {
if local.sprites_dir.join("manifest.yaml").is_file() {
return Some(local.sprites_dir);
}
}
let current = current_symlink_path().ok()?;
if current.join("manifest.yaml").is_file() {
return Some(current);
}
None
}
pub fn resolve_paperdoll_dir() -> Option<PathBuf> {
if let Ok(p) = std::env::var("FLATLAND_PAPERDOLL_DIR") {
let path = PathBuf::from(p);
if path.is_dir() {
return Some(path);
}
}
if let Ok(assets) = std::env::var("FLATLAND_ASSETS") {
let path = PathBuf::from(assets).join("paperdoll");
if path.is_dir() {
return Some(path);
}
}
if let Some(sprites) = resolve_sprites_dir() {
let path = sprites.join("paperdoll");
if path.is_dir() {
return Some(path);
}
}
if let Some(rev) = synced_rev_dir() {
let path = rev.join("paperdoll");
if path.is_dir() {
return Some(path);
}
}
let mut dir = std::env::current_dir().ok()?;
for _ in 0..8 {
let candidate = dir.join("assets/paperdoll");
if candidate.is_dir() {
return Some(candidate);
}
if !dir.pop() {
break;
}
}
None
}
pub fn should_prefer_repo_assets() -> bool {
if std::env::var_os("FLATLAND_FORCE_ASSET_SYNC").is_some() {
return false;
}
if std::env::var_os("FLATLAND_USE_REPO_ASSETS").is_some() {
return true;
}
let Ok(exe) = std::env::current_exe() else {
return false;
};
exe.components().any(|c| c.as_os_str() == "target") && find_repo_sprites_dir().is_some()
}
fn walk_asset_dir(
root: &Path,
dir: &Path,
prefix: &str,
files: &mut BTreeMap<String, AssetFileEntry>,
) -> anyhow::Result<()> {
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
let name = entry.file_name();
if name == "previews" {
continue;
}
walk_asset_dir(root, &path, prefix, files)?;
continue;
}
let rel = path
.strip_prefix(root)?
.to_string_lossy()
.replace('\\', "/");
if rel.starts_with('.') || rel.contains("/.") {
continue;
}
if rel.ends_with(".html") {
continue;
}
let meta = std::fs::metadata(&path)?;
files.insert(
format!("{prefix}{rel}"),
AssetFileEntry {
sha256: sha256_file(&path)?,
size: meta.len(),
},
);
}
Ok(())
}
pub struct AssetSyncOptions {
pub index_url: String,
pub storage_prefix: String,
pub target_rev: Option<u64>,
pub quiet: bool,
pub force_remote: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AssetSyncKind {
Remote,
LocalCacheNoRemote,
RepoDevNoRemote,
}
#[derive(Debug, Clone)]
pub struct AssetSyncResult {
pub state: LocalAssetState,
pub kind: AssetSyncKind,
}
impl Default for AssetSyncOptions {
fn default() -> Self {
Self {
index_url: assets_index_url(),
storage_prefix: assets_storage_prefix(),
target_rev: None,
quiet: false,
force_remote: false,
}
}
}
pub async fn sync_assets(opts: AssetSyncOptions) -> anyhow::Result<AssetSyncResult> {
if !opts.force_remote && should_prefer_repo_assets() {
if let Some(repo_dir) = find_repo_sprites_dir() {
if let Some(publish_rev) = read_repo_publish_rev() {
if opts.target_rev.map_or(true, |t| publish_rev >= t) {
if !opts.quiet {
println!(
"Using repo sprites at {} (rev {publish_rev}); set FLATLAND_FORCE_ASSET_SYNC=1 to pull remote.",
repo_dir.display()
);
}
return Ok(AssetSyncResult {
state: LocalAssetState {
publish_rev,
sprites_dir: repo_dir,
},
kind: AssetSyncKind::RepoDevNoRemote,
});
}
}
}
}
let client = reqwest::Client::builder()
.user_agent(format!("flatland-client-lib/{}", env!("CARGO_PKG_VERSION")))
.connect_timeout(SYNC_CONNECT_TIMEOUT)
.timeout(SYNC_REQUEST_TIMEOUT)
.pool_max_idle_per_host(SYNC_CONCURRENCY)
.build()?;
let response = client.get(&opts.index_url).send().await?;
if response.status() == reqwest::StatusCode::NOT_FOUND {
return sync_without_remote_index(opts);
}
let index: AssetBundleIndex = response.error_for_status()?.json().await?;
if index.files.is_empty() {
anyhow::bail!("remote asset index is empty — refuse to sync");
}
if !index.files.contains_key("manifest.yaml") {
anyhow::bail!("remote asset index missing manifest.yaml");
}
sync_from_index(&client, opts, index).await
}
fn sync_without_remote_index(opts: AssetSyncOptions) -> anyhow::Result<AssetSyncResult> {
if let Some(local) = read_local_state() {
if local.sprites_dir.join("manifest.yaml").is_file() {
if !opts.quiet {
println!(
"Remote latest.json not found; using cached publish rev {}.",
local.publish_rev
);
}
return Ok(AssetSyncResult {
state: local,
kind: AssetSyncKind::LocalCacheNoRemote,
});
}
}
if let Some(repo) = find_repo_sprites_dir() {
let publish_rev = read_repo_publish_rev().unwrap_or(0);
if !opts.quiet {
println!(
"Remote latest.json not found; using repo sprites at {} (rev {publish_rev}).",
repo.display()
);
}
return Ok(AssetSyncResult {
state: LocalAssetState {
publish_rev,
sprites_dir: repo,
},
kind: AssetSyncKind::RepoDevNoRemote,
});
}
anyhow::bail!(
"Gfx asset bundle not published yet (HTTP 404 on latest.json). \
Server admin: flatland-admin content publish"
);
}
async fn sync_from_index(
client: &reqwest::Client,
opts: AssetSyncOptions,
index: AssetBundleIndex,
) -> anyhow::Result<AssetSyncResult> {
let publish_rev = opts.target_rev.unwrap_or(index.publish_rev);
if publish_rev != index.publish_rev {
anyhow::bail!(
"requested publish rev {publish_rev} but remote latest is {}",
index.publish_rev
);
}
if let Some(local) = read_local_state() {
if local.publish_rev == publish_rev && local.sprites_dir.join("manifest.yaml").is_file() {
if verify_rev_dir(&local.sprites_dir, &index)? {
if !opts.quiet {
println!(
"Assets up to date (publish rev {publish_rev}, {} files).",
index.files.len()
);
}
return Ok(AssetSyncResult {
state: local,
kind: AssetSyncKind::Remote,
});
}
if !opts.quiet {
println!(
"Cached rev {publish_rev} incomplete or corrupt — re-syncing {} files…",
index.files.len()
);
}
}
}
let rev_dir = sprites_dir_for_rev(publish_rev)?;
std::fs::create_dir_all(&rev_dir)?;
let total = index.files.len();
let mut pending: Vec<(String, AssetFileEntry)> = Vec::new();
let mut already_ok = 0usize;
for (rel, entry) in &index.files {
let dest = rev_dir.join(rel);
if dest.is_file() && sha256_file(&dest)? == entry.sha256 {
already_ok += 1;
continue;
}
pending.push((rel.clone(), entry.clone()));
}
if !opts.quiet {
println!(
"Syncing publish rev {publish_rev}: {already_ok}/{total} present, {} to download…",
pending.len()
);
}
download_files_concurrent(client, &opts, publish_rev, &rev_dir, pending, opts.quiet).await?;
if !verify_rev_dir(&rev_dir, &index)? {
anyhow::bail!(
"asset sync verification failed for rev {publish_rev} after download — try again"
);
}
let index_path = rev_dir.join(".bundle-index.json");
let _ = std::fs::write(&index_path, serde_json::to_vec_pretty(&index)?);
activate_rev_dir(&rev_dir)?;
let state = LocalAssetState {
publish_rev,
sprites_dir: rev_dir,
};
write_local_state(&state)?;
if !opts.quiet {
let paperdoll_n = index
.files
.keys()
.filter(|k| k.starts_with("paperdoll/"))
.count();
println!(
"Synced publish rev {publish_rev} → {} ({} files, {paperdoll_n} paperdoll)",
state.sprites_dir.display(),
index.files.len()
);
}
Ok(AssetSyncResult {
state,
kind: AssetSyncKind::Remote,
})
}
async fn download_files_concurrent(
client: &reqwest::Client,
opts: &AssetSyncOptions,
publish_rev: u64,
rev_dir: &Path,
pending: Vec<(String, AssetFileEntry)>,
quiet: bool,
) -> anyhow::Result<()> {
if pending.is_empty() {
return Ok(());
}
let total = pending.len();
let client = client.clone();
let prefix = opts.storage_prefix.clone();
let rev_dir = rev_dir.to_path_buf();
let pending = Arc::new(pending);
let done = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let mut join_set = tokio::task::JoinSet::new();
let mut next = 0usize;
while next < pending.len() || !join_set.is_empty() {
while join_set.len() < SYNC_CONCURRENCY && next < pending.len() {
let (rel, entry) = pending[next].clone();
next += 1;
let client = client.clone();
let prefix = prefix.clone();
let rev_dir = rev_dir.clone();
let done = Arc::clone(&done);
join_set.spawn(async move {
download_one_with_retries(&client, &prefix, publish_rev, &rev_dir, &rel, &entry)
.await?;
let n = done.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
if !quiet && (n % 25 == 0 || n == total) {
println!(" [{n}/{total}] synced…");
}
Ok::<(), anyhow::Error>(())
});
}
if let Some(res) = join_set.join_next().await {
res.map_err(|e| anyhow::anyhow!("asset download task join: {e}"))??;
}
}
Ok(())
}
async fn download_one_with_retries(
client: &reqwest::Client,
prefix: &str,
publish_rev: u64,
rev_dir: &Path,
rel: &str,
entry: &AssetFileEntry,
) -> anyhow::Result<()> {
let dest = rev_dir.join(rel);
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)?;
}
let url = firebase_download_url(
DEFAULT_FIREBASE_BUCKET,
&firebase_object_path_for_prefix(prefix, publish_rev, rel),
);
let mut last_err = None;
for attempt in 1..=SYNC_ATTEMPTS {
match download_one(client, &url, &dest, entry).await {
Ok(()) => return Ok(()),
Err(err) => {
last_err = Some(err);
if attempt < SYNC_ATTEMPTS {
let backoff = Duration::from_millis(200 * 2u64.pow(attempt - 1));
tokio::time::sleep(backoff).await;
}
}
}
}
Err(last_err.unwrap_or_else(|| anyhow::anyhow!("download failed for {rel}")))
}
async fn download_one(
client: &reqwest::Client,
url: &str,
dest: &Path,
entry: &AssetFileEntry,
) -> anyhow::Result<()> {
let bytes = client
.get(url)
.send()
.await?
.error_for_status()?
.bytes()
.await?;
if bytes.len() as u64 != entry.size && entry.size > 0 {
if sha256_bytes(&bytes) != entry.sha256 {
anyhow::bail!(
"size/hash mismatch (got {} bytes, expected {} / {})",
bytes.len(),
entry.size,
entry.sha256
);
}
} else if sha256_bytes(&bytes) != entry.sha256 {
anyhow::bail!("hash mismatch after download");
}
let tmp = dest.with_file_name(format!(
"{}.partial",
dest.file_name()
.and_then(|n| n.to_str())
.unwrap_or("asset")
));
std::fs::write(&tmp, &bytes)?;
std::fs::rename(&tmp, dest)?;
Ok(())
}
pub fn needs_asset_sync(server_publish_rev: u64) -> bool {
if server_publish_rev == 0 {
return false;
}
if let Some(local) = read_local_state() {
if local.publish_rev >= server_publish_rev
&& local.sprites_dir.join("manifest.yaml").is_file()
{
return false;
}
}
if should_prefer_repo_assets() {
if let Some(repo_rev) = read_repo_publish_rev() {
if repo_rev >= server_publish_rev && find_repo_sprites_dir().is_some() {
return false;
}
}
}
resolve_sprites_dir().is_none()
}
pub fn read_repo_publish_rev() -> Option<u64> {
let sprites = find_repo_sprites_dir()?;
let mut dir = sprites.as_path();
for _ in 0..8 {
let marker = dir.join(".content-publish.json");
if marker.is_file() {
#[derive(Deserialize)]
struct Marker {
rev: u64,
}
let text = std::fs::read_to_string(marker).ok()?;
let m: Marker = serde_json::from_str(&text).ok()?;
return Some(m.rev);
}
dir = dir.parent()?;
}
None
}
fn verify_rev_dir(dir: &Path, index: &AssetBundleIndex) -> anyhow::Result<bool> {
for (rel, entry) in &index.files {
let path = dir.join(rel);
if !path.is_file() {
return Ok(false);
}
if sha256_file(&path)? != entry.sha256 {
return Ok(false);
}
}
Ok(true)
}
fn sha256_bytes(bytes: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(bytes);
hex::encode(hasher.finalize())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn firebase_url_encodes_slashes() {
let url = firebase_download_url("bucket", "flatland3/client-assets/latest.json");
assert!(url.contains("%2F"));
assert!(url.contains("alt=media"));
}
#[test]
fn build_index_hashes_files() {
let dir = std::env::temp_dir().join(format!("flatland-assets-test-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(dir.join("cells")).expect("dir");
fs::write(dir.join("manifest.yaml"), "version: 1\n").expect("write");
fs::write(dir.join("cells/a.png"), b"png").expect("write");
let index = build_bundle_index(&dir, 3, "2026-01-01T00:00:00Z").expect("index");
assert_eq!(index.publish_rev, 3);
assert_eq!(index.files.len(), 2);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn build_client_index_includes_paperdoll_prefix() {
let root = std::env::temp_dir().join(format!(
"flatland-assets-paperdoll-{}",
std::process::id()
));
let sprites = root.join("sprites");
let paperdoll = root.join("paperdoll");
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(sprites.join("cells")).unwrap();
fs::create_dir_all(paperdoll.join("skins")).unwrap();
fs::write(sprites.join("manifest.yaml"), "version: 1\n").unwrap();
fs::write(paperdoll.join("skins/hero.yaml"), "skin: {}\n").unwrap();
let sources = ClientBundleSources {
sprites_dir: &sprites,
paperdoll_dir: Some(&paperdoll),
..ClientBundleSources::empty()
};
let index = build_client_bundle_index(&sources, 9, "2026-01-01T00:00:00Z").unwrap();
assert!(index.files.contains_key("manifest.yaml"));
assert!(index.files.contains_key("paperdoll/skins/hero.yaml"));
assert_eq!(
resolve_bundle_file_path(&sources, "paperdoll/skins/hero.yaml"),
paperdoll.join("skins/hero.yaml")
);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn needs_sync_false_when_repo_matches_server_rev() {
if should_prefer_repo_assets()
&& read_repo_publish_rev().is_some()
&& find_repo_sprites_dir().is_some()
{
let rev = read_repo_publish_rev().unwrap();
assert!(!needs_asset_sync(rev));
}
}
#[test]
fn repo_outranks_stale_synced_cache() {
assert!(repo_sprites_outrank_cache(Some(85), Some(75)));
assert!(repo_sprites_outrank_cache(Some(75), Some(75)));
assert!(!repo_sprites_outrank_cache(Some(70), Some(75)));
assert!(repo_sprites_outrank_cache(Some(85), None));
assert!(!repo_sprites_outrank_cache(None, Some(75)));
}
#[test]
fn prefer_repo_only_from_cargo_target_or_env() {
assert!(should_prefer_repo_assets() || std::env::var_os("FLATLAND_FORCE_ASSET_SYNC").is_some());
}
#[test]
fn build_client_index_includes_player_presentation() {
let root = std::env::temp_dir().join(format!(
"flatland-assets-pres-{}",
std::process::id()
));
let sprites = root.join("sprites");
let pres = root.join("player-presentation.yaml");
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(&sprites).unwrap();
fs::write(sprites.join("manifest.yaml"), "version: 1\n").unwrap();
fs::write(&pres, "player_presentation:\n paperdoll_ref: hero\n").unwrap();
let sources = ClientBundleSources {
sprites_dir: &sprites,
player_presentation: Some(&pres),
..ClientBundleSources::empty()
};
let index = build_client_bundle_index(&sources, 11, "2026-01-01T00:00:00Z").unwrap();
assert!(index.files.contains_key("player-presentation.yaml"));
assert_eq!(
resolve_bundle_file_path(&sources, "player-presentation.yaml"),
pres
);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn verify_rev_dir_detects_missing_and_corrupt() {
let dir = std::env::temp_dir().join(format!(
"flatland-assets-verify-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("manifest.yaml"), "version: 1\n").unwrap();
let index = build_bundle_index(&dir, 1, "2026-01-01T00:00:00Z").unwrap();
assert!(verify_rev_dir(&dir, &index).unwrap());
fs::write(dir.join("extra.png"), b"x").unwrap();
assert!(verify_rev_dir(&dir, &index).unwrap());
fs::write(dir.join("manifest.yaml"), "tampered\n").unwrap();
assert!(!verify_rev_dir(&dir, &index).unwrap());
fs::remove_file(dir.join("manifest.yaml")).unwrap();
assert!(!verify_rev_dir(&dir, &index).unwrap());
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn partial_download_path_keeps_full_name() {
let dest = PathBuf::from("/tmp/cells/hero.png");
let tmp = dest.with_file_name(format!(
"{}.partial",
dest.file_name().and_then(|n| n.to_str()).unwrap()
));
assert_eq!(tmp, PathBuf::from("/tmp/cells/hero.png.partial"));
}
}