Skip to main content

flatland_client_lib/
publish_packs.rs

1//! Validate + bump publish rev + upload client/sim packs to the asset backend.
2
3use std::path::{Path, PathBuf};
4
5use anyhow::Context;
6
7use crate::asset_backend::{
8    mime_for_path, store_from_config, AssetBackendConfig, AssetBackendKind, AssetStore,
9};
10use crate::assets::{
11    build_client_bundle_index, resolve_bundle_file_path, AssetBundleIndex, ClientBundleSources,
12};
13use crate::content_pack::{build_sim_pack_index, upload_sim_pack};
14
15#[derive(Debug, Clone)]
16pub struct PublishPacksResult {
17    pub rev: u64,
18    pub published_at: String,
19    pub summary: String,
20    pub client_files: usize,
21    pub sim_files: usize,
22    pub uploaded: bool,
23    pub backend_kind: String,
24    pub bucket: String,
25    pub dest: String,
26}
27
28/// Shared Publish path for content-admin and `flatland-admin content publish`.
29///
30/// `write_marker` bumps `assets/.content-publish.json`. When `skip_upload` is false,
31/// uploads client gfx + sim content packs to [`AssetBackendConfig::from_env`].
32pub async fn publish_content_packs(
33    repo_root: &Path,
34    summary: String,
35    publish_rev: u64,
36    published_at: String,
37    skip_upload: bool,
38) -> anyhow::Result<PublishPacksResult> {
39    publish_content_packs_opts(
40        repo_root,
41        summary,
42        publish_rev,
43        published_at,
44        skip_upload,
45        true,
46    )
47    .await
48}
49
50/// Like [`publish_content_packs`], with control over sim pack upload (smoke tests skip sim).
51pub async fn publish_content_packs_opts(
52    repo_root: &Path,
53    summary: String,
54    publish_rev: u64,
55    published_at: String,
56    skip_upload: bool,
57    upload_sim: bool,
58) -> anyhow::Result<PublishPacksResult> {
59    let (client_index, sources) = build_client_sources(repo_root, publish_rev, &published_at)?;
60    let sim_index = build_sim_pack_index(repo_root, publish_rev, &published_at)?;
61    let cfg = AssetBackendConfig::from_env();
62    let mut uploaded = false;
63    if !skip_upload {
64        let store = store_from_config(&cfg)?;
65        upload_client_bundle(store.as_ref(), &cfg, &sources, &client_index)
66            .await
67            .context("upload client asset pack")?;
68        if upload_sim {
69            upload_sim_pack(store.as_ref(), &cfg, repo_root, &sim_index)
70                .await
71                .context("upload sim content pack")?;
72        }
73        uploaded = true;
74    }
75    Ok(PublishPacksResult {
76        rev: publish_rev,
77        published_at,
78        summary,
79        client_files: client_index.files.len(),
80        sim_files: sim_index.files.len(),
81        uploaded,
82        backend_kind: backend_label(&cfg).to_string(),
83        bucket: cfg.bucket.clone(),
84        dest: describe_dest(&cfg, &cfg.client_prefix, publish_rev),
85    })
86}
87
88struct ClientBundleOwned {
89    sprites_dir: PathBuf,
90    paperdoll_dir: Option<PathBuf>,
91    player_presentation: Option<PathBuf>,
92    player_art_dir: Option<PathBuf>,
93    client_settings: Option<PathBuf>,
94    terrain_kinds: Option<PathBuf>,
95    audio_sfx_dir: Option<PathBuf>,
96}
97
98impl ClientBundleOwned {
99    fn as_refs(&self) -> ClientBundleSources<'_> {
100        ClientBundleSources {
101            sprites_dir: &self.sprites_dir,
102            paperdoll_dir: self.paperdoll_dir.as_deref(),
103            player_presentation: self.player_presentation.as_deref(),
104            player_art_dir: self.player_art_dir.as_deref(),
105            client_settings: self.client_settings.as_deref(),
106            terrain_kinds: self.terrain_kinds.as_deref(),
107            audio_sfx_dir: self.audio_sfx_dir.as_deref(),
108        }
109    }
110}
111
112fn build_client_sources(
113    repo_root: &Path,
114    publish_rev: u64,
115    published_at: &str,
116) -> anyhow::Result<(AssetBundleIndex, ClientBundleOwned)> {
117    // Prefer repo sprites for pack builds — `default_sprites_dir()` may resolve to
118    // `~/.flatland3/assets/current`, which nests an older paperdoll tree and breaks uploads.
119    let sprites_dir = {
120        let repo_sprites = repo_root.join("assets/gfx/sprites");
121        if repo_sprites.join("manifest.yaml").is_file() {
122            repo_sprites
123        } else {
124            flatland_presentation::default_sprites_dir().ok_or_else(|| {
125                anyhow::anyhow!("gfx sprites dir not found under assets/gfx/sprites")
126            })?
127        }
128    };
129    let paperdoll_dir = repo_root
130        .join("assets/paperdoll")
131        .is_dir()
132        .then(|| repo_root.join("assets/paperdoll"));
133    let player_presentation = {
134        let p = repo_root.join("assets/gfx/player-presentation.yaml");
135        p.is_file().then_some(p)
136    };
137    let player_art_dir = {
138        let p = repo_root.join("assets/gfx/player");
139        p.is_dir().then_some(p)
140    };
141    let client_settings = {
142        let p = repo_root.join("assets/config/client-settings.yaml");
143        p.is_file().then_some(p)
144    };
145    let terrain_kinds = {
146        let p = repo_root.join("assets/world/terrain-kinds.yaml");
147        p.is_file().then_some(p)
148    };
149    let audio_sfx_dir = {
150        let p = repo_root.join("assets/audio/sfx");
151        p.is_dir().then_some(p)
152    };
153    let owned = ClientBundleOwned {
154        sprites_dir,
155        paperdoll_dir,
156        player_presentation,
157        player_art_dir,
158        client_settings,
159        terrain_kinds,
160        audio_sfx_dir,
161    };
162    let index = build_client_bundle_index(&owned.as_refs(), publish_rev, published_at)?;
163    Ok((index, owned))
164}
165
166async fn upload_client_bundle(
167    store: &dyn AssetStore,
168    cfg: &AssetBackendConfig,
169    sources: &ClientBundleOwned,
170    index: &AssetBundleIndex,
171) -> anyhow::Result<()> {
172    let refs = sources.as_refs();
173    for (rel, _) in &index.files {
174        let path = resolve_bundle_file_path(&refs, rel);
175        let bytes = std::fs::read(&path)
176            .with_context(|| format!("read client pack file {rel} from {}", path.display()))?;
177        let key = cfg.client_object_key(index.publish_rev, rel);
178        store
179            .put(&key, mime_for_path(&path), &bytes)
180            .await
181            .with_context(|| format!("upload client {key}"))?;
182    }
183    let latest = serde_json::to_vec(index)?;
184    store
185        .put(&cfg.client_index_object(), "application/json", &latest)
186        .await?;
187    Ok(())
188}
189
190fn backend_label(cfg: &AssetBackendConfig) -> &'static str {
191    match cfg.kind {
192        AssetBackendKind::Local => "local",
193        AssetBackendKind::Gcs => "gcs",
194        AssetBackendKind::S3 => "s3",
195    }
196}
197
198fn describe_dest(cfg: &AssetBackendConfig, prefix: &str, rev: u64) -> String {
199    match cfg.kind {
200        AssetBackendKind::Local => cfg
201            .local_root
202            .join(prefix)
203            .join(format!("rev-{rev}"))
204            .display()
205            .to_string(),
206        AssetBackendKind::Gcs => format!("gs://{}/{prefix}/rev-{rev}/", cfg.bucket),
207        AssetBackendKind::S3 => format!("s3://{}/{prefix}/rev-{rev}/", cfg.bucket),
208    }
209}