1use std::{
2 collections::HashMap,
3 default, fmt, fs,
4 path::{Path, PathBuf},
5 str,
6};
7
8use rusoto_core::Region;
9use serde::{Deserialize, Serialize};
10use url::Url;
11use yansi::Paint;
12
13use super::{
14 logger,
15 roblox_api::{
16 AssetTypeId, ExperienceAnimationType, ExperienceAvatarType, ExperienceCollisionType,
17 ExperienceConfigurationModel, ExperienceGenre, ExperiencePlayableDevice,
18 PlaceConfigurationModel, SocialSlotType,
19 },
20 roblox_resource_manager::AssetId,
21};
22
23#[derive(Deserialize)]
24#[serde(rename_all = "camelCase")]
25pub struct Config {
26 #[serde(default)]
27 pub owner: OwnerConfig,
28
29 #[serde(default)]
30 pub payments: PaymentsConfig,
31
32 #[serde(default = "Vec::new")]
33 pub environments: Vec<EnvironmentConfig>,
34
35 pub target: TargetConfig,
36
37 #[serde(default)]
38 pub state: StateConfig,
39}
40
41#[derive(Deserialize, Clone)]
42#[serde(rename_all = "camelCase")]
43pub enum OwnerConfig {
44 Personal,
45 Group(AssetId),
46}
47impl default::Default for OwnerConfig {
48 fn default() -> Self {
49 OwnerConfig::Personal
50 }
51}
52
53#[derive(Deserialize, Clone)]
54#[serde(rename_all = "camelCase")]
55pub enum PaymentsConfig {
56 Owner,
57 Personal,
58 Group,
59}
60impl default::Default for PaymentsConfig {
61 fn default() -> Self {
62 PaymentsConfig::Owner
63 }
64}
65
66#[derive(Deserialize, Clone)]
67#[serde(rename_all = "camelCase")]
68pub enum StateConfig {
69 Local,
70 Remote(RemoteStateConfig),
71}
72impl default::Default for StateConfig {
73 fn default() -> Self {
74 StateConfig::Local
75 }
76}
77
78#[derive(Deserialize, Clone)]
79#[serde(rename_all = "camelCase")]
80pub struct RemoteStateConfig {
81 pub bucket: String,
82 pub key: String,
83 pub region: Region,
84}
85impl fmt::Display for RemoteStateConfig {
86 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
87 write!(
88 f,
89 "{}/{}/{}.mantle-state.yml",
90 self.region.name(),
91 self.bucket,
92 self.key
93 )
94 }
95}
96
97#[derive(Deserialize, Clone)]
98#[serde(rename_all = "camelCase")]
99pub struct EnvironmentConfig {
100 pub name: String,
101
102 #[serde(default = "Vec::new")]
103 pub branches: Vec<String>,
104
105 #[serde(default)]
106 pub tag_commit: bool,
107
108 pub overrides: Option<serde_yaml::Value>,
109}
110
111#[derive(Serialize, Deserialize, Clone)]
112#[serde(rename_all = "camelCase")]
113pub enum TargetConfig {
114 Experience(ExperienceTargetConfig),
115}
116
117#[derive(Serialize, Deserialize, Clone)]
118#[serde(rename_all = "camelCase")]
119pub struct ExperienceTargetConfig {
120 pub configuration: Option<ExperienceTargetConfigurationConfig>,
121
122 pub places: Option<HashMap<String, PlaceTargetConfig>>,
123
124 pub social_links: Option<Vec<SocialLinkTargetConfig>>,
125
126 pub products: Option<HashMap<String, ProductTargetConifg>>,
127
128 pub passes: Option<HashMap<String, PassTargetConfig>>,
129
130 pub badges: Option<HashMap<String, BadgeTargetConfig>>,
131
132 pub assets: Option<Vec<AssetTargetConfig>>,
133}
134
135#[derive(Serialize, Deserialize, Clone)]
136#[serde(rename_all = "camelCase")]
137pub struct SocialLinkTargetConfig {
138 pub title: String,
139 pub url: Url,
140}
141
142#[derive(Serialize, Deserialize, Clone)]
143#[serde(rename_all = "camelCase")]
144pub enum GenreTargetConfig {
145 All,
146 Adventure,
147 Building,
148 Comedy,
149 Fighting,
150 Fps,
151 Horror,
152 Medieval,
153 Military,
154 Naval,
155 Rpg,
156 SciFi,
157 Sports,
158 TownAndCity,
159 Western,
160}
161
162#[derive(Serialize, Deserialize, Clone, Copy)]
163#[serde(rename_all = "camelCase")]
164pub enum PlayabilityTargetConfig {
165 Private,
166 Public,
167 Friends,
168}
169
170#[derive(Serialize, Deserialize, Clone)]
171#[serde(rename_all = "camelCase")]
172pub enum AvatarTypeTargetConfig {
173 R6,
174 R15,
175 PlayerChoice,
176}
177
178#[derive(Serialize, Deserialize, Clone, Copy)]
179#[serde(rename_all = "camelCase")]
180pub enum PlayableDeviceTargetConfig {
181 Computer,
182 Phone,
183 Tablet,
184 Console,
185}
186
187#[derive(Serialize, Deserialize, Clone, Copy)]
188#[serde(rename_all = "camelCase")]
189pub enum AnimationTypeTargetConfig {
190 Standard,
191 PlayerChoice,
192}
193
194#[derive(Serialize, Deserialize, Clone, Copy)]
195#[serde(rename_all = "camelCase")]
196pub enum CollisionTypeTargetConfig {
197 OuterBox,
198 InnerBox,
199}
200
201#[derive(Serialize, Deserialize, Clone, Copy)]
202#[serde(rename_all = "camelCase")]
203pub struct Constraint {
204 pub min: Option<f32>,
205 pub max: Option<f32>,
206}
207
208#[derive(Serialize, Deserialize, Clone, Copy)]
209#[serde(rename_all = "camelCase")]
210pub struct AvatarScaleConstraintsTargetConfig {
211 pub height: Option<Constraint>,
212 pub width: Option<Constraint>,
213 pub head: Option<Constraint>,
214 pub body_type: Option<Constraint>,
215 pub proportions: Option<Constraint>,
216}
217
218#[derive(Serialize, Deserialize, Clone, Copy)]
219#[serde(rename_all = "camelCase")]
220pub struct AvatarAssetOverridesTargetConfig {
221 pub face: Option<AssetId>,
222 pub head: Option<AssetId>,
223 pub torso: Option<AssetId>,
224 pub left_arm: Option<AssetId>,
225 pub right_arm: Option<AssetId>,
226 pub left_leg: Option<AssetId>,
227 pub right_leg: Option<AssetId>,
228 #[serde(rename = "tshirt")]
229 pub t_shirt: Option<AssetId>,
230 pub shirt: Option<AssetId>,
231 pub pants: Option<AssetId>,
232}
233
234#[derive(Serialize, Deserialize, Clone)]
235#[serde(rename_all = "camelCase")]
236pub struct ProductTargetConifg {
237 pub name: String,
238 pub description: Option<String>,
239 pub icon: Option<String>,
240 pub price: u32,
241}
242
243#[derive(Serialize, Deserialize, Clone)]
244#[serde(rename_all = "camelCase")]
245pub struct PassTargetConfig {
246 pub name: String,
247 pub description: Option<String>,
248 pub icon: String,
249 pub price: Option<u32>,
250}
251
252#[derive(Serialize, Deserialize, Clone)]
253#[serde(rename_all = "camelCase")]
254pub struct BadgeTargetConfig {
255 pub name: String,
256 pub description: Option<String>,
257 pub icon: String,
258 pub enabled: Option<bool>,
259}
260
261#[derive(Serialize, Deserialize, Clone)]
262#[serde(rename_all = "camelCase", untagged)]
263pub enum AssetTargetConfig {
264 File(String),
265 FileWithAlias { file: String, name: String },
266}
267
268#[derive(Serialize, Deserialize, Clone)]
269#[serde(rename_all = "camelCase")]
270pub struct ExperienceTargetConfigurationConfig {
271 pub genre: Option<GenreTargetConfig>,
273 pub playable_devices: Option<Vec<PlayableDeviceTargetConfig>>,
274 pub icon: Option<String>,
275 pub thumbnails: Option<Vec<String>>,
276
277 pub playability: Option<PlayabilityTargetConfig>,
279
280 pub paid_access_price: Option<u32>,
282 pub private_server_price: Option<u32>,
283
284 pub enable_studio_access_to_apis: Option<bool>,
286 pub allow_third_party_sales: Option<bool>,
287 pub allow_third_party_teleports: Option<bool>,
288
289 pub avatar_type: Option<AvatarTypeTargetConfig>,
293 pub avatar_animation_type: Option<AnimationTypeTargetConfig>,
294 pub avatar_collision_type: Option<CollisionTypeTargetConfig>,
295 pub avatar_scale_constraints: Option<AvatarScaleConstraintsTargetConfig>,
296 pub avatar_asset_overrides: Option<AvatarAssetOverridesTargetConfig>,
297}
298
299impl From<&ExperienceTargetConfigurationConfig> for ExperienceConfigurationModel {
300 fn from(config: &ExperienceTargetConfigurationConfig) -> Self {
301 let mut model = ExperienceConfigurationModel::default();
302 if let Some(genre) = &config.genre {
303 model.genre = match genre {
304 GenreTargetConfig::All => ExperienceGenre::All,
305 GenreTargetConfig::Adventure => ExperienceGenre::Adventure,
306 GenreTargetConfig::Building => ExperienceGenre::Tutorial,
307 GenreTargetConfig::Comedy => ExperienceGenre::Funny,
308 GenreTargetConfig::Fighting => ExperienceGenre::Ninja,
309 GenreTargetConfig::Fps => ExperienceGenre::Fps,
310 GenreTargetConfig::Horror => ExperienceGenre::Scary,
311 GenreTargetConfig::Medieval => ExperienceGenre::Fantasy,
312 GenreTargetConfig::Military => ExperienceGenre::War,
313 GenreTargetConfig::Naval => ExperienceGenre::Pirate,
314 GenreTargetConfig::Rpg => ExperienceGenre::Rpg,
315 GenreTargetConfig::SciFi => ExperienceGenre::SciFi,
316 GenreTargetConfig::Sports => ExperienceGenre::Sports,
317 GenreTargetConfig::TownAndCity => ExperienceGenre::TownAndCity,
318 GenreTargetConfig::Western => ExperienceGenre::WildWest,
319 }
320 }
321 if let Some(playable_devices) = &config.playable_devices {
322 model.playable_devices = playable_devices
323 .iter()
324 .map(|device| match device {
325 PlayableDeviceTargetConfig::Computer => ExperiencePlayableDevice::Computer,
326 PlayableDeviceTargetConfig::Phone => ExperiencePlayableDevice::Phone,
327 PlayableDeviceTargetConfig::Tablet => ExperiencePlayableDevice::Tablet,
328 PlayableDeviceTargetConfig::Console => ExperiencePlayableDevice::Console,
329 })
330 .collect();
331 }
332 if let Some(playability) = &config.playability {
333 model.is_friends_only = match playability {
334 PlayabilityTargetConfig::Friends => Some(true),
335 PlayabilityTargetConfig::Public => Some(false),
336 PlayabilityTargetConfig::Private => None,
337 }
338 }
339 model.is_for_sale = config.clone().paid_access_price.is_some();
340 model.price = config.paid_access_price;
341 model.allow_private_servers = config.private_server_price.is_some();
342 model.private_server_price = config.private_server_price;
343 if let Some(enable_studio_access_to_apis) = config.enable_studio_access_to_apis {
344 model.studio_access_to_apis_allowed = enable_studio_access_to_apis;
345 }
346 if let Some(allow_third_party_sales) = config.allow_third_party_sales {
347 model.permissions.is_third_party_purchase_allowed = allow_third_party_sales;
348 }
349 if let Some(allow_third_party_teleports) = config.allow_third_party_teleports {
350 model.permissions.is_third_party_teleport_allowed = allow_third_party_teleports;
351 }
352 if let Some(avatar_type) = &config.avatar_type {
353 model.universe_avatar_type = match avatar_type {
354 AvatarTypeTargetConfig::R6 => ExperienceAvatarType::MorphToR6,
355 AvatarTypeTargetConfig::R15 => ExperienceAvatarType::MorphToR15,
356 AvatarTypeTargetConfig::PlayerChoice => ExperienceAvatarType::PlayerChoice,
357 }
358 }
359 if let Some(avatar_animation_type) = &config.avatar_animation_type {
360 model.universe_animation_type = match avatar_animation_type {
361 AnimationTypeTargetConfig::Standard => ExperienceAnimationType::Standard,
362 AnimationTypeTargetConfig::PlayerChoice => ExperienceAnimationType::PlayerChoice,
363 }
364 }
365 if let Some(avatar_collision_type) = &config.avatar_collision_type {
366 model.universe_collision_type = match avatar_collision_type {
367 CollisionTypeTargetConfig::OuterBox => ExperienceCollisionType::OuterBox,
368 CollisionTypeTargetConfig::InnerBox => ExperienceCollisionType::InnerBox,
369 }
370 }
371 if let Some(constraints) = &config.avatar_scale_constraints {
372 if let Some(height) = constraints.height.and_then(|c| c.min) {
373 model.universe_avatar_min_scales.height = height.to_string();
374 }
375 if let Some(width) = constraints.width.and_then(|c| c.min) {
376 model.universe_avatar_min_scales.width = width.to_string();
377 }
378 if let Some(head) = constraints.head.and_then(|c| c.min) {
379 model.universe_avatar_min_scales.head = head.to_string();
380 }
381 if let Some(body_type) = constraints.body_type.and_then(|c| c.min) {
382 model.universe_avatar_min_scales.body_type = body_type.to_string();
383 }
384 if let Some(proportions) = constraints.proportions.and_then(|c| c.min) {
385 model.universe_avatar_min_scales.proportion = proportions.to_string();
386 }
387
388 if let Some(height) = constraints.height.and_then(|c| c.max) {
389 model.universe_avatar_max_scales.height = height.to_string();
390 }
391 if let Some(width) = constraints.width.and_then(|c| c.max) {
392 model.universe_avatar_max_scales.width = width.to_string();
393 }
394 if let Some(head) = constraints.head.and_then(|c| c.max) {
395 model.universe_avatar_max_scales.head = head.to_string();
396 }
397 if let Some(body_type) = constraints.body_type.and_then(|c| c.max) {
398 model.universe_avatar_max_scales.body_type = body_type.to_string();
399 }
400 if let Some(proportions) = constraints.proportions.and_then(|c| c.max) {
401 model.universe_avatar_max_scales.proportion = proportions.to_string();
402 }
403 }
404 if let Some(avatar_asset_overrides) = &config.avatar_asset_overrides {
405 for override_model in model.universe_avatar_asset_overrides.iter_mut() {
406 if let Some(override_config) = match override_model.asset_type_id {
407 AssetTypeId::Face => avatar_asset_overrides.face,
408 AssetTypeId::Head => avatar_asset_overrides.head,
409 AssetTypeId::Torso => avatar_asset_overrides.torso,
410 AssetTypeId::LeftArm => avatar_asset_overrides.left_arm,
411 AssetTypeId::RightArm => avatar_asset_overrides.right_arm,
412 AssetTypeId::LeftLeg => avatar_asset_overrides.left_leg,
413 AssetTypeId::RightLeg => avatar_asset_overrides.right_leg,
414 AssetTypeId::TShirt => avatar_asset_overrides.t_shirt,
415 AssetTypeId::Shirt => avatar_asset_overrides.shirt,
416 AssetTypeId::Pants => avatar_asset_overrides.pants,
417 _ => None,
418 } {
419 override_model.is_player_choice = false;
420 override_model.asset_id = Some(override_config);
421 }
422 }
423 }
424 model
425 }
426}
427
428#[derive(Serialize, Deserialize, Clone)]
429#[serde(rename_all = "camelCase")]
430pub enum ServerFillTargetConfig {
431 RobloxOptimized,
432 Maximum,
433 ReservedSlots(u32),
434}
435
436#[derive(Serialize, Deserialize, Clone)]
437#[serde(rename_all = "camelCase")]
438pub struct PlaceTargetConfig {
439 pub file: Option<String>,
440 pub configuration: Option<PlaceTargetConfigurationConfig>,
441}
442
443#[derive(Serialize, Deserialize, Clone)]
444#[serde(rename_all = "camelCase")]
445pub struct PlaceTargetConfigurationConfig {
446 pub name: Option<String>,
447 pub description: Option<String>,
448 pub max_player_count: Option<u32>,
449 pub allow_copying: Option<bool>,
450 pub server_fill: Option<ServerFillTargetConfig>,
451}
452
453impl From<PlaceTargetConfigurationConfig> for PlaceConfigurationModel {
454 fn from(config: PlaceTargetConfigurationConfig) -> Self {
455 let mut model = PlaceConfigurationModel::default();
456 if let Some(name) = config.name {
457 model.name = name;
458 }
459 if let Some(description) = config.description {
460 model.description = description;
461 }
462 if let Some(max_player_count) = config.max_player_count {
463 model.max_player_count = max_player_count;
464 }
465 if let Some(allow_copying) = config.allow_copying {
466 model.allow_copying = allow_copying;
467 }
468 if let Some(server_fill) = config.server_fill {
469 model.social_slot_type = match server_fill {
470 ServerFillTargetConfig::RobloxOptimized => SocialSlotType::Automatic,
471 ServerFillTargetConfig::Maximum => SocialSlotType::Empty,
472 ServerFillTargetConfig::ReservedSlots(_) => SocialSlotType::Custom,
473 };
474 model.custom_social_slots_count = match server_fill {
475 ServerFillTargetConfig::ReservedSlots(count) => Some(count),
476 _ => None,
477 }
478 }
479 model
480 }
481}
482
483fn parse_project_path(project: Option<&str>) -> Result<(PathBuf, PathBuf), String> {
484 let project = project.unwrap_or(".");
485 let project_path = Path::new(project).to_owned();
486
487 let (project_dir, config_file) = if project_path.is_dir() {
488 (project_path.clone(), project_path.join("mantle.yml"))
489 } else if project_path.is_file() {
490 (project_path.parent().unwrap().into(), project_path)
491 } else {
492 return Err(format!("Unable to load project path: {}", project));
493 };
494
495 if config_file.exists() {
496 return Ok((project_dir, config_file));
497 }
498
499 Err(format!("Config file {} not found", config_file.display()))
500}
501
502fn load_config_file(config_file: &Path) -> Result<Config, String> {
503 let data = fs::read_to_string(config_file).map_err(|e| {
504 format!(
505 "Unable to read config file: {}\n\t{}",
506 config_file.display(),
507 e
508 )
509 })?;
510
511 serde_yaml::from_str::<Config>(&data).map_err(|e| {
512 format!(
513 "Unable to parse config file {}\n\t{}",
514 config_file.display(),
515 e
516 )
517 })
518}
519
520pub fn load_project_config(project: Option<&str>) -> Result<(PathBuf, Config), String> {
521 let (project_path, config_path) = parse_project_path(project)?;
522 let config = load_config_file(&config_path)?;
523
524 logger::log(format!(
525 "Loaded config file {}",
526 Paint::cyan(config_path.display())
527 ));
528
529 Ok((project_path, config))
530}