use crate::download::download_asset_version; use crate::rbx_util::{class_is_a,get_mapinfo,get_root_instance,read_dom,MapInfo,ReadDomError}; use heck::{ToSnakeCase,ToTitleCase}; #[allow(dead_code)] #[derive(Debug)] pub enum Error{ ModelInfoDownload(rbx_asset::cloud::GetError), CreatorTypeMustBeUser, Download(crate::download::Error), ModelFileDecode(ReadDomError), } impl std::fmt::Display for Error{ fn fmt(&self,f:&mut std::fmt::Formatter<'_>)->std::fmt::Result{ write!(f,"{self:?}") } } impl std::error::Error for Error{} #[allow(nonstandard_style)] pub struct CheckRequest{ pub ModelID:u64, } impl From<crate::nats_types::CheckMapfixRequest> for CheckRequest{ fn from(value:crate::nats_types::CheckMapfixRequest)->Self{ Self{ ModelID:value.ModelID, } } } impl From<crate::nats_types::CheckSubmissionRequest> for CheckRequest{ fn from(value:crate::nats_types::CheckSubmissionRequest)->Self{ Self{ ModelID:value.ModelID, } } } #[derive(Default)] pub enum Check{ Pass, #[default] Fail, } impl Check{ fn pass(&self)->bool{ match self{ Check::Pass=>true, Check::Fail=>false, } } } impl std::fmt::Display for Check{ fn fmt(&self,f:&mut std::fmt::Formatter<'_>)->std::fmt::Result{ match self{ Check::Pass=>write!(f,"passed"), Check::Fail=>write!(f,"failed"), } } } #[derive(Default)] pub struct CheckReport{ // === METADATA CHECKS === // the model must have exactly 1 root part (models uploaded to roblox can have multiple roots) exactly_one_root:Check, // the root must be of class Model root_is_model:Check, // the prefix of the model's name must match the game it was submitted for. bhop_ for bhop, and surf_ for surf model_name_prefix_is_valid:Check, // your model's name must match this regex: ^[a-z0-9_] model_name_is_snake_case:Check, // map must have a StringValue named Creator and DisplayName. additionally, they must both have a value has_display_name:Check, has_creator:Check, // the display name must be capitalized display_name_is_title_case:Check, // you cannot have any Scripts or ModuleScripts that have the keyword 'getfenv" or 'require' // you cannot have more than 50 duplicate scripts // === MODE CHECKS === // Exactly one MapStart exactly_one_mapstart:Check, // At least one MapFinish at_least_one_mapfinish:Check, // Spawn0 or Spawn1 must exist spawn1_exists:Check, // No duplicate Spawn# no_duplicate_spawns:Check, // No duplicate WormholeOut# (duplicate WormholeIn# ok) no_duplicate_wormhole_out:Check, } impl CheckReport{ pub fn pass(&self)->bool{ return self.exactly_one_root.pass() &&self.root_is_model.pass() &&self.model_name_prefix_is_valid.pass() &&self.model_name_is_snake_case.pass() &&self.has_display_name.pass() &&self.has_creator.pass() &&self.display_name_is_title_case.pass() &&self.exactly_one_mapstart.pass() &&self.at_least_one_mapfinish.pass() &&self.spawn1_exists.pass() &&self.no_duplicate_spawns.pass() &&self.no_duplicate_wormhole_out.pass() } } impl std::fmt::Display for CheckReport{ fn fmt(&self,f:&mut std::fmt::Formatter<'_>)->std::fmt::Result{ write!(f, "exactly_one_root={}\n root_is_model={}\n model_name_prefix_is_valid={}\n model_name_is_snake_case={}\n has_display_name={}\n has_creator={}\n display_name_is_title_case={}\n exactly_one_mapstart={}\n at_least_one_mapfinish={}\n spawn1_exists={}\n no_duplicate_spawns={}\n no_duplicate_wormhole_out={}", self.exactly_one_root, self.root_is_model, self.model_name_prefix_is_valid, self.model_name_is_snake_case, self.has_display_name, self.has_creator, self.display_name_is_title_case, self.exactly_one_mapstart, self.at_least_one_mapfinish, self.spawn1_exists, self.no_duplicate_spawns, self.no_duplicate_wormhole_out, ) } } enum Zone{ Start(ModeID), Finish(ModeID), } #[derive(Debug,Hash,Eq,PartialEq)] struct ModeID(u64); impl ModeID{ const MAIN:Self=Self(0); const BONUS:Self=Self(1); } #[allow(dead_code)] pub enum ZoneParseError{ NoCaptures, ParseInt(core::num::ParseIntError) } impl std::str::FromStr for Zone{ type Err=ZoneParseError; fn from_str(s:&str)->Result<Self,Self::Err>{ match s{ "MapStart"=>Ok(Self::Start(ModeID::MAIN)), "MapFinish"=>Ok(Self::Finish(ModeID::MAIN)), "BonusStart"=>Ok(Self::Start(ModeID::BONUS)), "BonusFinish"=>Ok(Self::Finish(ModeID::BONUS)), other=>{ let bonus_start_pattern=lazy_regex::lazy_regex!(r"^Bonus(\d+)Start$|^BonusStart(\d+)$"); if let Some(captures)=bonus_start_pattern.captures(other){ return Ok(Self::Start(ModeID(captures[1].parse().map_err(ZoneParseError::ParseInt)?))); } let bonus_finish_pattern=lazy_regex::lazy_regex!(r"^Bonus(\d+)Finish$|^BonusFinish(\d+)$"); if let Some(captures)=bonus_finish_pattern.captures(other){ return Ok(Self::Finish(ModeID(captures[1].parse().map_err(ZoneParseError::ParseInt)?))); } Err(ZoneParseError::NoCaptures) } } } } #[derive(Debug,Hash,Eq,PartialEq)] struct SpawnID(u64); impl SpawnID{ const FIRST:Self=Self(1); } #[derive(Debug,Hash,Eq,PartialEq)] struct WormholeOutID(u64); struct Counts{ mode_start_counts:std::collections::HashMap<ModeID,u64>, mode_finish_counts:std::collections::HashMap<ModeID,u64>, spawn_counts:std::collections::HashMap<SpawnID,u64>, wormhole_out_counts:std::collections::HashMap<WormholeOutID,u64>, } impl Counts{ fn new()->Self{ Self{ mode_start_counts:std::collections::HashMap::new(), mode_finish_counts:std::collections::HashMap::new(), spawn_counts:std::collections::HashMap::new(), wormhole_out_counts:std::collections::HashMap::new(), } } } pub fn check(dom:&rbx_dom_weak::WeakDom)->CheckReport{ // empty report with all checks failed let mut report=CheckReport::default(); // extract the root instance, otherwise immediately return let Ok(model_instance)=get_root_instance(&dom)else{ return report; }; report.exactly_one_root=Check::Pass; if model_instance.class=="Model"{ report.root_is_model=Check::Pass; } if model_instance.name==model_instance.name.to_snake_case(){ report.model_name_is_snake_case=Check::Pass; } // extract model info let MapInfo{display_name,creator,game_id}=get_mapinfo(&dom,model_instance); // check DisplayName if let Ok(display_name)=display_name{ if !display_name.is_empty(){ report.has_display_name=Check::Pass; if display_name==display_name.to_title_case(){ report.display_name_is_title_case=Check::Pass; } } } // check Creator if let Ok(creator)=creator{ if !creator.is_empty(){ report.has_creator=Check::Pass; } } // check GameID if game_id.is_ok(){ report.model_name_prefix_is_valid=Check::Pass; } // === MODE CHECKS === // count objects let mut counts=Counts::new(); for instance in dom.descendants_of(model_instance.referent()){ if class_is_a(instance.class.as_str(),"BasePart"){ // Zones match instance.name.parse(){ Ok(Zone::Start(mode_id))=>*counts.mode_start_counts.entry(mode_id).or_insert(0)+=1, Ok(Zone::Finish(mode_id))=>*counts.mode_finish_counts.entry(mode_id).or_insert(0)+=1, _=>(), } // Spawns let spawn_pattern=lazy_regex::lazy_regex!(r"^Spawn(\d+)$"); if let Some(captures)=spawn_pattern.captures(instance.name.as_str()){ if let Ok(spawn_id)=captures[1].parse(){ *counts.spawn_counts.entry(SpawnID(spawn_id)).or_insert(0)+=1; } } // WormholeOuts let wormhole_out_pattern=lazy_regex::lazy_regex!(r"^WormholeOut(\d+)$"); if let Some(captures)=wormhole_out_pattern.captures(instance.name.as_str()){ if let Ok(wormhole_out_id)=captures[1].parse(){ *counts.wormhole_out_counts.entry(WormholeOutID(wormhole_out_id)).or_insert(0)+=1; } } } } // MapStart must exist && there must be exactly one of any bonus start zones. if counts.mode_start_counts.get(&ModeID::MAIN)==Some(&1) &&counts.mode_start_counts.iter().all(|(_,&c)|c==1){ report.exactly_one_mapstart=Check::Pass; } // iterate over start zones if counts.mode_start_counts.iter().all(|(mode_id,_)| // ensure that at least one end zone exists with the same mode id counts.mode_finish_counts.get(mode_id).is_some_and(|&num|0<num) ){ report.at_least_one_mapfinish=Check::Pass; } // Spawn1 must exist if counts.spawn_counts.get(&SpawnID::FIRST).is_some(){ report.spawn1_exists=Check::Pass; } if counts.spawn_counts.iter().all(|(_,&c)|c==1){ report.no_duplicate_spawns=Check::Pass; } if counts.wormhole_out_counts.iter().all(|(_,&c)|c==1){ report.no_duplicate_wormhole_out=Check::Pass; } report } pub struct CheckReportAndVersion{ pub report:CheckReport, pub version:u64, } impl crate::message_handler::MessageHandler{ pub async fn check_inner(&self,check_info:CheckRequest)->Result<CheckReportAndVersion,Error>{ // discover asset creator and latest version let info=self.cloud_context.get_asset_info( rbx_asset::cloud::GetAssetLatestRequest{asset_id:check_info.ModelID} ).await.map_err(Error::ModelInfoDownload)?; // reject models created by a group let rbx_asset::cloud::Creator::userId(_user_id)=info.creationContext.creator else{ return Err(Error::CreatorTypeMustBeUser); }; // parse model version string let version=info.revisionId; let maybe_gzip=download_asset_version(&self.cloud_context,rbx_asset::cloud::GetAssetVersionRequest{ asset_id:check_info.ModelID, version, }).await.map_err(Error::Download)?; // decode dom (slow!) let dom=maybe_gzip.read_with(read_dom,read_dom).map_err(Error::ModelFileDecode)?; let report=check(&dom); Ok(CheckReportAndVersion{report,version}) } }