diff --git a/validation/src/check.rs b/validation/src/check.rs new file mode 100644 index 0000000..87cdcc6 --- /dev/null +++ b/validation/src/check.rs @@ -0,0 +1,315 @@ +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, + ParseUserID(core::num::ParseIntError), + ParseModelVersion(core::num::ParseIntError), + 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 struct CheckReport{ + // === METADATA CHECKS === + // the model must have exactly 1 root part (models uploaded to roblox can have multiple roots) + exactly_one_root:bool, + // the root must be of class Model + root_is_model:bool, + // 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:bool, + // your model's name must match this regex: ^[a-z0-9_] + model_name_is_snake_case:bool, + // map must have a StringValue named Creator and DisplayName. additionally, they must both have a value + has_display_name:bool, + has_creator:bool, + // the display name must be capitalized + display_name_is_title_case:bool, + // 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:bool, + // At least one MapFinish + at_least_one_mapfinish:bool, + // Spawn0 or Spawn1 must exist + spawn1_exists:bool, + // No duplicate Spawn# + no_duplicate_spawns:bool, + // No duplicate WormholeOut# (duplicate WormholeIn# ok) + no_duplicate_wormhole_out:bool, +} +impl CheckReport{ + pub fn pass(&self)->bool{ + return 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 + } +} +impl std::fmt::Display for CheckReport{ + fn fmt(&self,f:&mut std::fmt::Formatter<'_>)->std::fmt::Result{ + write!(f, + "exactly_one_root={}\ +root_is_model={}\ +model_name_prefix_is_valid={}\ +model_name_is_snake_case={}\ +has_display_name={}\ +has_creator={}\ +display_name_is_title_case={}\ +exactly_one_mapstart={}\ +at_least_one_mapfinish={}\ +spawn1_exists={}\ +no_duplicate_spawns={}\ +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::Start(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=true; + + if model_instance.class=="Model"{ + report.root_is_model=true; + } + if model_instance.name==model_instance.name.to_snake_case(){ + report.model_name_is_snake_case=true; + } + + // 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=true; + if display_name==display_name.to_title_case(){ + report.display_name_is_title_case=true; + } + } + } + + // check Creator + if let Ok(creator)=creator{ + if !creator.is_empty(){ + report.has_creator=true; + } + } + + // check GameID + if game_id.is_ok(){ + report.model_name_prefix_is_valid=true; + } + + // === 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=true; + } + // 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=true; + } + // Spawn1 must exist + if counts.spawn_counts.get(&SpawnID::FIRST).is_some(){ + report.spawn1_exists=true; + } + if counts.spawn_counts.iter().all(|(_,&c)|c==1){ + report.no_duplicate_spawns=true; + } + if counts.wormhole_out_counts.iter().all(|(_,&c)|c==1){ + report.no_duplicate_wormhole_out=true; + } + + 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_string)=info.creationContext.creator else{ + return Err(Error::CreatorTypeMustBeUser); + }; + + // parse model version string + let version=info.revisionId.parse().map_err(Error::ParseModelVersion)?; + + let model_data=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=read_dom(std::io::Cursor::new(model_data)).map_err(Error::ModelFileDecode)?; + + let report=check(&dom); + + Ok(CheckReportAndVersion{report,version}) + } +} diff --git a/validation/src/check_mapfix.rs b/validation/src/check_mapfix.rs new file mode 100644 index 0000000..b261f4e --- /dev/null +++ b/validation/src/check_mapfix.rs @@ -0,0 +1,57 @@ +use crate::check::CheckReportAndVersion; +use crate::nats_types::CheckMapfixRequest; + +#[allow(dead_code)] +#[derive(Debug)] +pub enum Error{ + Check(crate::check::Error), + ApiActionMapfixCheck(submissions_api::Error), +} +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{} + +impl crate::message_handler::MessageHandler{ + pub async fn check_mapfix(&self,check_info:CheckMapfixRequest)->Result<(),Error>{ + let mapfix_id=check_info.MapfixID; + let check_result=self.check_inner(check_info.into()).await; + + // update the mapfix depending on the result + match check_result{ + Ok(CheckReportAndVersion{report,version})=>{ + if report.pass(){ + // update the mapfix model status to submitted + self.api.action_mapfix_submitted( + submissions_api::types::ActionMapfixSubmittedRequest{ + MapfixID:mapfix_id, + ModelVersion:version, + } + ).await.map_err(Error::ApiActionMapfixCheck)?; + }else{ + // update the mapfix model status to request changes + self.api.action_mapfix_request_changes( + submissions_api::types::ActionMapfixRequestChangesRequest{ + MapfixID:mapfix_id, + StatusMessage:report.to_string(), + } + ).await.map_err(Error::ApiActionMapfixCheck)?; + } + }, + Err(e)=>{ + // TODO: report the error + // update the mapfix model status to request changes + self.api.action_mapfix_request_changes( + submissions_api::types::ActionMapfixRequestChangesRequest{ + MapfixID:mapfix_id, + StatusMessage:e.to_string(), + } + ).await.map_err(Error::ApiActionMapfixCheck)?; + }, + } + + Ok(()) + } +} diff --git a/validation/src/check_submission.rs b/validation/src/check_submission.rs new file mode 100644 index 0000000..12f18e1 --- /dev/null +++ b/validation/src/check_submission.rs @@ -0,0 +1,57 @@ +use crate::check::CheckReportAndVersion; +use crate::nats_types::CheckSubmissionRequest; + +#[allow(dead_code)] +#[derive(Debug)] +pub enum Error{ + Check(crate::check::Error), + ApiActionSubmissionCheck(submissions_api::Error), +} +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{} + +impl crate::message_handler::MessageHandler{ + pub async fn check_submission(&self,check_info:CheckSubmissionRequest)->Result<(),Error>{ + let submission_id=check_info.SubmissionID; + let check_result=self.check_inner(check_info.into()).await; + + // update the submission depending on the result + match check_result{ + Ok(CheckReportAndVersion{report,version})=>{ + if report.pass(){ + // update the submission model status to submitted + self.api.action_submission_submitted( + submissions_api::types::ActionSubmissionSubmittedRequest{ + SubmissionID:submission_id, + ModelVersion:version, + } + ).await.map_err(Error::ApiActionSubmissionCheck)?; + }else{ + // update the submission model status to request changes + self.api.action_submission_request_changes( + submissions_api::types::ActionSubmissionRequestChangesRequest{ + SubmissionID:submission_id, + StatusMessage:report.to_string(), + } + ).await.map_err(Error::ApiActionSubmissionCheck)?; + } + }, + Err(e)=>{ + // TODO: report the error + // update the submission model status to request changes + self.api.action_submission_request_changes( + submissions_api::types::ActionSubmissionRequestChangesRequest{ + SubmissionID:submission_id, + StatusMessage:e.to_string(), + } + ).await.map_err(Error::ApiActionSubmissionCheck)?; + }, + } + + Ok(()) + } +} diff --git a/validation/src/main.rs b/validation/src/main.rs index 2b91fdc..801ea3f 100644 --- a/validation/src/main.rs +++ b/validation/src/main.rs @@ -5,6 +5,9 @@ mod message_handler; mod nats_types; mod types; mod download; +mod check; +mod check_mapfix; +mod check_submission; mod create; mod create_mapfix; mod create_submission; diff --git a/validation/src/message_handler.rs b/validation/src/message_handler.rs index 43a6a6d..fc30782 100644 --- a/validation/src/message_handler.rs +++ b/validation/src/message_handler.rs @@ -7,6 +7,8 @@ pub enum HandleMessageError{ UnknownSubject(String), CreateMapfix(submissions_api::Error), CreateSubmission(submissions_api::Error), + CheckMapfix(crate::check_mapfix::Error), + CheckSubmission(crate::check_submission::Error), UploadMapfix(crate::upload_mapfix::Error), UploadSubmission(crate::upload_submission::Error), ValidateMapfix(crate::validate_mapfix::Error), @@ -52,6 +54,8 @@ impl MessageHandler{ match message.subject.as_str(){ "maptest.mapfixes.create"=>self.create_mapfix(from_slice(&message.payload)?).await.map_err(HandleMessageError::CreateMapfix), "maptest.submissions.create"=>self.create_submission(from_slice(&message.payload)?).await.map_err(HandleMessageError::CreateSubmission), + "maptest.mapfixes.check"=>self.check_mapfix(from_slice(&message.payload)?).await.map_err(HandleMessageError::CheckMapfix), + "maptest.submissions.check"=>self.check_submission(from_slice(&message.payload)?).await.map_err(HandleMessageError::CheckSubmission), "maptest.mapfixes.upload"=>self.upload_mapfix(from_slice(&message.payload)?).await.map_err(HandleMessageError::UploadMapfix), "maptest.submissions.upload"=>self.upload_submission(from_slice(&message.payload)?).await.map_err(HandleMessageError::UploadSubmission), "maptest.mapfixes.validate"=>self.validate_mapfix(from_slice(&message.payload)?).await.map_err(HandleMessageError::ValidateMapfix), diff --git a/validation/src/nats_types.rs b/validation/src/nats_types.rs index 1401f83..a1126ae 100644 --- a/validation/src/nats_types.rs +++ b/validation/src/nats_types.rs @@ -20,6 +20,20 @@ pub struct CreateMapfixRequest{ pub TargetAssetID:u64, } +#[allow(nonstandard_style)] +#[derive(serde::Deserialize)] +pub struct CheckSubmissionRequest{ + pub SubmissionID:i64, + pub ModelID:u64, +} + +#[allow(nonstandard_style)] +#[derive(serde::Deserialize)] +pub struct CheckMapfixRequest{ + pub MapfixID:i64, + pub ModelID:u64, +} + #[allow(nonstandard_style)] #[derive(serde::Deserialize)] pub struct ValidateSubmissionRequest{