Files
maps-service/validation/src/check.rs
Rhys Lloyd ed8a54370c
Some checks failed
continuous-integration/drone/push Build is failing
Limit DisplayName and Creator to 50 characters (#323)
Closes #276

Reviewed-on: #323
Co-authored-by: Rhys Lloyd <krakow20@gmail.com>
Co-committed-by: Rhys Lloyd <krakow20@gmail.com>
2026-03-03 17:48:10 +00:00

1017 lines
33 KiB
Rust

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<Regex>=LazyLock::new(||Regex::new($r).unwrap());
&RE
}};
}
#[expect(nonstandard_style)]
pub struct CheckRequest{
ModelID:u64,
SkipChecks:bool,
}
impl From<crate::nats_types::CheckMapfixRequest> for CheckRequest{
fn from(value:crate::nats_types::CheckMapfixRequest)->Self{
Self{
ModelID:value.ModelID,
SkipChecks:value.SkipChecks,
}
}
}
impl From<crate::nats_types::CheckSubmissionRequest> 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<Self,Self::Err>{
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<Self,Self::Err>{
// 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<Self,Self::Err>{
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<ModeID,Vec<&Instance>>)->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<ModeID,Vec<&'a Instance>>,
mode_finish_counts:HashMap<ModeID,Vec<&'a Instance>>,
mode_anticheat_counts:HashMap<ModeID,Vec<&'a Instance>>,
teleport_counts:HashMap<StageID,Vec<&'a Instance>>,
spawn_counts:HashMap<StageID,u64>,
wormhole_in_counts:HashMap<WormholeID,u64>,
wormhole_out_counts:HashMap<WormholeID,u64>,
}
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<usize>{
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<Str>,
{
/// Compute the StringCheck, passing through the provided value on success.
fn check<T>(self,value:T)->Result<T,Self>{
if self.observed==self.expected{
Ok(value)
}else{
Err(self)
}
}
}
impl<Str:std::fmt::Display> 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<ID,T>(HashMap<ID,T>);
pub struct DuplicateCheck<ID,T>(Result<(),DuplicateCheckContext<ID,T>>);
impl<ID,T> DuplicateCheckContext<ID,T>{
/// Compute the DuplicateCheck using the contents predicate.
fn check(self,f:impl Fn(&T)->bool)->DuplicateCheck<ID,T>{
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<ID,T>{
extra:HashMap<ID,T>,
}
// Check that there is at least one matching item for each item in a reference set, and no extra items
pub struct SetDifferenceCheckContextAtLeastOne<ID,T>{
extra:HashMap<ID,T>,
missing:HashSet<ID>,
}
pub struct SetDifferenceCheck<Context>(Result<(),Context>);
impl<ID,T> SetDifferenceCheckContextAllowNone<ID,T>{
fn new(initial_set:HashMap<ID,T>)->Self{
Self{
extra:initial_set,
}
}
}
impl<ID:Eq+std::hash::Hash,T> SetDifferenceCheckContextAllowNone<ID,T>{
/// Compute the SetDifferenceCheck result for the specified reference set.
fn check<U>(mut self,reference_set:&HashMap<ID,U>)->SetDifferenceCheck<Self>{
// 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<ID,T> SetDifferenceCheckContextAtLeastOne<ID,T>{
fn new(initial_set:HashMap<ID,T>)->Self{
Self{
extra:initial_set,
missing:HashSet::new(),
}
}
}
impl<ID:Copy+Eq+std::hash::Hash,T> SetDifferenceCheckContextAtLeastOne<ID,T>{
/// Compute the SetDifferenceCheck result for the specified reference set.
fn check<U>(mut self,reference_set:&HashMap<ID,U>)->SetDifferenceCheck<Self>{
// 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<MapInfo<'_>> for MapInfoOwned{
type Error=IntoMapInfoOwnedError;
fn try_from(value:MapInfo<'_>)->Result<Self,Self::Error>{
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<display_name.len(){
return Err(DisplayNameError::TooLong(display_name.len()));
}
// Check title case
let display_name=StringEquality{
observed:display_name,
expected:display_name.to_title_case(),
}.check(display_name).map_err(DisplayNameError::TitleCase)?;
Ok(display_name)
}
enum CreatorError{
Empty(StringEmpty),
TooLong(usize),
StringValue(StringValueError),
}
fn check_creator<'a>(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<creator.len(){
return Err(CreatorError::TooLong(creator.len()));
}
Ok(creator)
}
/// The result of every map check.
struct MapCheck<'a>{
// === 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<GameID,ParseGameIDError>,
// === MODE CHECKS ===
// MapStart must exist
mapstart:Result<Exists,Absent>,
// No duplicate map starts (including bonuses)
mode_start_counts:DuplicateCheck<ModeID,Vec<&'a Instance>>,
// At least one finish zone for each start zone, and no finishes with no start
mode_finish_counts:SetDifferenceCheck<SetDifferenceCheckContextAtLeastOne<ModeID,Vec<&'a Instance>>>,
// Check for dangling MapAnticheat zones (no associated MapStart)
mode_anticheat_counts:SetDifferenceCheck<SetDifferenceCheckContextAllowNone<ModeID,Vec<&'a Instance>>>,
// Check that modes are sequential
modes_sequential:Result<(),Vec<ModeID>>,
// Spawn1 must exist
spawn1:Result<Exists,Absent>,
// Check for dangling Teleport# (no associated Spawn#)
teleport_counts:SetDifferenceCheck<SetDifferenceCheckContextAllowNone<StageID,Vec<&'a Instance>>>,
// No duplicate Spawn#
spawn_counts:DuplicateCheck<StageID,u64>,
// Check for dangling WormholeIn# (no associated WormholeOut#)
wormhole_in_counts:SetDifferenceCheck<SetDifferenceCheckContextAtLeastOne<WormholeID,u64>>,
// No duplicate WormholeOut# (duplicate WormholeIn# ok)
// No dangling WormholeOut#
wormhole_out_counts:DuplicateCheck<WormholeID,u64>,
// === 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<sequential as u64){
non_sequential.push(mode_id);
}
}
// sort so it's prettier when it prints out
non_sequential.sort();
Err(non_sequential)
}
};
// There must be exactly one start zone for every mode in the map.
let mode_start_counts=DuplicateCheckContext(self.counts.mode_start_counts).check(|c|1<c.len());
// Check that there are no Teleports without a corresponding Spawn.
// Spawns are allowed to have 0 Teleports.
let teleport_counts=SetDifferenceCheckContextAllowNone::new(self.counts.teleport_counts)
.check(&self.counts.spawn_counts);
// There must be exactly one of any perticular spawn id in the map.
let spawn_counts=DuplicateCheckContext(self.counts.spawn_counts).check(|&c|1<c);
// Check that at least one WormholeIn exists for each WormholeOut.
// This also checks that there are no WormholeIn without a corresponding WormholeOut.
let wormhole_in_counts=SetDifferenceCheckContextAtLeastOne::new(self.counts.wormhole_in_counts)
.check(&self.counts.wormhole_out_counts);
// 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(|&c|1<c);
// There must not be any unanchored parts
let unanchored_parts=if self.unanchored_parts.is_empty(){
Ok(())
}else{
Err(self.unanchored_parts)
};
MapCheck{
model_class,
model_name,
display_name,
creator,
game_id,
mapstart,
mode_start_counts,
mode_finish_counts,
mode_anticheat_counts,
modes_sequential,
spawn1,
teleport_counts,
spawn_counts,
wormhole_in_counts,
wormhole_out_counts,
unanchored_parts,
}
}
}
impl MapCheck<'_>{
fn result(self)->Result<MapInfoOwned,Result<MapCheckList,serde_json::Error>>{
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:F,
separator:&'static str,
}
impl<F> Separated<F>{
fn new(separator:&'static str,f:F)->Self{
Self{separator,f}
}
}
impl<F,I,D> std::fmt::Display for Separated<F>
where
D:std::fmt::Display,
I:IntoIterator<Item=D>,
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<D>{
display:D,
duplicates:usize,
}
impl<D> Duplicates<D>{
fn new(display:D,duplicates:usize)->Self{
Self{
display,
duplicates,
}
}
}
impl<D:std::fmt::Display> std::fmt::Display for Duplicates<D>{
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<MapCheckList,serde_json::Error>{
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<Check>,
}
pub struct CheckListAndVersion{
pub status:Result<MapInfoOwned,MapCheckList>,
pub version:u64,
}
impl crate::message_handler::MessageHandler{
pub async fn check_inner(&self,check_info:CheckRequest)->Result<CheckListAndVersion,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)?;
// 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})
}
}