1use std::collections::BTreeMap;
4use std::path::{Path, PathBuf};
5
6use anyhow::Context;
7
8use crate::asset_backend::{mime_for_path, AssetBackendConfig, AssetStore};
9use crate::assets::{sha256_file, AssetBundleIndex, AssetFileEntry};
10
11pub const SIM_PACK_ASSET_DIRS: &[&str] = &[
13 "crafting",
14 "items",
15 "world",
16 "config",
17 "npcs",
18 "loot",
19 "combat",
20 "dialogue",
21 "quests",
22 "crops",
23 "gfx/sprites",
24 "paperdoll",
25];
26
27pub fn build_sim_pack_index(
29 repo_root: &Path,
30 publish_rev: u64,
31 published_at: &str,
32) -> anyhow::Result<AssetBundleIndex> {
33 let assets = repo_root.join("assets");
34 let mut files = BTreeMap::new();
35 for rel_dir in SIM_PACK_ASSET_DIRS {
36 let dir = assets.join(rel_dir);
37 if dir.is_dir() {
38 walk_into(&dir, &assets, &mut files)?;
39 }
40 }
41 let marker = assets.join(".content-publish.json");
43 if marker.is_file() {
44 let meta = std::fs::metadata(&marker)?;
45 files.insert(
46 ".content-publish.json".to_string(),
47 AssetFileEntry {
48 sha256: sha256_file(&marker)?,
49 size: meta.len(),
50 },
51 );
52 }
53 if files.is_empty() {
54 anyhow::bail!("sim pack is empty — expected content under {}", assets.display());
55 }
56 Ok(AssetBundleIndex {
57 publish_rev,
58 published_at: published_at.to_string(),
59 files,
60 })
61}
62
63fn walk_into(
64 dir: &Path,
65 assets_root: &Path,
66 files: &mut BTreeMap<String, AssetFileEntry>,
67) -> anyhow::Result<()> {
68 for entry in std::fs::read_dir(dir).with_context(|| format!("read_dir {}", dir.display()))? {
69 let entry = entry?;
70 let path = entry.path();
71 let name = entry.file_name();
72 let name_str = name.to_string_lossy();
73 if name_str.starts_with('.') || name_str == "node_modules" {
74 continue;
75 }
76 if name_str.ends_with(".map-locks.json") {
77 continue;
78 }
79 if path.is_dir() {
80 walk_into(&path, assets_root, files)?;
81 continue;
82 }
83 if !path.is_file() {
84 continue;
85 }
86 let rel = path
87 .strip_prefix(assets_root)
88 .with_context(|| format!("strip {}", path.display()))?
89 .to_string_lossy()
90 .replace('\\', "/");
91 let meta = std::fs::metadata(&path)?;
92 files.insert(
93 rel,
94 AssetFileEntry {
95 sha256: sha256_file(&path)?,
96 size: meta.len(),
97 },
98 );
99 }
100 Ok(())
101}
102
103pub fn resolve_sim_pack_file(repo_root: &Path, relative: &str) -> PathBuf {
105 repo_root.join("assets").join(relative)
106}
107
108pub async fn upload_sim_pack(
110 store: &dyn AssetStore,
111 cfg: &AssetBackendConfig,
112 repo_root: &Path,
113 index: &AssetBundleIndex,
114) -> anyhow::Result<()> {
115 for (rel, _) in &index.files {
116 let path = resolve_sim_pack_file(repo_root, rel);
117 let bytes = std::fs::read(&path)
118 .with_context(|| format!("read sim pack file {}", path.display()))?;
119 let key = cfg.sim_object_key(index.publish_rev, rel);
120 store
121 .put(&key, mime_for_path(&path), &bytes)
122 .await
123 .with_context(|| format!("upload sim {key}"))?;
124 }
125 let latest = serde_json::to_vec(index)?;
126 store
127 .put(&cfg.sim_index_object(), "application/json", &latest)
128 .await
129 .context("upload sim latest.json")?;
130 Ok(())
131}
132
133pub async fn download_sim_pack(
135 store: &dyn AssetStore,
136 cfg: &AssetBackendConfig,
137 index: &AssetBundleIndex,
138 dest_root: &Path,
139) -> anyhow::Result<PathBuf> {
140 let assets_out = dest_root.join("assets");
141 std::fs::create_dir_all(&assets_out)?;
142
143 const CONCURRENCY: usize = 8;
144 let pending: Vec<(String, AssetFileEntry)> = index
145 .files
146 .iter()
147 .map(|(k, v)| (k.clone(), v.clone()))
148 .collect();
149 let total = pending.len();
150 let mut join_set = tokio::task::JoinSet::new();
151 let mut next = 0usize;
152 let done = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
153
154 while next < pending.len() || !join_set.is_empty() {
157 while join_set.len() < CONCURRENCY && next < pending.len() {
158 let (rel, entry) = pending[next].clone();
159 next += 1;
160 let cfg = cfg.clone();
161 let assets_out = assets_out.clone();
162 let publish_rev = index.publish_rev;
163 let done = std::sync::Arc::clone(&done);
164 join_set.spawn(async move {
165 let store = crate::asset_backend::store_from_config(&cfg)?;
166 let key = cfg.sim_object_key(publish_rev, &rel);
167 let bytes = store
168 .get(&key)
169 .await
170 .with_context(|| format!("download sim {key}"))?;
171 let got = {
172 use sha2::{Digest, Sha256};
173 hex::encode(Sha256::digest(&bytes))
174 };
175 if got != entry.sha256 {
176 anyhow::bail!("sim pack hash mismatch for {rel}");
177 }
178 let dest = assets_out.join(&rel);
179 if let Some(parent) = dest.parent() {
180 std::fs::create_dir_all(parent)?;
181 }
182 std::fs::write(&dest, &bytes)?;
183 let n = done.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
184 if n % 100 == 0 || n == total {
185 tracing::info!(n, total, "sim pack download progress");
186 }
187 Ok::<(), anyhow::Error>(())
188 });
189 }
190 if let Some(res) = join_set.join_next().await {
191 res.map_err(|e| anyhow::anyhow!("sim pack download join: {e}"))??;
192 }
193 }
194 Ok(dest_root.to_path_buf())
195}
196
197pub fn sim_pack_cache_dir(publish_rev: u64) -> anyhow::Result<PathBuf> {
199 let base = dirs::home_dir().ok_or_else(|| anyhow::anyhow!("home directory not found"))?;
200 Ok(base
201 .join(".flatland3")
202 .join("sim-packs")
203 .join(format!("rev-{publish_rev}")))
204}
205
206pub async fn ensure_sim_pack_cached(
208 target_rev: Option<u64>,
209) -> anyhow::Result<(u64, PathBuf)> {
210 let cfg = AssetBackendConfig::from_env();
211 let store = crate::asset_backend::store_from_config(&cfg)?;
212 let index_bytes = store.get(&cfg.sim_index_object()).await.with_context(|| {
213 format!(
214 "fetch sim latest.json ({})",
215 cfg.sim_index_object()
216 )
217 })?;
218 let index: AssetBundleIndex = serde_json::from_slice(&index_bytes)?;
219 let rev = target_rev.unwrap_or(index.publish_rev);
220 if rev != index.publish_rev {
221 anyhow::bail!(
222 "requested sim pack rev {rev} but remote latest is {}",
223 index.publish_rev
224 );
225 }
226 let dest = sim_pack_cache_dir(rev)?;
227 if sim_pack_cache_complete(&dest, &index)? {
228 return Ok((rev, dest));
229 }
230 let _ = std::fs::remove_dir_all(&dest);
232 download_sim_pack(store.as_ref(), &cfg, &index, &dest).await?;
233 if !sim_pack_cache_complete(&dest, &index)? {
234 anyhow::bail!("sim pack rev {rev} incomplete after download");
235 }
236 Ok((rev, dest))
237}
238
239fn sim_pack_cache_complete(dest_root: &Path, index: &AssetBundleIndex) -> anyhow::Result<bool> {
240 let assets = dest_root.join("assets");
241 if !assets.is_dir() {
242 return Ok(false);
243 }
244 for (rel, entry) in &index.files {
245 let path = assets.join(rel);
246 if !path.is_file() {
247 return Ok(false);
248 }
249 let meta = std::fs::metadata(&path)?;
250 if meta.len() != entry.size && entry.size > 0 {
251 return Ok(false);
253 }
254 }
255 Ok(true)
256}
257
258#[cfg(test)]
259mod tests {
260 use super::*;
261 use crate::asset_backend::LocalAssetStore;
262
263 #[tokio::test]
264 async fn local_sim_pack_roundtrip() {
265 let repo = match flatland_repo_fixture() {
266 Some(p) => p,
267 None => return,
268 };
269 let tmp = std::env::temp_dir().join(format!(
270 "flatland-sim-pack-test-{}",
271 std::process::id()
272 ));
273 let _ = std::fs::remove_dir_all(&tmp);
274 std::fs::create_dir_all(&tmp).unwrap();
275 let store = LocalAssetStore {
276 root: tmp.join("store"),
277 };
278 let cfg = AssetBackendConfig {
279 kind: crate::asset_backend::AssetBackendKind::Local,
280 bucket: "local".into(),
281 client_prefix: "client".into(),
282 sim_prefix: "sim".into(),
283 local_root: tmp.join("store"),
284 s3_region: "us-east-1".into(),
285 s3_endpoint: None,
286 index_url_override: None,
287 };
288 let index = build_sim_pack_index(&repo, 42, "test").expect("index");
289 assert!(!index.files.is_empty());
290 upload_sim_pack(&store, &cfg, &repo, &index)
291 .await
292 .expect("upload");
293 let dest = tmp.join("out");
294 download_sim_pack(&store, &cfg, &index, &dest)
295 .await
296 .expect("download");
297 assert!(dest.join("assets/world").is_dir() || dest.join("assets/items").is_dir());
298 let _ = std::fs::remove_dir_all(&tmp);
299 }
300
301 fn flatland_repo_fixture() -> Option<PathBuf> {
302 let mut dir = std::env::current_dir().ok()?;
303 for _ in 0..8 {
304 if dir.join("assets/world/segments").is_dir() {
305 return Some(dir);
306 }
307 if !dir.pop() {
308 break;
309 }
310 }
311 None
312 }
313}