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!(
55 "sim pack is empty — expected content under {}",
56 assets.display()
57 );
58 }
59 Ok(AssetBundleIndex {
60 publish_rev,
61 published_at: published_at.to_string(),
62 files,
63 })
64}
65
66fn walk_into(
67 dir: &Path,
68 assets_root: &Path,
69 files: &mut BTreeMap<String, AssetFileEntry>,
70) -> anyhow::Result<()> {
71 for entry in std::fs::read_dir(dir).with_context(|| format!("read_dir {}", dir.display()))? {
72 let entry = entry?;
73 let path = entry.path();
74 let name = entry.file_name();
75 let name_str = name.to_string_lossy();
76 if name_str.starts_with('.') || name_str == "node_modules" {
77 continue;
78 }
79 if name_str.ends_with(".map-locks.json") {
80 continue;
81 }
82 if path.is_dir() {
83 walk_into(&path, assets_root, files)?;
84 continue;
85 }
86 if !path.is_file() {
87 continue;
88 }
89 let rel = path
90 .strip_prefix(assets_root)
91 .with_context(|| format!("strip {}", path.display()))?
92 .to_string_lossy()
93 .replace('\\', "/");
94 let meta = std::fs::metadata(&path)?;
95 files.insert(
96 rel,
97 AssetFileEntry {
98 sha256: sha256_file(&path)?,
99 size: meta.len(),
100 },
101 );
102 }
103 Ok(())
104}
105
106pub fn resolve_sim_pack_file(repo_root: &Path, relative: &str) -> PathBuf {
108 repo_root.join("assets").join(relative)
109}
110
111pub async fn upload_sim_pack(
113 store: &dyn AssetStore,
114 cfg: &AssetBackendConfig,
115 repo_root: &Path,
116 index: &AssetBundleIndex,
117) -> anyhow::Result<()> {
118 for (rel, _) in &index.files {
119 let path = resolve_sim_pack_file(repo_root, rel);
120 let bytes = std::fs::read(&path)
121 .with_context(|| format!("read sim pack file {}", path.display()))?;
122 let key = cfg.sim_object_key(index.publish_rev, rel);
123 store
124 .put(&key, mime_for_path(&path), &bytes)
125 .await
126 .with_context(|| format!("upload sim {key}"))?;
127 }
128 let latest = serde_json::to_vec(index)?;
129 store
130 .put(&cfg.sim_index_object(), "application/json", &latest)
131 .await
132 .context("upload sim latest.json")?;
133 Ok(())
134}
135
136pub async fn download_sim_pack(
138 store: &dyn AssetStore,
139 cfg: &AssetBackendConfig,
140 index: &AssetBundleIndex,
141 dest_root: &Path,
142) -> anyhow::Result<PathBuf> {
143 let assets_out = dest_root.join("assets");
144 std::fs::create_dir_all(&assets_out)?;
145
146 const CONCURRENCY: usize = 8;
147 let pending: Vec<(String, AssetFileEntry)> = index
148 .files
149 .iter()
150 .map(|(k, v)| (k.clone(), v.clone()))
151 .collect();
152 let total = pending.len();
153 let mut join_set = tokio::task::JoinSet::new();
154 let mut next = 0usize;
155 let done = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
156
157 while next < pending.len() || !join_set.is_empty() {
160 while join_set.len() < CONCURRENCY && next < pending.len() {
161 let (rel, entry) = pending[next].clone();
162 next += 1;
163 let cfg = cfg.clone();
164 let assets_out = assets_out.clone();
165 let publish_rev = index.publish_rev;
166 let done = std::sync::Arc::clone(&done);
167 join_set.spawn(async move {
168 let store = crate::asset_backend::store_from_config(&cfg)?;
169 let key = cfg.sim_object_key(publish_rev, &rel);
170 let bytes = store
171 .get(&key)
172 .await
173 .with_context(|| format!("download sim {key}"))?;
174 let got = {
175 use sha2::{Digest, Sha256};
176 hex::encode(Sha256::digest(&bytes))
177 };
178 if got != entry.sha256 {
179 anyhow::bail!("sim pack hash mismatch for {rel}");
180 }
181 let dest = assets_out.join(&rel);
182 if let Some(parent) = dest.parent() {
183 std::fs::create_dir_all(parent)?;
184 }
185 std::fs::write(&dest, &bytes)?;
186 let n = done.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
187 if n % 100 == 0 || n == total {
188 tracing::info!(n, total, "sim pack download progress");
189 }
190 Ok::<(), anyhow::Error>(())
191 });
192 }
193 if let Some(res) = join_set.join_next().await {
194 res.map_err(|e| anyhow::anyhow!("sim pack download join: {e}"))??;
195 }
196 }
197 Ok(dest_root.to_path_buf())
198}
199
200pub fn sim_pack_cache_dir(publish_rev: u64) -> anyhow::Result<PathBuf> {
202 let base = dirs::home_dir().ok_or_else(|| anyhow::anyhow!("home directory not found"))?;
203 Ok(base
204 .join(".flatland3")
205 .join("sim-packs")
206 .join(format!("rev-{publish_rev}")))
207}
208
209pub async fn ensure_sim_pack_cached(target_rev: Option<u64>) -> anyhow::Result<(u64, PathBuf)> {
216 let cfg = AssetBackendConfig::from_env();
217 let store = crate::asset_backend::store_from_config(&cfg)?;
218 let index_bytes = store
219 .get(&cfg.sim_index_object())
220 .await
221 .with_context(|| format!("fetch sim latest.json ({})", cfg.sim_index_object()))?;
222 let index: AssetBundleIndex = serde_json::from_slice(&index_bytes)?;
223 let remote_rev = index.publish_rev;
224
225 if let Some((local_rev, dest)) = maybe_seed_sim_pack_from_repo_assets(remote_rev)? {
226 return Ok((local_rev, dest));
227 }
228
229 let rev = target_rev.unwrap_or(remote_rev);
230 if rev != remote_rev {
231 anyhow::bail!(
232 "requested sim pack rev {rev} but remote latest is {remote_rev}"
233 );
234 }
235 let dest = sim_pack_cache_dir(rev)?;
236 if sim_pack_cache_complete(&dest, &index)? {
237 return Ok((rev, dest));
238 }
239 let _ = std::fs::remove_dir_all(&dest);
241 download_sim_pack(store.as_ref(), &cfg, &index, &dest).await?;
242 if !sim_pack_cache_complete(&dest, &index)? {
243 anyhow::bail!("sim pack rev {rev} incomplete after download");
244 }
245 Ok((rev, dest))
246}
247
248fn prefer_repo_assets_over_remote() -> bool {
249 std::env::var("FLATLAND_SIM_PACK_PREFER_REPO_ASSETS")
250 .map(|v| matches!(v.as_str(), "1" | "true" | "TRUE" | "yes" | "YES"))
251 .unwrap_or(true)
252}
253
254fn maybe_seed_sim_pack_from_repo_assets(
258 remote_rev: u64,
259) -> anyhow::Result<Option<(u64, PathBuf)>> {
260 if !prefer_repo_assets_over_remote() {
261 return Ok(None);
262 }
263 let Some(repo) = find_repo_root_for_sim_pack() else {
264 return Ok(None);
265 };
266 let marker_path = repo.join("assets/.content-publish.json");
267 let Ok(text) = std::fs::read_to_string(&marker_path) else {
268 return Ok(None);
269 };
270 let marker: serde_json::Value = serde_json::from_str(&text)?;
271 let local_rev = marker
272 .get("rev")
273 .and_then(|v| v.as_u64())
274 .ok_or_else(|| anyhow::anyhow!("assets/.content-publish.json missing rev"))?;
275 if local_rev < remote_rev {
278 return Ok(None);
279 }
280 let assets = repo.join("assets");
281 if !assets.join("items").is_dir() {
282 return Ok(None);
283 }
284 let dest = sim_pack_cache_dir(local_rev)?;
285 let dest_assets = dest.join("assets");
286 std::fs::create_dir_all(&dest_assets)?;
287 for rel_dir in SIM_PACK_ASSET_DIRS {
289 let src = assets.join(rel_dir);
290 if !src.is_dir() {
291 continue;
292 }
293 let dst = dest_assets.join(rel_dir);
294 copy_dir_recursive(&src, &dst)?;
295 }
296 if marker_path.is_file() {
297 std::fs::copy(&marker_path, dest_assets.join(".content-publish.json"))?;
298 }
299 let stamp = serde_json::json!({
301 "seeded_from": assets.display().to_string(),
302 "local_rev": local_rev,
303 "remote_latest_seen": remote_rev,
304 "reason": "repo assets at/above remote sim latest — prefer local (clone/rsync auto-heal)",
305 });
306 std::fs::write(
307 dest.join(".seeded-from-repo.json"),
308 serde_json::to_string_pretty(&stamp)?,
309 )?;
310 Ok(Some((local_rev, dest)))
311}
312
313fn find_repo_root_for_sim_pack() -> Option<PathBuf> {
314 if let Ok(root) = std::env::var("FLATLAND_REPO_ROOT") {
315 let p = PathBuf::from(root);
316 if p.join("assets/items").is_dir() {
317 return Some(p);
318 }
319 }
320 if let Ok(cwd) = std::env::current_dir() {
322 let mut dir = cwd;
323 for _ in 0..6 {
324 if dir.join("assets/items").is_dir() && dir.join("assets/world").is_dir() {
325 return Some(dir);
326 }
327 if !dir.pop() {
328 break;
329 }
330 }
331 }
332 None
333}
334
335fn copy_dir_recursive(src: &Path, dst: &Path) -> anyhow::Result<()> {
336 std::fs::create_dir_all(dst)?;
337 for entry in std::fs::read_dir(src).with_context(|| format!("read_dir {}", src.display()))? {
338 let entry = entry?;
339 let name = entry.file_name();
340 let name_str = name.to_string_lossy();
341 if name_str.starts_with('.') || name_str == "node_modules" {
342 continue;
343 }
344 if name_str.ends_with(".map-locks.json") {
345 continue;
346 }
347 let from = entry.path();
348 let to = dst.join(&name);
349 if from.is_dir() {
350 copy_dir_recursive(&from, &to)?;
351 } else if from.is_file() {
352 if let Some(parent) = to.parent() {
353 std::fs::create_dir_all(parent)?;
354 }
355 std::fs::copy(&from, &to)
356 .with_context(|| format!("copy {} → {}", from.display(), to.display()))?;
357 }
358 }
359 Ok(())
360}
361
362fn sim_pack_cache_complete(dest_root: &Path, index: &AssetBundleIndex) -> anyhow::Result<bool> {
363 let assets = dest_root.join("assets");
364 if !assets.is_dir() {
365 return Ok(false);
366 }
367 for (rel, entry) in &index.files {
368 let path = assets.join(rel);
369 if !path.is_file() {
370 return Ok(false);
371 }
372 let meta = std::fs::metadata(&path)?;
373 if meta.len() != entry.size && entry.size > 0 {
374 return Ok(false);
376 }
377 }
378 Ok(true)
379}
380
381#[cfg(test)]
382mod tests {
383 use super::*;
384 use crate::asset_backend::LocalAssetStore;
385
386 #[tokio::test]
387 async fn local_sim_pack_roundtrip() {
388 let repo = match flatland_repo_fixture() {
389 Some(p) => p,
390 None => return,
391 };
392 let tmp =
393 std::env::temp_dir().join(format!("flatland-sim-pack-test-{}", std::process::id()));
394 let _ = std::fs::remove_dir_all(&tmp);
395 std::fs::create_dir_all(&tmp).unwrap();
396 let store = LocalAssetStore {
397 root: tmp.join("store"),
398 };
399 let cfg = AssetBackendConfig {
400 kind: crate::asset_backend::AssetBackendKind::Local,
401 bucket: "local".into(),
402 client_prefix: "client".into(),
403 sim_prefix: "sim".into(),
404 local_root: tmp.join("store"),
405 s3_region: "us-east-1".into(),
406 s3_endpoint: None,
407 index_url_override: None,
408 };
409 let index = build_sim_pack_index(&repo, 42, "test").expect("index");
410 assert!(!index.files.is_empty());
411 upload_sim_pack(&store, &cfg, &repo, &index)
412 .await
413 .expect("upload");
414 let dest = tmp.join("out");
415 download_sim_pack(&store, &cfg, &index, &dest)
416 .await
417 .expect("download");
418 assert!(dest.join("assets/world").is_dir() || dest.join("assets/items").is_dir());
419 let _ = std::fs::remove_dir_all(&tmp);
420 }
421
422 fn flatland_repo_fixture() -> Option<PathBuf> {
423 let mut dir = std::env::current_dir().ok()?;
424 for _ in 0..8 {
425 if dir.join("assets/world/segments").is_dir() {
426 return Some(dir);
427 }
428 if !dir.pop() {
429 break;
430 }
431 }
432 None
433 }
434}