1use 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
28pub 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
50pub 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 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}