Some checks failed
continuous-integration/drone/push Build is failing
Closes #276 Reviewed-on: #323 Co-authored-by: Rhys Lloyd <krakow20@gmail.com> Co-committed-by: Rhys Lloyd <krakow20@gmail.com>
1017 lines
33 KiB
Rust
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})
|
|
}
|
|
}
|