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) =
60        build_client_sources(repo_root, publish_rev, &published_at)?;
61    let sim_index = build_sim_pack_index(repo_root, publish_rev, &published_at)?;
62    let cfg = AssetBackendConfig::from_env();
63    let mut uploaded = false;
64    if !skip_upload {
65        let store = store_from_config(&cfg)?;
66        upload_client_bundle(store.as_ref(), &cfg, &sources, &client_index)
67            .await
68            .context("upload client asset pack")?;
69        if upload_sim {
70            upload_sim_pack(store.as_ref(), &cfg, repo_root, &sim_index)
71                .await
72                .context("upload sim content pack")?;
73        }
74        uploaded = true;
75    }
76    Ok(PublishPacksResult {
77        rev: publish_rev,
78        published_at,
79        summary,
80        client_files: client_index.files.len(),
81        sim_files: sim_index.files.len(),
82        uploaded,
83        backend_kind: backend_label(&cfg).to_string(),
84        bucket: cfg.bucket.clone(),
85        dest: describe_dest(&cfg, &cfg.client_prefix, publish_rev),
86    })
87}
88
89struct ClientBundleOwned {
90    sprites_dir: PathBuf,
91    paperdoll_dir: Option<PathBuf>,
92    player_presentation: Option<PathBuf>,
93    player_art_dir: Option<PathBuf>,
94    client_settings: Option<PathBuf>,
95    terrain_kinds: 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        }
108    }
109}
110
111fn build_client_sources(
112    repo_root: &Path,
113    publish_rev: u64,
114    published_at: &str,
115) -> anyhow::Result<(AssetBundleIndex, ClientBundleOwned)> {
116    // Prefer repo sprites for pack builds — `default_sprites_dir()` may resolve to
117    // `~/.flatland3/assets/current`, which nests an older paperdoll tree and breaks uploads.
118    let sprites_dir = {
119        let repo_sprites = repo_root.join("assets/gfx/sprites");
120        if repo_sprites.join("manifest.yaml").is_file() {
121            repo_sprites
122        } else {
123            flatland_presentation::default_sprites_dir().ok_or_else(|| {
124                anyhow::anyhow!("gfx sprites dir not found under assets/gfx/sprites")
125            })?
126        }
127    };
128    let paperdoll_dir = repo_root
129        .join("assets/paperdoll")
130        .is_dir()
131        .then(|| repo_root.join("assets/paperdoll"));
132    let player_presentation = {
133        let p = repo_root.join("assets/gfx/player-presentation.yaml");
134        p.is_file().then_some(p)
135    };
136    let player_art_dir = {
137        let p = repo_root.join("assets/gfx/player");
138        p.is_dir().then_some(p)
139    };
140    let client_settings = {
141        let p = repo_root.join("assets/config/client-settings.yaml");
142        p.is_file().then_some(p)
143    };
144    let terrain_kinds = {
145        let p = repo_root.join("assets/world/terrain-kinds.yaml");
146        p.is_file().then_some(p)
147    };
148    let owned = ClientBundleOwned {
149        sprites_dir,
150        paperdoll_dir,
151        player_presentation,
152        player_art_dir,
153        client_settings,
154        terrain_kinds,
155    };
156    let index = build_client_bundle_index(&owned.as_refs(), publish_rev, published_at)?;
157    Ok((index, owned))
158}
159
160async fn upload_client_bundle(
161    store: &dyn AssetStore,
162    cfg: &AssetBackendConfig,
163    sources: &ClientBundleOwned,
164    index: &AssetBundleIndex,
165) -> anyhow::Result<()> {
166    let refs = sources.as_refs();
167    for (rel, _) in &index.files {
168        let path = resolve_bundle_file_path(&refs, rel);
169        let bytes = std::fs::read(&path).with_context(|| {
170            format!(
171                "read client pack file {rel} from {}",
172                path.display()
173            )
174        })?;
175        let key = cfg.client_object_key(index.publish_rev, rel);
176        store
177            .put(&key, mime_for_path(&path), &bytes)
178            .await
179            .with_context(|| format!("upload client {key}"))?;
180    }
181    let latest = serde_json::to_vec(index)?;
182    store
183        .put(&cfg.client_index_object(), "application/json", &latest)
184        .await?;
185    Ok(())
186}
187
188fn backend_label(cfg: &AssetBackendConfig) -> &'static str {
189    match cfg.kind {
190        AssetBackendKind::Local => "local",
191        AssetBackendKind::Gcs => "gcs",
192        AssetBackendKind::S3 => "s3",
193    }
194}
195
196fn describe_dest(cfg: &AssetBackendConfig, prefix: &str, rev: u64) -> String {
197    match cfg.kind {
198        AssetBackendKind::Local => cfg
199            .local_root
200            .join(prefix)
201            .join(format!("rev-{rev}"))
202            .display()
203            .to_string(),
204        AssetBackendKind::Gcs => format!("gs://{}/{prefix}/rev-{rev}/", cfg.bucket),
205        AssetBackendKind::S3 => format!("s3://{}/{prefix}/rev-{rev}/", cfg.bucket),
206    }
207}