use std::collections::{HashSet,HashMap}; use crate::download::download_asset_version; use crate::rbx_util::{get_mapinfo,get_root_instance,read_dom,ReadDomError,GameID,ParseGameIDError,MapInfo,GetRootInstanceError,StringValueError}; use heck::{ToSnakeCase,ToTitleCase}; use rbx_dom_weak::Instance; use rust_grpc::validator::Check; #[expect(dead_code)] #[derive(Debug)] pub enum Error{ ModelInfoDownload(rbx_asset::cloud::GetError), CreatorTypeMustBeUser, Download(crate::download::Error), ModelFileDecode(ReadDomError), GetRootInstance(GetRootInstanceError), IntoMapInfoOwned(IntoMapInfoOwnedError), ToJsonValue(serde_json::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{} macro_rules! lazy_regex{ ($r:literal)=>{{ use regex::Regex; use std::sync::LazyLock; static RE:LazyLock=LazyLock::new(||Regex::new($r).unwrap()); &RE }}; } #[expect(nonstandard_style)] pub struct CheckRequest{ ModelID:u64, SkipChecks:bool, } impl From for CheckRequest{ fn from(value:crate::nats_types::CheckMapfixRequest)->Self{ Self{ ModelID:value.ModelID, SkipChecks:value.SkipChecks, } } } impl From for CheckRequest{ fn from(value:crate::nats_types::CheckSubmissionRequest)->Self{ Self{ ModelID:value.ModelID, SkipChecks:value.SkipChecks, } } } #[derive(Clone,Copy,Debug,Hash,Eq,PartialEq,Ord,PartialOrd)] struct ModeID(u64); impl ModeID{ const MAIN:Self=Self(0); const BONUS:Self=Self(1); } impl std::fmt::Display for ModeID{ fn fmt(&self,f:&mut std::fmt::Formatter<'_>)->std::fmt::Result{ match self{ &ModeID::MAIN=>write!(f,"Main"), &ModeID(mode_id)=>write!(f,"Bonus{mode_id}"), } } } enum Zone{ Start, Finish, Anticheat, } struct ModeElement{ zone:Zone, mode_id:ModeID, } #[expect(dead_code)] pub enum IDParseError{ NoCaptures, ParseInt(core::num::ParseIntError), } // Parse a Zone from a part name impl std::str::FromStr for ModeElement{ type Err=IDParseError; fn from_str(s:&str)->Result{ match s{ "MapStart"=>Ok(Self{zone:Zone::Start,mode_id:ModeID::MAIN}), "MapFinish"=>Ok(Self{zone:Zone::Finish,mode_id:ModeID::MAIN}), "MapAnticheat"=>Ok(Self{zone:Zone::Anticheat,mode_id:ModeID::MAIN}), "BonusStart"=>Ok(Self{zone:Zone::Start,mode_id:ModeID::BONUS}), "BonusFinish"=>Ok(Self{zone:Zone::Finish,mode_id:ModeID::BONUS}), "BonusAnticheat"=>Ok(Self{zone:Zone::Anticheat,mode_id:ModeID::BONUS}), other=>{ let everything_pattern=lazy_regex!(r"^Bonus(\d+)Start$|^BonusStart(\d+)$|^Bonus(\d+)Finish$|^BonusFinish(\d+)$|^Bonus(\d+)Anticheat$|^BonusAnticheat(\d+)$"); if let Some(captures)=everything_pattern.captures(other){ if let Some(mode_id)=captures.get(1).or(captures.get(2)){ return Ok(Self{ zone:Zone::Start, mode_id:ModeID(mode_id.as_str().parse().map_err(IDParseError::ParseInt)?), }); } if let Some(mode_id)=captures.get(3).or(captures.get(4)){ return Ok(Self{ zone:Zone::Finish, mode_id:ModeID(mode_id.as_str().parse().map_err(IDParseError::ParseInt)?), }); } if let Some(mode_id)=captures.get(5).or(captures.get(6)){ return Ok(Self{ zone:Zone::Anticheat, mode_id:ModeID(mode_id.as_str().parse().map_err(IDParseError::ParseInt)?), }); } } Err(IDParseError::NoCaptures) } } } } impl std::fmt::Display for ModeElement{ fn fmt(&self,f:&mut std::fmt::Formatter<'_>)->std::fmt::Result{ match self{ ModeElement{zone:Zone::Start,mode_id:ModeID::MAIN}=>write!(f,"MapStart"), ModeElement{zone:Zone::Start,mode_id:ModeID::BONUS}=>write!(f,"BonusStart"), ModeElement{zone:Zone::Start,mode_id:ModeID(mode_id)}=>write!(f,"Bonus{mode_id}Start"), ModeElement{zone:Zone::Finish,mode_id:ModeID::MAIN}=>write!(f,"MapFinish"), ModeElement{zone:Zone::Finish,mode_id:ModeID::BONUS}=>write!(f,"BonusFinish"), ModeElement{zone:Zone::Finish,mode_id:ModeID(mode_id)}=>write!(f,"Bonus{mode_id}Finish"), ModeElement{zone:Zone::Anticheat,mode_id:ModeID::MAIN}=>write!(f,"MapAnticheat"), ModeElement{zone:Zone::Anticheat,mode_id:ModeID::BONUS}=>write!(f,"BonusAnticheat"), ModeElement{zone:Zone::Anticheat,mode_id:ModeID(mode_id)}=>write!(f,"Bonus{mode_id}Anticheat"), } } } #[derive(Clone,Copy,Debug,Hash,Eq,PartialEq)] struct StageID(u64); impl StageID{ const FIRST:Self=Self(1); } enum StageElementBehaviour{ Teleport, Spawn, } struct StageElement{ stage_id:StageID, behaviour:StageElementBehaviour, } // Parse a SpawnTeleport from a part name impl std::str::FromStr for StageElement{ type Err=IDParseError; fn from_str(s:&str)->Result{ // Trigger ForceTrigger Teleport ForceTeleport SpawnAt ForceSpawnAt let teleport_pattern=lazy_regex!(r"^(?:Force)?(Teleport|SpawnAt|Trigger)(\d+)$"); if let Some(captures)=teleport_pattern.captures(s){ return Ok(StageElement{ behaviour:StageElementBehaviour::Teleport, stage_id:StageID(captures[1].parse().map_err(IDParseError::ParseInt)?), }); } // Spawn let spawn_pattern=lazy_regex!(r"^Spawn(\d+)$"); if let Some(captures)=spawn_pattern.captures(s){ return Ok(StageElement{ behaviour:StageElementBehaviour::Spawn, stage_id:StageID(captures[1].parse().map_err(IDParseError::ParseInt)?), }); } Err(IDParseError::NoCaptures) } } impl std::fmt::Display for StageElement{ fn fmt(&self,f:&mut std::fmt::Formatter<'_>)->std::fmt::Result{ match self{ StageElement{behaviour:StageElementBehaviour::Spawn,stage_id:StageID(stage_id)}=>write!(f,"Spawn{stage_id}"), StageElement{behaviour:StageElementBehaviour::Teleport,stage_id:StageID(stage_id)}=>write!(f,"Teleport{stage_id}"), } } } #[derive(Clone,Copy,Debug,Hash,Eq,PartialEq)] struct WormholeID(u64); enum WormholeBehaviour{ In, Out, } struct WormholeElement{ behaviour:WormholeBehaviour, wormhole_id:WormholeID, } // Parse a Wormhole from a part name impl std::str::FromStr for WormholeElement{ type Err=IDParseError; fn from_str(s:&str)->Result{ let wormhole_in_pattern=lazy_regex!(r"^WormholeIn(\d+)$"); if let Some(captures)=wormhole_in_pattern.captures(s){ return Ok(Self{ behaviour:WormholeBehaviour::In, wormhole_id:WormholeID(captures[1].parse().map_err(IDParseError::ParseInt)?), }); } let wormhole_out_pattern=lazy_regex!(r"^WormholeOut(\d+)$"); if let Some(captures)=wormhole_out_pattern.captures(s){ return Ok(Self{ behaviour:WormholeBehaviour::Out, wormhole_id:WormholeID(captures[1].parse().map_err(IDParseError::ParseInt)?), }); } Err(IDParseError::NoCaptures) } } impl std::fmt::Display for WormholeElement{ fn fmt(&self,f:&mut std::fmt::Formatter<'_>)->std::fmt::Result{ match self{ WormholeElement{behaviour:WormholeBehaviour::In,wormhole_id:WormholeID(wormhole_id)}=>write!(f,"WormholeIn{wormhole_id}"), WormholeElement{behaviour:WormholeBehaviour::Out,wormhole_id:WormholeID(wormhole_id)}=>write!(f,"WormholeOut{wormhole_id}"), } } } fn count_sequential(modes:&HashMap>)->usize{ for mode_id in 0..modes.len(){ if !modes.contains_key(&ModeID(mode_id as u64)){ return mode_id; } } return modes.len(); } /// Count various map elements #[derive(Default)] struct Counts<'a>{ mode_start_counts:HashMap>, mode_finish_counts:HashMap>, mode_anticheat_counts:HashMap>, teleport_counts:HashMap>, spawn_counts:HashMap, wormhole_in_counts:HashMap, wormhole_out_counts:HashMap, } pub struct ModelInfo<'a>{ model_class:&'a str, model_name:&'a str, map_info:MapInfo<'a>, counts:Counts<'a>, unanchored_parts:Vec<&'a Instance>, } impl ModelInfo<'_>{ pub fn count_modes(&self)->Option{ let start_zones_count=self.counts.mode_start_counts.len(); let finish_zones_count=self.counts.mode_finish_counts.len(); let sequential_start_zones=count_sequential(&self.counts.mode_start_counts); let sequential_finish_zones=count_sequential(&self.counts.mode_finish_counts); // all counts must match if start_zones_count==finish_zones_count && sequential_start_zones==sequential_finish_zones && start_zones_count==sequential_start_zones && finish_zones_count==sequential_finish_zones { Some(start_zones_count) }else{ None } } } pub fn get_model_info<'a>(dom:&'a rbx_dom_weak::WeakDom,model_instance:&'a rbx_dom_weak::Instance)->ModelInfo<'a>{ // extract model info let map_info=get_mapinfo(dom,model_instance); // count objects (default count is 0) let mut counts=Counts::default(); // locate unanchored parts let mut unanchored_parts=Vec::new(); let anchored_ustr=rbx_dom_weak::ustr("Anchored"); let db=rbx_reflection_database::get().unwrap(); let base_part=&db.classes["BasePart"]; let base_parts=dom.descendants_of(model_instance.referent()).filter(|&instance| db.classes.get(instance.class.as_str()).is_some_and(|class| db.has_superclass(class,base_part) ) ); for instance in base_parts{ // Zones match instance.name.parse(){ Ok(ModeElement{zone:Zone::Start,mode_id})=>counts.mode_start_counts.entry(mode_id).or_default().push(instance), Ok(ModeElement{zone:Zone::Finish,mode_id})=>counts.mode_finish_counts.entry(mode_id).or_default().push(instance), Ok(ModeElement{zone:Zone::Anticheat,mode_id})=>counts.mode_anticheat_counts.entry(mode_id).or_default().push(instance), Err(_)=>(), } // Spawns & Teleports match instance.name.parse(){ Ok(StageElement{behaviour:StageElementBehaviour::Teleport,stage_id})=>counts.teleport_counts.entry(stage_id).or_default().push(instance), Ok(StageElement{behaviour:StageElementBehaviour::Spawn,stage_id})=>*counts.spawn_counts.entry(stage_id).or_insert(0)+=1, Err(_)=>(), } // Wormholes match instance.name.parse(){ Ok(WormholeElement{behaviour:WormholeBehaviour::In,wormhole_id})=>*counts.wormhole_in_counts.entry(wormhole_id).or_insert(0)+=1, Ok(WormholeElement{behaviour:WormholeBehaviour::Out,wormhole_id})=>*counts.wormhole_out_counts.entry(wormhole_id).or_insert(0)+=1, Err(_)=>(), } // Unanchored parts if let Some(rbx_dom_weak::types::Variant::Bool(false))=instance.properties.get(&anchored_ustr){ unanchored_parts.push(instance); } } ModelInfo{ model_class:model_instance.class.as_str(), model_name:model_instance.name.as_str(), map_info, counts, unanchored_parts, } } // check if an observed string matches an expected string pub struct StringEquality<'a,Str>{ observed:&'a str, expected:Str, } impl<'a,Str> StringEquality<'a,Str> where &'a str:PartialEq, { /// Compute the StringCheck, passing through the provided value on success. fn check(self,value:T)->Result{ if self.observed==self.expected{ Ok(value) }else{ Err(self) } } } impl std::fmt::Display for StringEquality<'_,Str>{ fn fmt(&self,f:&mut std::fmt::Formatter<'_>)->std::fmt::Result{ write!(f,"expected: {}, observed: {}",self.expected,self.observed) } } // check if a string is empty pub struct StringEmpty; impl std::fmt::Display for StringEmpty{ fn fmt(&self,f:&mut std::fmt::Formatter<'_>)->std::fmt::Result{ write!(f,"Empty string") } } fn check_empty(value:&str)->Result<&str,StringEmpty>{ (!value.is_empty()).then_some(value).ok_or(StringEmpty) } // check for duplicate objects pub struct DuplicateCheckContext(HashMap); pub struct DuplicateCheck(Result<(),DuplicateCheckContext>); impl DuplicateCheckContext{ /// Compute the DuplicateCheck using the contents predicate. fn check(self,f:impl Fn(&T)->bool)->DuplicateCheck{ let Self(mut set)=self; // remove correct entries set.retain(|_,c|f(c)); // if any entries remain, they are incorrect if set.is_empty(){ DuplicateCheck(Ok(())) }else{ DuplicateCheck(Err(Self(set))) } } } // Check that there are no items which do not have a matching item in a reference set pub struct SetDifferenceCheckContextAllowNone{ extra:HashMap, } // Check that there is at least one matching item for each item in a reference set, and no extra items pub struct SetDifferenceCheckContextAtLeastOne{ extra:HashMap, missing:HashSet, } pub struct SetDifferenceCheck(Result<(),Context>); impl SetDifferenceCheckContextAllowNone{ fn new(initial_set:HashMap)->Self{ Self{ extra:initial_set, } } } impl SetDifferenceCheckContextAllowNone{ /// Compute the SetDifferenceCheck result for the specified reference set. fn check(mut self,reference_set:&HashMap)->SetDifferenceCheck{ // remove correct entries for id in reference_set.keys(){ self.extra.remove(id); } // if any entries remain, they are incorrect if self.extra.is_empty(){ SetDifferenceCheck(Ok(())) }else{ SetDifferenceCheck(Err(self)) } } } impl SetDifferenceCheckContextAtLeastOne{ fn new(initial_set:HashMap)->Self{ Self{ extra:initial_set, missing:HashSet::new(), } } } impl SetDifferenceCheckContextAtLeastOne{ /// Compute the SetDifferenceCheck result for the specified reference set. fn check(mut self,reference_set:&HashMap)->SetDifferenceCheck{ // remove correct entries for id in reference_set.keys(){ if self.extra.remove(id).is_none(){ // the set did not contain a required item. This is a fail self.missing.insert(*id); } } // if any entries remain, they are incorrect if self.extra.is_empty()&&self.missing.is_empty(){ SetDifferenceCheck(Ok(())) }else{ SetDifferenceCheck(Err(self)) } } } /// Info lifted out of a fully compliant map pub struct MapInfoOwned{ pub display_name:String, pub creator:String, pub game_id:GameID, } #[expect(dead_code)] #[derive(Debug)] pub enum IntoMapInfoOwnedError{ DisplayName(StringValueError), Creator(StringValueError), GameID(ParseGameIDError), } impl TryFrom> for MapInfoOwned{ type Error=IntoMapInfoOwnedError; fn try_from(value:MapInfo<'_>)->Result{ Ok(Self{ display_name:value.display_name.map_err(IntoMapInfoOwnedError::DisplayName)?.to_owned(), creator:value.creator.map_err(IntoMapInfoOwnedError::Creator)?.to_owned(), game_id:value.game_id.map_err(IntoMapInfoOwnedError::GameID)?, }) } } // Named dummy types for readability struct Exists; struct Absent; enum DisplayNameError<'a>{ TitleCase(StringEquality<'a,String>), Empty(StringEmpty), TooLong(usize), StringValue(StringValueError), } fn check_display_name<'a>(display_name:Result<&'a str,StringValueError>)->Result<&'a str,DisplayNameError<'a>>{ // DisplayName StringValue can be missing or whatever let display_name=display_name.map_err(DisplayNameError::StringValue)?; // DisplayName cannot be "" let display_name=check_empty(display_name).map_err(DisplayNameError::Empty)?; // DisplayName cannot exceed 50 characters if 50(creator:Result<&'a str,StringValueError>)->Result<&'a str,CreatorError>{ // Creator StringValue can be missing or whatever let creator=creator.map_err(CreatorError::StringValue)?; // Creator cannot be "" let creator=check_empty(creator).map_err(CreatorError::Empty)?; // Creator cannot exceed 50 characters if 50{ // === METADATA CHECKS === // The root must be of class Model model_class:Result<(),StringEquality<'a,&'static str>>, // Model's name must be in snake case model_name:Result<(),StringEquality<'a,String>>, // Map must have a StringValue named DisplayName. // Value must not be empty, must be in title case. display_name:Result<&'a str,DisplayNameError<'a>>, // Map must have a StringValue named Creator. // Value must not be empty. creator:Result<&'a str,CreatorError>, // The prefix of the model's name must match the game it was submitted for. // bhop_ for bhop, and surf_ for surf game_id:Result, // === MODE CHECKS === // MapStart must exist mapstart:Result, // No duplicate map starts (including bonuses) mode_start_counts:DuplicateCheck>, // At least one finish zone for each start zone, and no finishes with no start mode_finish_counts:SetDifferenceCheck>>, // Check for dangling MapAnticheat zones (no associated MapStart) mode_anticheat_counts:SetDifferenceCheck>>, // Check that modes are sequential modes_sequential:Result<(),Vec>, // Spawn1 must exist spawn1:Result, // Check for dangling Teleport# (no associated Spawn#) teleport_counts:SetDifferenceCheck>>, // No duplicate Spawn# spawn_counts:DuplicateCheck, // Check for dangling WormholeIn# (no associated WormholeOut#) wormhole_in_counts:SetDifferenceCheck>, // No duplicate WormholeOut# (duplicate WormholeIn# ok) // No dangling WormholeOut# wormhole_out_counts:DuplicateCheck, // === GENERAL CHECKS === unanchored_parts:Result<(),Vec<&'a Instance>>, } impl<'a> ModelInfo<'a>{ fn check(self)->MapCheck<'a>{ // Check class is exactly "Model" let model_class=StringEquality{ observed:self.model_class, expected:"Model", }.check(()); // Check model name is snake case let model_name=StringEquality{ observed:self.model_name, expected:self.model_name.to_snake_case(), }.check(()); // Check display name is not empty and has title case let display_name=check_display_name(self.map_info.display_name); // Check Creator is not empty let creator=check_creator(self.map_info.creator); // Check GameID (model name was prefixed with bhop_ surf_ etc) let game_id=self.map_info.game_id; // MapStart must exist let mapstart=if self.counts.mode_start_counts.contains_key(&ModeID::MAIN){ Ok(Exists) }else{ Err(Absent) }; // Spawn1 must exist let spawn1=if self.counts.spawn_counts.contains_key(&StageID::FIRST){ Ok(Exists) }else{ Err(Absent) }; // Check that at least one finish zone exists for each start zone. // This also checks that there are no finish zones without a corresponding start zone. let mode_finish_counts=SetDifferenceCheckContextAtLeastOne::new(self.counts.mode_finish_counts) .check(&self.counts.mode_start_counts); // Check that there are no anticheat zones without a corresponding start zone. // Modes are allowed to have 0 anticheat zones. let mode_anticheat_counts=SetDifferenceCheckContextAllowNone::new(self.counts.mode_anticheat_counts) .check(&self.counts.mode_start_counts); // There must not be non-sequential modes. If Bonus100 exists, Bonuses 1-99 had better also exist. let modes_sequential={ let sequential=count_sequential(&self.counts.mode_start_counts); if sequential==self.counts.mode_start_counts.len(){ Ok(()) }else{ let mut non_sequential=Vec::with_capacity(self.counts.mode_start_counts.len()-sequential); for (&mode_id,_) in &self.counts.mode_start_counts{ let ModeID(mode_id_u64)=mode_id; if !(mode_id_u64{ fn result(self)->Result>{ match self{ MapCheck{ model_class:Ok(()), model_name:Ok(()), display_name:Ok(display_name), creator:Ok(creator), game_id:Ok(game_id), mapstart:Ok(Exists), mode_start_counts:DuplicateCheck(Ok(())), mode_finish_counts:SetDifferenceCheck(Ok(())), mode_anticheat_counts:SetDifferenceCheck(Ok(())), modes_sequential:Ok(()), spawn1:Ok(Exists), teleport_counts:SetDifferenceCheck(Ok(())), spawn_counts:DuplicateCheck(Ok(())), wormhole_in_counts:SetDifferenceCheck(Ok(())), wormhole_out_counts:DuplicateCheck(Ok(())), unanchored_parts:Ok(()), }=>{ Ok(MapInfoOwned{ display_name:display_name.to_owned(), creator:creator.to_owned(), game_id, }) }, other=>Err(other.itemize()), } } } struct Separated{ f:F, separator:&'static str, } impl Separated{ fn new(separator:&'static str,f:F)->Self{ Self{separator,f} } } impl std::fmt::Display for Separated where D:std::fmt::Display, I:IntoIterator, F:Fn()->I, { fn fmt(&self,f:&mut std::fmt::Formatter<'_>)->std::fmt::Result{ let mut it=(self.f)().into_iter(); if let Some(first)=it.next(){ write!(f,"{first}")?; for item in it{ write!(f,"{}{item}",self.separator)?; } } Ok(()) } } struct Duplicates{ display:D, duplicates:usize, } impl Duplicates{ fn new(display:D,duplicates:usize)->Self{ Self{ display, duplicates, } } } impl std::fmt::Display for Duplicates{ fn fmt(&self,f:&mut std::fmt::Formatter<'_>)->std::fmt::Result{ write!(f,"{} ({} duplicates)",self.display,self.duplicates) } } macro_rules! passed{ ($name:literal)=>{ Check{ name:$name.to_owned(), summary:String::new(), passed:true, } } } macro_rules! summary{ ($name:literal,$summary:expr)=>{ Check{ name:$name.to_owned(), summary:$summary, passed:false, } }; } macro_rules! summary_format{ ($name:literal,$fmt:literal)=>{ Check{ name:$name.to_owned(), summary:format!($fmt), passed:false, } }; } // Generate an error message for each observed issue separated by newlines. // This defines MapCheck.to_string() which is used in MapCheck.result() impl MapCheck<'_>{ fn itemize(&self)->Result{ let model_class=match &self.model_class{ Ok(())=>passed!("ModelClass"), Err(context)=>summary_format!("ModelClass","Invalid model class: {context}"), }; let model_name=match &self.model_name{ Ok(())=>passed!("ModelName"), Err(context)=>summary_format!("ModelName","Model name must have snake_case: {context}"), }; let display_name=match &self.display_name{ Ok(_)=>passed!("DisplayName"), Err(DisplayNameError::TitleCase(context))=>summary_format!("DisplayName","DisplayName must have Title Case: {context}"), Err(DisplayNameError::Empty(context))=>summary_format!("DisplayName","Invalid DisplayName: {context}"), Err(DisplayNameError::TooLong(context))=>summary_format!("DisplayName","DisplayName is too long: {context} characters (50 characters max)"), Err(DisplayNameError::StringValue(context))=>summary_format!("DisplayName","DisplayName StringValue: {context}"), }; let creator=match &self.creator{ Ok(_)=>passed!("Creator"), Err(CreatorError::Empty(context))=>summary_format!("Creator","Invalid Creator: {context}"), Err(CreatorError::TooLong(context))=>summary_format!("Creator","Creator is too long: {context} characters (50 characters max)"), Err(CreatorError::StringValue(context))=>summary_format!("Creator","Creator StringValue: {context}"), }; let game_id=match &self.game_id{ Ok(_)=>passed!("GameID"), Err(ParseGameIDError)=>summary!("GameID","Model name must be prefixed with bhop_ surf_ or flytrials_".to_owned()), }; let mapstart=match &self.mapstart{ Ok(Exists)=>passed!("MapStart"), Err(Absent)=>summary_format!("MapStart","Model has no MapStart"), }; let duplicate_start=match &self.mode_start_counts{ DuplicateCheck(Ok(()))=>passed!("DuplicateStart"), DuplicateCheck(Err(DuplicateCheckContext(context)))=>{ let context=Separated::new(", ",||context.iter().map(|(&mode_id,instances)| Duplicates::new(ModeElement{zone:Zone::Start,mode_id},instances.len()) )); summary_format!("DuplicateStart","Duplicate start zones: {context}") } }; let (extra_finish,missing_finish)=match &self.mode_finish_counts{ SetDifferenceCheck(Ok(()))=>(passed!("DanglingFinish"),passed!("MissingFinish")), SetDifferenceCheck(Err(context))=>( if context.extra.is_empty(){ passed!("DanglingFinish") }else{ let plural=if context.extra.len()==1{"zone"}else{"zones"}; let context=Separated::new(", ",||context.extra.iter().map(|(&mode_id,_instances)| ModeElement{zone:Zone::Finish,mode_id} )); summary_format!("DanglingFinish","No matching start zone for finish {plural}: {context}") }, if context.missing.is_empty(){ passed!("MissingFinish") }else{ let plural=if context.missing.len()==1{"zone"}else{"zones"}; let context=Separated::new(", ",||context.missing.iter().map(|&mode_id| ModeElement{zone:Zone::Finish,mode_id} )); summary_format!("MissingFinish","Missing finish {plural}: {context}") } ), }; let dangling_anticheat=match &self.mode_anticheat_counts{ SetDifferenceCheck(Ok(()))=>passed!("DanglingAnticheat"), SetDifferenceCheck(Err(context))=>{ if context.extra.is_empty(){ passed!("DanglingAnticheat") }else{ let plural=if context.extra.len()==1{"zone"}else{"zones"}; let context=Separated::new(", ",||context.extra.iter().map(|(&mode_id,_instances)| ModeElement{zone:Zone::Anticheat,mode_id} )); summary_format!("DanglingAnticheat","No matching start zone for anticheat {plural}: {context}") } } }; let sequential_modes=match &self.modes_sequential{ Ok(())=>passed!("SequentialModes"), Err(context)=>{ let non_sequential=context.len(); let plural_non_sequential=if non_sequential==1{"mode"}else{"modes"}; let comma_separated=Separated::new(", ",||context); summary_format!("SequentialModes","{non_sequential} {plural_non_sequential} should use a lower ModeID (no gaps): {comma_separated}") } }; let spawn1=match &self.spawn1{ Ok(Exists)=>passed!("Spawn1"), Err(Absent)=>summary_format!("Spawn1","Model has no Spawn1"), }; let dangling_teleport=match &self.teleport_counts{ SetDifferenceCheck(Ok(()))=>passed!("DanglingTeleport"), SetDifferenceCheck(Err(context))=>{ let unique_names:HashSet<_>=context.extra.values().flat_map(|instances| instances.iter().map(|instance|instance.name.as_str()) ).collect(); let plural=if unique_names.len()==1{"object"}else{"objects"}; let context=Separated::new(", ",||&unique_names); summary_format!("DanglingTeleport","No matching Spawn for {plural}: {context}") } }; let duplicate_spawns=match &self.spawn_counts{ DuplicateCheck(Ok(()))=>passed!("DuplicateSpawn"), DuplicateCheck(Err(DuplicateCheckContext(context)))=>{ let context=Separated::new(", ",||context.iter().map(|(&stage_id,&instances)| Duplicates::new(StageElement{behaviour:StageElementBehaviour::Spawn,stage_id},instances as usize) )); summary_format!("DuplicateSpawn","Duplicate Spawn: {context}") } }; let (extra_wormhole_in,missing_wormhole_in)=match &self.wormhole_in_counts{ SetDifferenceCheck(Ok(()))=>(passed!("ExtraWormholeIn"),passed!("MissingWormholeIn")), SetDifferenceCheck(Err(context))=>( if context.extra.is_empty(){ passed!("ExtraWormholeIn") }else{ let context=Separated::new(", ",||context.extra.iter().map(|(&wormhole_id,_instances)| WormholeElement{behaviour:WormholeBehaviour::In,wormhole_id} )); summary_format!("ExtraWormholeIn","WormholeIn with no matching WormholeOut: {context}") }, if context.missing.is_empty(){ passed!("MissingWormholeIn") }else{ // This counts WormholeIn objects, but // flipped logic is easier to understand let context=Separated::new(", ",||context.missing.iter().map(|&wormhole_id| WormholeElement{behaviour:WormholeBehaviour::Out,wormhole_id} )); summary_format!("MissingWormholeIn","WormholeOut with no matching WormholeIn: {context}") } ) }; let duplicate_wormhole_out=match &self.wormhole_out_counts{ DuplicateCheck(Ok(()))=>passed!("DuplicateWormholeOut"), DuplicateCheck(Err(DuplicateCheckContext(context)))=>{ let context=Separated::new(", ",||context.iter().map(|(&wormhole_id,&instances)| Duplicates::new(WormholeElement{behaviour:WormholeBehaviour::Out,wormhole_id},instances as usize) )); summary_format!("DuplicateWormholeOut","Duplicate WormholeOut: {context}") } }; let unanchored_parts=match &self.unanchored_parts{ Ok(())=>passed!("UnanchoredParts"), Err(unanchored_parts)=>{ let count=unanchored_parts.len(); let plural=if count==1{"part"}else{"parts"}; let context=Separated::new(", ",||unanchored_parts.iter().map(|&instance| instance.name.as_str() ).take(20)); summary_format!("UnanchoredParts","{count} unanchored {plural}: {context}") } }; Ok(MapCheckList{checks:vec![ model_class, model_name, display_name, creator, game_id, mapstart, duplicate_start, extra_finish, missing_finish, dangling_anticheat, sequential_modes, spawn1, dangling_teleport, duplicate_spawns, extra_wormhole_in, missing_wormhole_in, duplicate_wormhole_out, unanchored_parts, ]}) } } #[derive(serde::Serialize)] pub struct MapCheckList{ pub checks:Vec, } pub struct CheckListAndVersion{ pub status:Result, pub version:u64, } impl crate::message_handler::MessageHandler{ pub async fn check_inner(&self,check_info:CheckRequest)->Result{ // 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)?; // extract the root instance let model_instance=get_root_instance(&dom).map_err(Error::GetRootInstance)?; // skip checks if check_info.SkipChecks{ // extract required fields let map_info=get_mapinfo(&dom,model_instance); let map_info_owned=map_info.try_into().map_err(Error::IntoMapInfoOwned)?; let status=Ok(map_info_owned); // return early return Ok(CheckListAndVersion{status,version}); } // extract information from the model let model_info=get_model_info(&dom,model_instance); // convert the model information into a structured report let map_check=model_info.check(); // check the report, generate an error message if it fails the check let status=match map_check.result(){ Ok(map_info)=>Ok(map_info), Err(Ok(check_list))=>Err(check_list), Err(Err(e))=>return Err(Error::ToJsonValue(e)), }; Ok(CheckListAndVersion{status,version}) } }