diff --git a/validation/src/check.rs b/validation/src/check.rs index 5acf80f..da861f3 100644 --- a/validation/src/check.rs +++ b/validation/src/check.rs @@ -1,5 +1,7 @@ +use std::borrow::Cow; +use std::collections::{HashSet,HashMap}; use crate::download::download_asset_version; -use crate::rbx_util::{class_is_a,get_mapinfo,get_root_instance,read_dom,ReadDomError,GameID}; +use crate::rbx_util::{class_is_a,get_mapinfo,get_root_instance,read_dom,ReadDomError,GameID,ParseGameIDError,MapInfo,GetRootInstanceError,StringValueError}; use heck::{ToSnakeCase,ToTitleCase}; @@ -38,124 +40,12 @@ impl From<crate::nats_types::CheckSubmissionRequest> for CheckRequest{ } } -#[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"), - } - } -} - -pub enum CheckStatus{ - Passed{ - display_name:String, - creator:String, - game_id:GameID, - }, - Failed{ - report:CheckReport, - } -} - -#[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)] +#[derive(Clone,Copy,Debug,Hash,Eq,PartialEq)] struct ModeID(u64); impl ModeID{ const MAIN:Self=Self(0); @@ -199,66 +89,37 @@ impl SpawnID{ 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>, + mode_start_counts:HashMap<ModeID,u64>, + mode_finish_counts:HashMap<ModeID,u64>, + spawn_counts:HashMap<SpawnID,u64>, + wormhole_out_counts: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(), + mode_start_counts:HashMap::new(), + mode_finish_counts:HashMap::new(), + spawn_counts:HashMap::new(), + wormhole_out_counts:HashMap::new(), } } } -pub fn check(dom:&rbx_dom_weak::WeakDom)->CheckStatus{ - // empty report with all checks failed - let mut report=CheckReport::default(); +pub struct ModelInfo<'a>{ + model_class:&'a str, + model_name:&'a str, + map_info:MapInfo<'a>, + counts:Counts, +} + +pub fn get_model_info(dom:&rbx_dom_weak::WeakDom)->Result<ModelInfo,GetRootInstanceError>{ // extract the root instance, otherwise immediately return - let Ok(model_instance)=get_root_instance(&dom)else{ - return CheckStatus::Failed{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; - } + let model_instance=get_root_instance(&dom)?; // extract model info let map_info=get_mapinfo(&dom,model_instance); - // check DisplayName - if let Ok(display_name)=map_info.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)=map_info.creator{ - if !creator.is_empty(){ - report.has_creator=Check::Pass; - } - } - - // check GameID - if map_info.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()){ @@ -286,38 +147,217 @@ pub fn check(dom:&rbx_dom_weak::WeakDom)->CheckStatus{ } } - // 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; - } + Ok(ModelInfo{ + model_class:model_instance.class.as_str(), + model_name:model_instance.name.as_str(), + map_info, + counts, + }) +} - if report.pass(){ - CheckStatus::Passed{ - // TODO: refactor data structure to avoid pain - display_name:map_info.display_name.unwrap().to_owned(), - creator:map_info.creator.unwrap().to_owned(), - game_id:map_info.game_id.unwrap(), + + +pub enum Check<Context>{ + Pass, + Fail(Context), +} + +// check if an observed string matches and expected string +pub struct StringCheck<'a>(Check<StringCheckContext<'a>>); +pub struct StringCheckContext<'a>{ + observed:&'a str, + expected:Cow<'a,str>, +} +impl<'a> StringCheckContext<'a>{ + fn check(self)->StringCheck<'a>{ + if self.observed==self.expected{ + StringCheck(Check::Pass) + }else{ + StringCheck(Check::Fail(self)) + } + } +} + +// check if a string is empty +pub enum StringEmptyCheck<Context>{ + Empty, + Passed(Context), +} + +// check for duplicate objects +pub struct DuplicateCheckContext<ID>(HashMap<ID,u64>); +pub struct DuplicateCheck<ID>(Check<DuplicateCheckContext<ID>>); +impl<ID> DuplicateCheckContext<ID>{ + fn check(self)->DuplicateCheck<ID>{ + let Self(mut set)=self; + // drop correct entries + set.retain(|_,&mut c|c!=1); + // if any entries remain, they are incorrect + if set.is_empty(){ + DuplicateCheck(Check::Pass) + }else{ + DuplicateCheck(Check::Fail(Self(set))) + } + } +} + +// check that there is at least one +pub struct AtLeastOneMatchingAndNoExtraCheckContext<ID>{ + set:HashMap<ID,u64>, + missing:HashSet<ID>, +} +pub struct AtLeastOneMatchingAndNoExtraCheck<ID>(Check<AtLeastOneMatchingAndNoExtraCheckContext<ID>>); +impl<ID> AtLeastOneMatchingAndNoExtraCheckContext<ID>{ + fn new(set:HashMap<ID,u64>)->Self{ + Self{ + set, + missing:HashSet::new(), + } + } +} +impl<ID:Copy+Eq+std::hash::Hash> AtLeastOneMatchingAndNoExtraCheckContext<ID>{ + fn check<T>(self,reference_set:&HashMap<ID,T>)->AtLeastOneMatchingAndNoExtraCheck<ID>{ + let Self{mut set,mut missing}=self; + // drop correct entries + for (id,_) in reference_set{ + if !set.remove(id).is_some(){ + // the set did not contain a required item. This is a fail + missing.insert(*id); + } + } + // if any entries remain, they are incorrect + if set.is_empty()&&missing.is_empty(){ + AtLeastOneMatchingAndNoExtraCheck(Check::Pass) + }else{ + AtLeastOneMatchingAndNoExtraCheck(Check::Fail(Self{set,missing})) + } + } +} + +pub struct MapInfoOwned{ + display_name:String, + creator:String, + game_id:GameID, +} + +// crazy! +pub struct MapCheck<'a>{ + model_class:StringCheck<'a>, + model_name:StringCheck<'a>, + display_name:Result<StringEmptyCheck<StringCheck<'a>>,StringValueError>, + creator:Result<StringEmptyCheck<()>,StringValueError>, + game_id:Result<GameID,ParseGameIDError>, + mapstart:Check<()>, + mode_start_counts:DuplicateCheck<ModeID>, + mode_finish_counts:AtLeastOneMatchingAndNoExtraCheck<ModeID>, + spawn1:Check<()>, + spawn_counts:DuplicateCheck<SpawnID>, + wormhole_out_counts:DuplicateCheck<WormholeOutID>, +} + +impl<'a> ModelInfo<'a>{ + fn check(self)->MapCheck<'a>{ + let model_class=StringCheckContext{ + observed:self.model_class, + expected:Cow::Borrowed("Model"), + }.check(); + + let model_name=StringCheckContext{ + observed:self.model_name, + expected:Cow::Owned(self.model_name.to_snake_case()), + }.check(); + + // check display name + let display_name=self.map_info.display_name.map(|display_name|{ + if display_name.is_empty(){ + StringEmptyCheck::Empty + }else{ + let display_name=StringCheckContext{ + observed:display_name, + expected:Cow::Owned(display_name.to_title_case()), + }.check(); + StringEmptyCheck::Passed(display_name) + } + }); + + // check Creator + let creator=self.map_info.creator.map(|creator|{ + if creator.is_empty(){ + StringEmptyCheck::Empty + }else{ + StringEmptyCheck::Passed(()) + } + }); + + // check GameID + let game_id=self.map_info.game_id; + + // MapStart must exist + let mapstart=if self.counts.mode_start_counts.get(&ModeID::MAIN).is_some(){ + Check::Pass + }else{ + Check::Fail(()) + }; + + // Spawn1 must exist + let spawn1=if self.counts.spawn_counts.get(&SpawnID::FIRST).is_some(){ + Check::Pass + }else{ + Check::Fail(()) + }; + + // check that at least one end zone exists for each start zone. + let mode_finish_counts=AtLeastOneMatchingAndNoExtraCheckContext::new(self.counts.mode_finish_counts).check(&self.counts.mode_start_counts); + + // there must be exactly one start zone for every mode in the map. + let mode_start_counts=DuplicateCheckContext(self.counts.mode_start_counts).check(); + + // there must be exactly one of any perticular spawn id in the map. + let spawn_counts=DuplicateCheckContext(self.counts.spawn_counts).check(); + + // there must be exactly one of any perticular wormhole out id in the map. + let wormhole_out_counts=DuplicateCheckContext(self.counts.wormhole_out_counts).check(); + + MapCheck{ + model_class, + model_name, + display_name, + creator, + game_id, + mapstart, + mode_start_counts, + mode_finish_counts, + spawn1, + spawn_counts, + wormhole_out_counts, + } + } +} + +impl<'a> MapCheck<'a>{ + fn pass(self)->Result<MapInfoOwned,Self>{ + match self{ + MapCheck{ + model_class:StringCheck(Check::Pass), + model_name:StringCheck(Check::Pass), + display_name:Ok(StringEmptyCheck::Passed(StringCheck(Check::Pass))), + creator:Ok(StringEmptyCheck::Passed(())), + game_id:Ok(game_id), + mapstart, + mode_start_counts, + mode_finish_counts, + spawn1, + spawn_counts, + wormhole_out_counts, + }=>{ + Ok(MapInfoOwned{ + display_name:display_name.to_owned(), + creator:creator.to_owned(), + game_id, + }) + }, + other=>Err(other), } - }else{ - CheckStatus::Failed{report} } }