use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use anyhow::Context;
use crate::asset_backend::{mime_for_path, AssetBackendConfig, AssetStore};
use crate::assets::{sha256_file, AssetBundleIndex, AssetFileEntry};
pub const SIM_PACK_ASSET_DIRS: &[&str] = &[
"crafting",
"items",
"world",
"config",
"npcs",
"loot",
"combat",
"dialogue",
"quests",
"crops",
"gfx/sprites",
"paperdoll",
];
pub fn build_sim_pack_index(
repo_root: &Path,
publish_rev: u64,
published_at: &str,
) -> anyhow::Result<AssetBundleIndex> {
let assets = repo_root.join("assets");
let mut files = BTreeMap::new();
for rel_dir in SIM_PACK_ASSET_DIRS {
let dir = assets.join(rel_dir);
if dir.is_dir() {
walk_into(&dir, &assets, &mut files)?;
}
}
let marker = assets.join(".content-publish.json");
if marker.is_file() {
let meta = std::fs::metadata(&marker)?;
files.insert(
".content-publish.json".to_string(),
AssetFileEntry {
sha256: sha256_file(&marker)?,
size: meta.len(),
},
);
}
if files.is_empty() {
anyhow::bail!("sim pack is empty — expected content under {}", assets.display());
}
Ok(AssetBundleIndex {
publish_rev,
published_at: published_at.to_string(),
files,
})
}
fn walk_into(
dir: &Path,
assets_root: &Path,
files: &mut BTreeMap<String, AssetFileEntry>,
) -> anyhow::Result<()> {
for entry in std::fs::read_dir(dir).with_context(|| format!("read_dir {}", dir.display()))? {
let entry = entry?;
let path = entry.path();
let name = entry.file_name();
let name_str = name.to_string_lossy();
if name_str.starts_with('.') || name_str == "node_modules" {
continue;
}
if name_str.ends_with(".map-locks.json") {
continue;
}
if path.is_dir() {
walk_into(&path, assets_root, files)?;
continue;
}
if !path.is_file() {
continue;
}
let rel = path
.strip_prefix(assets_root)
.with_context(|| format!("strip {}", path.display()))?
.to_string_lossy()
.replace('\\', "/");
let meta = std::fs::metadata(&path)?;
files.insert(
rel,
AssetFileEntry {
sha256: sha256_file(&path)?,
size: meta.len(),
},
);
}
Ok(())
}
pub fn resolve_sim_pack_file(repo_root: &Path, relative: &str) -> PathBuf {
repo_root.join("assets").join(relative)
}
pub async fn upload_sim_pack(
store: &dyn AssetStore,
cfg: &AssetBackendConfig,
repo_root: &Path,
index: &AssetBundleIndex,
) -> anyhow::Result<()> {
for (rel, _) in &index.files {
let path = resolve_sim_pack_file(repo_root, rel);
let bytes = std::fs::read(&path)
.with_context(|| format!("read sim pack file {}", path.display()))?;
let key = cfg.sim_object_key(index.publish_rev, rel);
store
.put(&key, mime_for_path(&path), &bytes)
.await
.with_context(|| format!("upload sim {key}"))?;
}
let latest = serde_json::to_vec(index)?;
store
.put(&cfg.sim_index_object(), "application/json", &latest)
.await
.context("upload sim latest.json")?;
Ok(())
}
pub async fn download_sim_pack(
store: &dyn AssetStore,
cfg: &AssetBackendConfig,
index: &AssetBundleIndex,
dest_root: &Path,
) -> anyhow::Result<PathBuf> {
let assets_out = dest_root.join("assets");
std::fs::create_dir_all(&assets_out)?;
const CONCURRENCY: usize = 8;
let pending: Vec<(String, AssetFileEntry)> = index
.files
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
let total = pending.len();
let mut join_set = tokio::task::JoinSet::new();
let mut next = 0usize;
let done = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
while next < pending.len() || !join_set.is_empty() {
while join_set.len() < CONCURRENCY && next < pending.len() {
let (rel, entry) = pending[next].clone();
next += 1;
let cfg = cfg.clone();
let assets_out = assets_out.clone();
let publish_rev = index.publish_rev;
let done = std::sync::Arc::clone(&done);
join_set.spawn(async move {
let store = crate::asset_backend::store_from_config(&cfg)?;
let key = cfg.sim_object_key(publish_rev, &rel);
let bytes = store
.get(&key)
.await
.with_context(|| format!("download sim {key}"))?;
let got = {
use sha2::{Digest, Sha256};
hex::encode(Sha256::digest(&bytes))
};
if got != entry.sha256 {
anyhow::bail!("sim pack hash mismatch for {rel}");
}
let dest = assets_out.join(&rel);
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&dest, &bytes)?;
let n = done.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
if n % 100 == 0 || n == total {
tracing::info!(n, total, "sim pack download progress");
}
Ok::<(), anyhow::Error>(())
});
}
if let Some(res) = join_set.join_next().await {
res.map_err(|e| anyhow::anyhow!("sim pack download join: {e}"))??;
}
}
Ok(dest_root.to_path_buf())
}
pub fn sim_pack_cache_dir(publish_rev: u64) -> anyhow::Result<PathBuf> {
let base = dirs::home_dir().ok_or_else(|| anyhow::anyhow!("home directory not found"))?;
Ok(base
.join(".flatland3")
.join("sim-packs")
.join(format!("rev-{publish_rev}")))
}
pub async fn ensure_sim_pack_cached(
target_rev: Option<u64>,
) -> anyhow::Result<(u64, PathBuf)> {
let cfg = AssetBackendConfig::from_env();
let store = crate::asset_backend::store_from_config(&cfg)?;
let index_bytes = store.get(&cfg.sim_index_object()).await.with_context(|| {
format!(
"fetch sim latest.json ({})",
cfg.sim_index_object()
)
})?;
let index: AssetBundleIndex = serde_json::from_slice(&index_bytes)?;
let rev = target_rev.unwrap_or(index.publish_rev);
if rev != index.publish_rev {
anyhow::bail!(
"requested sim pack rev {rev} but remote latest is {}",
index.publish_rev
);
}
let dest = sim_pack_cache_dir(rev)?;
if sim_pack_cache_complete(&dest, &index)? {
return Ok((rev, dest));
}
let _ = std::fs::remove_dir_all(&dest);
download_sim_pack(store.as_ref(), &cfg, &index, &dest).await?;
if !sim_pack_cache_complete(&dest, &index)? {
anyhow::bail!("sim pack rev {rev} incomplete after download");
}
Ok((rev, dest))
}
fn sim_pack_cache_complete(dest_root: &Path, index: &AssetBundleIndex) -> anyhow::Result<bool> {
let assets = dest_root.join("assets");
if !assets.is_dir() {
return Ok(false);
}
for (rel, entry) in &index.files {
let path = assets.join(rel);
if !path.is_file() {
return Ok(false);
}
let meta = std::fs::metadata(&path)?;
if meta.len() != entry.size && entry.size > 0 {
return Ok(false);
}
}
Ok(true)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::asset_backend::LocalAssetStore;
#[tokio::test]
async fn local_sim_pack_roundtrip() {
let repo = match flatland_repo_fixture() {
Some(p) => p,
None => return,
};
let tmp = std::env::temp_dir().join(format!(
"flatland-sim-pack-test-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&tmp);
std::fs::create_dir_all(&tmp).unwrap();
let store = LocalAssetStore {
root: tmp.join("store"),
};
let cfg = AssetBackendConfig {
kind: crate::asset_backend::AssetBackendKind::Local,
bucket: "local".into(),
client_prefix: "client".into(),
sim_prefix: "sim".into(),
local_root: tmp.join("store"),
s3_region: "us-east-1".into(),
s3_endpoint: None,
index_url_override: None,
};
let index = build_sim_pack_index(&repo, 42, "test").expect("index");
assert!(!index.files.is_empty());
upload_sim_pack(&store, &cfg, &repo, &index)
.await
.expect("upload");
let dest = tmp.join("out");
download_sim_pack(&store, &cfg, &index, &dest)
.await
.expect("download");
assert!(dest.join("assets/world").is_dir() || dest.join("assets/items").is_dir());
let _ = std::fs::remove_dir_all(&tmp);
}
fn flatland_repo_fixture() -> Option<PathBuf> {
let mut dir = std::env::current_dir().ok()?;
for _ in 0..8 {
if dir.join("assets/world/segments").is_dir() {
return Some(dir);
}
if !dir.pop() {
break;
}
}
None
}
}