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) = 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 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}