validator: rusty check v1
This commit is contained in:
parent
a5daa2df4a
commit
337cf502cf
@ -1,5 +1,7 @@
|
|||||||
|
use std::borrow::Cow;
|
||||||
|
use std::collections::{HashSet,HashMap};
|
||||||
use crate::download::download_asset_version;
|
use crate::download::download_asset_version;
|
||||||
use crate::rbx_util::{class_is_a,get_mapinfo,get_root_instance,read_dom,ReadDomError,GameID};
|
use crate::rbx_util::{class_is_a,get_mapinfo,get_root_instance,read_dom,ReadDomError,GameID,ParseGameIDError,MapInfo,GetRootInstanceError,StringValueError};
|
||||||
|
|
||||||
use heck::{ToSnakeCase,ToTitleCase};
|
use heck::{ToSnakeCase,ToTitleCase};
|
||||||
|
|
||||||
@ -38,124 +40,12 @@ impl From<crate::nats_types::CheckSubmissionRequest> for CheckRequest{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
pub enum Check{
|
|
||||||
Pass,
|
|
||||||
#[default]
|
|
||||||
Fail,
|
|
||||||
}
|
|
||||||
impl Check{
|
|
||||||
fn pass(&self)->bool{
|
|
||||||
match self{
|
|
||||||
Check::Pass=>true,
|
|
||||||
Check::Fail=>false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
impl std::fmt::Display for Check{
|
|
||||||
fn fmt(&self,f:&mut std::fmt::Formatter<'_>)->std::fmt::Result{
|
|
||||||
match self{
|
|
||||||
Check::Pass=>write!(f,"passed"),
|
|
||||||
Check::Fail=>write!(f,"failed"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub enum CheckStatus{
|
|
||||||
Passed{
|
|
||||||
display_name:String,
|
|
||||||
creator:String,
|
|
||||||
game_id:GameID,
|
|
||||||
},
|
|
||||||
Failed{
|
|
||||||
report:CheckReport,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
pub struct CheckReport{
|
|
||||||
// === METADATA CHECKS ===
|
|
||||||
// the model must have exactly 1 root part (models uploaded to roblox can have multiple roots)
|
|
||||||
exactly_one_root:Check,
|
|
||||||
// the root must be of class Model
|
|
||||||
root_is_model:Check,
|
|
||||||
// the prefix of the model's name must match the game it was submitted for. bhop_ for bhop, and surf_ for surf
|
|
||||||
model_name_prefix_is_valid:Check,
|
|
||||||
// your model's name must match this regex: ^[a-z0-9_]
|
|
||||||
model_name_is_snake_case:Check,
|
|
||||||
// map must have a StringValue named Creator and DisplayName. additionally, they must both have a value
|
|
||||||
has_display_name:Check,
|
|
||||||
has_creator:Check,
|
|
||||||
// the display name must be capitalized
|
|
||||||
display_name_is_title_case:Check,
|
|
||||||
// you cannot have any Scripts or ModuleScripts that have the keyword 'getfenv" or 'require'
|
|
||||||
// you cannot have more than 50 duplicate scripts
|
|
||||||
|
|
||||||
// === MODE CHECKS ===
|
|
||||||
// Exactly one MapStart
|
|
||||||
exactly_one_mapstart:Check,
|
|
||||||
// At least one MapFinish
|
|
||||||
at_least_one_mapfinish:Check,
|
|
||||||
// Spawn0 or Spawn1 must exist
|
|
||||||
spawn1_exists:Check,
|
|
||||||
// No duplicate Spawn#
|
|
||||||
no_duplicate_spawns:Check,
|
|
||||||
// No duplicate WormholeOut# (duplicate WormholeIn# ok)
|
|
||||||
no_duplicate_wormhole_out:Check,
|
|
||||||
}
|
|
||||||
impl CheckReport{
|
|
||||||
pub fn pass(&self)->bool{
|
|
||||||
return self.exactly_one_root.pass()
|
|
||||||
&&self.root_is_model.pass()
|
|
||||||
&&self.model_name_prefix_is_valid.pass()
|
|
||||||
&&self.model_name_is_snake_case.pass()
|
|
||||||
&&self.has_display_name.pass()
|
|
||||||
&&self.has_creator.pass()
|
|
||||||
&&self.display_name_is_title_case.pass()
|
|
||||||
&&self.exactly_one_mapstart.pass()
|
|
||||||
&&self.at_least_one_mapfinish.pass()
|
|
||||||
&&self.spawn1_exists.pass()
|
|
||||||
&&self.no_duplicate_spawns.pass()
|
|
||||||
&&self.no_duplicate_wormhole_out.pass()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
impl std::fmt::Display for CheckReport{
|
|
||||||
fn fmt(&self,f:&mut std::fmt::Formatter<'_>)->std::fmt::Result{
|
|
||||||
write!(f,
|
|
||||||
"exactly_one_root={}\n
|
|
||||||
root_is_model={}\n
|
|
||||||
model_name_prefix_is_valid={}\n
|
|
||||||
model_name_is_snake_case={}\n
|
|
||||||
has_display_name={}\n
|
|
||||||
has_creator={}\n
|
|
||||||
display_name_is_title_case={}\n
|
|
||||||
exactly_one_mapstart={}\n
|
|
||||||
at_least_one_mapfinish={}\n
|
|
||||||
spawn1_exists={}\n
|
|
||||||
no_duplicate_spawns={}\n
|
|
||||||
no_duplicate_wormhole_out={}",
|
|
||||||
self.exactly_one_root,
|
|
||||||
self.root_is_model,
|
|
||||||
self.model_name_prefix_is_valid,
|
|
||||||
self.model_name_is_snake_case,
|
|
||||||
self.has_display_name,
|
|
||||||
self.has_creator,
|
|
||||||
self.display_name_is_title_case,
|
|
||||||
self.exactly_one_mapstart,
|
|
||||||
self.at_least_one_mapfinish,
|
|
||||||
self.spawn1_exists,
|
|
||||||
self.no_duplicate_spawns,
|
|
||||||
self.no_duplicate_wormhole_out,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum Zone{
|
enum Zone{
|
||||||
Start(ModeID),
|
Start(ModeID),
|
||||||
Finish(ModeID),
|
Finish(ModeID),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug,Hash,Eq,PartialEq)]
|
#[derive(Clone,Copy,Debug,Hash,Eq,PartialEq)]
|
||||||
struct ModeID(u64);
|
struct ModeID(u64);
|
||||||
impl ModeID{
|
impl ModeID{
|
||||||
const MAIN:Self=Self(0);
|
const MAIN:Self=Self(0);
|
||||||
@ -199,66 +89,37 @@ impl SpawnID{
|
|||||||
struct WormholeOutID(u64);
|
struct WormholeOutID(u64);
|
||||||
|
|
||||||
struct Counts{
|
struct Counts{
|
||||||
mode_start_counts:std::collections::HashMap<ModeID,u64>,
|
mode_start_counts:HashMap<ModeID,u64>,
|
||||||
mode_finish_counts:std::collections::HashMap<ModeID,u64>,
|
mode_finish_counts:HashMap<ModeID,u64>,
|
||||||
spawn_counts:std::collections::HashMap<SpawnID,u64>,
|
spawn_counts:HashMap<SpawnID,u64>,
|
||||||
wormhole_out_counts:std::collections::HashMap<WormholeOutID,u64>,
|
wormhole_out_counts:HashMap<WormholeOutID,u64>,
|
||||||
}
|
}
|
||||||
impl Counts{
|
impl Counts{
|
||||||
fn new()->Self{
|
fn new()->Self{
|
||||||
Self{
|
Self{
|
||||||
mode_start_counts:std::collections::HashMap::new(),
|
mode_start_counts:HashMap::new(),
|
||||||
mode_finish_counts:std::collections::HashMap::new(),
|
mode_finish_counts:HashMap::new(),
|
||||||
spawn_counts:std::collections::HashMap::new(),
|
spawn_counts:HashMap::new(),
|
||||||
wormhole_out_counts:std::collections::HashMap::new(),
|
wormhole_out_counts:HashMap::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn check(dom:&rbx_dom_weak::WeakDom)->CheckStatus{
|
|
||||||
// empty report with all checks failed
|
|
||||||
let mut report=CheckReport::default();
|
|
||||||
|
|
||||||
|
pub struct ModelInfo<'a>{
|
||||||
|
model_class:&'a str,
|
||||||
|
model_name:&'a str,
|
||||||
|
map_info:MapInfo<'a>,
|
||||||
|
counts:Counts,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_model_info(dom:&rbx_dom_weak::WeakDom)->Result<ModelInfo,GetRootInstanceError>{
|
||||||
// extract the root instance, otherwise immediately return
|
// extract the root instance, otherwise immediately return
|
||||||
let Ok(model_instance)=get_root_instance(&dom)else{
|
let model_instance=get_root_instance(&dom)?;
|
||||||
return CheckStatus::Failed{report};
|
|
||||||
};
|
|
||||||
|
|
||||||
report.exactly_one_root=Check::Pass;
|
|
||||||
|
|
||||||
if model_instance.class=="Model"{
|
|
||||||
report.root_is_model=Check::Pass;
|
|
||||||
}
|
|
||||||
if model_instance.name==model_instance.name.to_snake_case(){
|
|
||||||
report.model_name_is_snake_case=Check::Pass;
|
|
||||||
}
|
|
||||||
|
|
||||||
// extract model info
|
// extract model info
|
||||||
let map_info=get_mapinfo(&dom,model_instance);
|
let map_info=get_mapinfo(&dom,model_instance);
|
||||||
|
|
||||||
// check DisplayName
|
|
||||||
if let Ok(display_name)=map_info.display_name{
|
|
||||||
if !display_name.is_empty(){
|
|
||||||
report.has_display_name=Check::Pass;
|
|
||||||
if display_name==display_name.to_title_case(){
|
|
||||||
report.display_name_is_title_case=Check::Pass;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// check Creator
|
|
||||||
if let Ok(creator)=map_info.creator{
|
|
||||||
if !creator.is_empty(){
|
|
||||||
report.has_creator=Check::Pass;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// check GameID
|
|
||||||
if map_info.game_id.is_ok(){
|
|
||||||
report.model_name_prefix_is_valid=Check::Pass;
|
|
||||||
}
|
|
||||||
|
|
||||||
// === MODE CHECKS ===
|
|
||||||
// count objects
|
// count objects
|
||||||
let mut counts=Counts::new();
|
let mut counts=Counts::new();
|
||||||
for instance in dom.descendants_of(model_instance.referent()){
|
for instance in dom.descendants_of(model_instance.referent()){
|
||||||
@ -286,38 +147,217 @@ pub fn check(dom:&rbx_dom_weak::WeakDom)->CheckStatus{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MapStart must exist && there must be exactly one of any bonus start zones.
|
Ok(ModelInfo{
|
||||||
if counts.mode_start_counts.get(&ModeID::MAIN)==Some(&1)
|
model_class:model_instance.class.as_str(),
|
||||||
&&counts.mode_start_counts.iter().all(|(_,&c)|c==1){
|
model_name:model_instance.name.as_str(),
|
||||||
report.exactly_one_mapstart=Check::Pass;
|
map_info,
|
||||||
}
|
counts,
|
||||||
// iterate over start zones
|
})
|
||||||
if counts.mode_start_counts.iter().all(|(mode_id,_)|
|
}
|
||||||
// ensure that at least one end zone exists with the same mode id
|
|
||||||
counts.mode_finish_counts.get(mode_id).is_some_and(|&num|0<num)
|
|
||||||
){
|
|
||||||
report.at_least_one_mapfinish=Check::Pass;
|
|
||||||
}
|
|
||||||
// Spawn1 must exist
|
|
||||||
if counts.spawn_counts.get(&SpawnID::FIRST).is_some(){
|
|
||||||
report.spawn1_exists=Check::Pass;
|
|
||||||
}
|
|
||||||
if counts.spawn_counts.iter().all(|(_,&c)|c==1){
|
|
||||||
report.no_duplicate_spawns=Check::Pass;
|
|
||||||
}
|
|
||||||
if counts.wormhole_out_counts.iter().all(|(_,&c)|c==1){
|
|
||||||
report.no_duplicate_wormhole_out=Check::Pass;
|
|
||||||
}
|
|
||||||
|
|
||||||
if report.pass(){
|
|
||||||
CheckStatus::Passed{
|
|
||||||
// TODO: refactor data structure to avoid pain
|
pub enum Check<Context>{
|
||||||
display_name:map_info.display_name.unwrap().to_owned(),
|
Pass,
|
||||||
creator:map_info.creator.unwrap().to_owned(),
|
Fail(Context),
|
||||||
game_id:map_info.game_id.unwrap(),
|
}
|
||||||
|
|
||||||
|
// check if an observed string matches and expected string
|
||||||
|
pub struct StringCheck<'a>(Check<StringCheckContext<'a>>);
|
||||||
|
pub struct StringCheckContext<'a>{
|
||||||
|
observed:&'a str,
|
||||||
|
expected:Cow<'a,str>,
|
||||||
|
}
|
||||||
|
impl<'a> StringCheckContext<'a>{
|
||||||
|
fn check(self)->StringCheck<'a>{
|
||||||
|
if self.observed==self.expected{
|
||||||
|
StringCheck(Check::Pass)
|
||||||
|
}else{
|
||||||
|
StringCheck(Check::Fail(self))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// check if a string is empty
|
||||||
|
pub enum StringEmptyCheck<Context>{
|
||||||
|
Empty,
|
||||||
|
Passed(Context),
|
||||||
|
}
|
||||||
|
|
||||||
|
// check for duplicate objects
|
||||||
|
pub struct DuplicateCheckContext<ID>(HashMap<ID,u64>);
|
||||||
|
pub struct DuplicateCheck<ID>(Check<DuplicateCheckContext<ID>>);
|
||||||
|
impl<ID> DuplicateCheckContext<ID>{
|
||||||
|
fn check(self)->DuplicateCheck<ID>{
|
||||||
|
let Self(mut set)=self;
|
||||||
|
// drop correct entries
|
||||||
|
set.retain(|_,&mut c|c!=1);
|
||||||
|
// if any entries remain, they are incorrect
|
||||||
|
if set.is_empty(){
|
||||||
|
DuplicateCheck(Check::Pass)
|
||||||
|
}else{
|
||||||
|
DuplicateCheck(Check::Fail(Self(set)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// check that there is at least one
|
||||||
|
pub struct AtLeastOneMatchingAndNoExtraCheckContext<ID>{
|
||||||
|
set:HashMap<ID,u64>,
|
||||||
|
missing:HashSet<ID>,
|
||||||
|
}
|
||||||
|
pub struct AtLeastOneMatchingAndNoExtraCheck<ID>(Check<AtLeastOneMatchingAndNoExtraCheckContext<ID>>);
|
||||||
|
impl<ID> AtLeastOneMatchingAndNoExtraCheckContext<ID>{
|
||||||
|
fn new(set:HashMap<ID,u64>)->Self{
|
||||||
|
Self{
|
||||||
|
set,
|
||||||
|
missing:HashSet::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl<ID:Copy+Eq+std::hash::Hash> AtLeastOneMatchingAndNoExtraCheckContext<ID>{
|
||||||
|
fn check<T>(self,reference_set:&HashMap<ID,T>)->AtLeastOneMatchingAndNoExtraCheck<ID>{
|
||||||
|
let Self{mut set,mut missing}=self;
|
||||||
|
// drop correct entries
|
||||||
|
for (id,_) in reference_set{
|
||||||
|
if !set.remove(id).is_some(){
|
||||||
|
// the set did not contain a required item. This is a fail
|
||||||
|
missing.insert(*id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// if any entries remain, they are incorrect
|
||||||
|
if set.is_empty()&&missing.is_empty(){
|
||||||
|
AtLeastOneMatchingAndNoExtraCheck(Check::Pass)
|
||||||
|
}else{
|
||||||
|
AtLeastOneMatchingAndNoExtraCheck(Check::Fail(Self{set,missing}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct MapInfoOwned{
|
||||||
|
display_name:String,
|
||||||
|
creator:String,
|
||||||
|
game_id:GameID,
|
||||||
|
}
|
||||||
|
|
||||||
|
// crazy!
|
||||||
|
pub struct MapCheck<'a>{
|
||||||
|
model_class:StringCheck<'a>,
|
||||||
|
model_name:StringCheck<'a>,
|
||||||
|
display_name:Result<StringEmptyCheck<StringCheck<'a>>,StringValueError>,
|
||||||
|
creator:Result<StringEmptyCheck<()>,StringValueError>,
|
||||||
|
game_id:Result<GameID,ParseGameIDError>,
|
||||||
|
mapstart:Check<()>,
|
||||||
|
mode_start_counts:DuplicateCheck<ModeID>,
|
||||||
|
mode_finish_counts:AtLeastOneMatchingAndNoExtraCheck<ModeID>,
|
||||||
|
spawn1:Check<()>,
|
||||||
|
spawn_counts:DuplicateCheck<SpawnID>,
|
||||||
|
wormhole_out_counts:DuplicateCheck<WormholeOutID>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> ModelInfo<'a>{
|
||||||
|
fn check(self)->MapCheck<'a>{
|
||||||
|
let model_class=StringCheckContext{
|
||||||
|
observed:self.model_class,
|
||||||
|
expected:Cow::Borrowed("Model"),
|
||||||
|
}.check();
|
||||||
|
|
||||||
|
let model_name=StringCheckContext{
|
||||||
|
observed:self.model_name,
|
||||||
|
expected:Cow::Owned(self.model_name.to_snake_case()),
|
||||||
|
}.check();
|
||||||
|
|
||||||
|
// check display name
|
||||||
|
let display_name=self.map_info.display_name.map(|display_name|{
|
||||||
|
if display_name.is_empty(){
|
||||||
|
StringEmptyCheck::Empty
|
||||||
|
}else{
|
||||||
|
let display_name=StringCheckContext{
|
||||||
|
observed:display_name,
|
||||||
|
expected:Cow::Owned(display_name.to_title_case()),
|
||||||
|
}.check();
|
||||||
|
StringEmptyCheck::Passed(display_name)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// check Creator
|
||||||
|
let creator=self.map_info.creator.map(|creator|{
|
||||||
|
if creator.is_empty(){
|
||||||
|
StringEmptyCheck::Empty
|
||||||
|
}else{
|
||||||
|
StringEmptyCheck::Passed(())
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// check GameID
|
||||||
|
let game_id=self.map_info.game_id;
|
||||||
|
|
||||||
|
// MapStart must exist
|
||||||
|
let mapstart=if self.counts.mode_start_counts.get(&ModeID::MAIN).is_some(){
|
||||||
|
Check::Pass
|
||||||
|
}else{
|
||||||
|
Check::Fail(())
|
||||||
|
};
|
||||||
|
|
||||||
|
// Spawn1 must exist
|
||||||
|
let spawn1=if self.counts.spawn_counts.get(&SpawnID::FIRST).is_some(){
|
||||||
|
Check::Pass
|
||||||
|
}else{
|
||||||
|
Check::Fail(())
|
||||||
|
};
|
||||||
|
|
||||||
|
// check that at least one end zone exists for each start zone.
|
||||||
|
let mode_finish_counts=AtLeastOneMatchingAndNoExtraCheckContext::new(self.counts.mode_finish_counts).check(&self.counts.mode_start_counts);
|
||||||
|
|
||||||
|
// there must be exactly one start zone for every mode in the map.
|
||||||
|
let mode_start_counts=DuplicateCheckContext(self.counts.mode_start_counts).check();
|
||||||
|
|
||||||
|
// there must be exactly one of any perticular spawn id in the map.
|
||||||
|
let spawn_counts=DuplicateCheckContext(self.counts.spawn_counts).check();
|
||||||
|
|
||||||
|
// there must be exactly one of any perticular wormhole out id in the map.
|
||||||
|
let wormhole_out_counts=DuplicateCheckContext(self.counts.wormhole_out_counts).check();
|
||||||
|
|
||||||
|
MapCheck{
|
||||||
|
model_class,
|
||||||
|
model_name,
|
||||||
|
display_name,
|
||||||
|
creator,
|
||||||
|
game_id,
|
||||||
|
mapstart,
|
||||||
|
mode_start_counts,
|
||||||
|
mode_finish_counts,
|
||||||
|
spawn1,
|
||||||
|
spawn_counts,
|
||||||
|
wormhole_out_counts,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> MapCheck<'a>{
|
||||||
|
fn pass(self)->Result<MapInfoOwned,Self>{
|
||||||
|
match self{
|
||||||
|
MapCheck{
|
||||||
|
model_class:StringCheck(Check::Pass),
|
||||||
|
model_name:StringCheck(Check::Pass),
|
||||||
|
display_name:Ok(StringEmptyCheck::Passed(StringCheck(Check::Pass))),
|
||||||
|
creator:Ok(StringEmptyCheck::Passed(())),
|
||||||
|
game_id:Ok(game_id),
|
||||||
|
mapstart,
|
||||||
|
mode_start_counts,
|
||||||
|
mode_finish_counts,
|
||||||
|
spawn1,
|
||||||
|
spawn_counts,
|
||||||
|
wormhole_out_counts,
|
||||||
|
}=>{
|
||||||
|
Ok(MapInfoOwned{
|
||||||
|
display_name:display_name.to_owned(),
|
||||||
|
creator:creator.to_owned(),
|
||||||
|
game_id,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
other=>Err(other),
|
||||||
}
|
}
|
||||||
}else{
|
|
||||||
CheckStatus::Failed{report}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user