rbx_mantle/
config.rs

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    // basic info
272    pub genre: Option<GenreTargetConfig>,
273    pub playable_devices: Option<Vec<PlayableDeviceTargetConfig>>,
274    pub icon: Option<String>,
275    pub thumbnails: Option<Vec<String>>,
276
277    // permissions
278    pub playability: Option<PlayabilityTargetConfig>,
279
280    // monetization
281    pub paid_access_price: Option<u32>,
282    pub private_server_price: Option<u32>,
283
284    // security
285    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    // localization: // TODO: localization
290
291    // avatar
292    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}