validator: implement map checks
This commit is contained in:
parent
de0cf37918
commit
95bfb87c6e
315
validation/src/check.rs
Normal file
315
validation/src/check.rs
Normal file
@ -0,0 +1,315 @@
|
||||
use crate::download::download_asset_version;
|
||||
use crate::rbx_util::{class_is_a,get_mapinfo,get_root_instance,read_dom,MapInfo,ReadDomError};
|
||||
|
||||
use heck::{ToSnakeCase,ToTitleCase};
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug)]
|
||||
pub enum Error{
|
||||
ModelInfoDownload(rbx_asset::cloud::GetError),
|
||||
CreatorTypeMustBeUser,
|
||||
ParseUserID(core::num::ParseIntError),
|
||||
ParseModelVersion(core::num::ParseIntError),
|
||||
Download(crate::download::Error),
|
||||
ModelFileDecode(ReadDomError),
|
||||
}
|
||||
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{}
|
||||
|
||||
#[allow(nonstandard_style)]
|
||||
pub struct CheckRequest{
|
||||
pub ModelID:u64,
|
||||
}
|
||||
|
||||
impl From<crate::nats_types::CheckMapfixRequest> for CheckRequest{
|
||||
fn from(value:crate::nats_types::CheckMapfixRequest)->Self{
|
||||
Self{
|
||||
ModelID:value.ModelID,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl From<crate::nats_types::CheckSubmissionRequest> for CheckRequest{
|
||||
fn from(value:crate::nats_types::CheckSubmissionRequest)->Self{
|
||||
Self{
|
||||
ModelID:value.ModelID,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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:bool,
|
||||
// the root must be of class Model
|
||||
root_is_model:bool,
|
||||
// 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:bool,
|
||||
// your model's name must match this regex: ^[a-z0-9_]
|
||||
model_name_is_snake_case:bool,
|
||||
// map must have a StringValue named Creator and DisplayName. additionally, they must both have a value
|
||||
has_display_name:bool,
|
||||
has_creator:bool,
|
||||
// the display name must be capitalized
|
||||
display_name_is_title_case:bool,
|
||||
// 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:bool,
|
||||
// At least one MapFinish
|
||||
at_least_one_mapfinish:bool,
|
||||
// Spawn0 or Spawn1 must exist
|
||||
spawn1_exists:bool,
|
||||
// No duplicate Spawn#
|
||||
no_duplicate_spawns:bool,
|
||||
// No duplicate WormholeOut# (duplicate WormholeIn# ok)
|
||||
no_duplicate_wormhole_out:bool,
|
||||
}
|
||||
impl CheckReport{
|
||||
pub fn pass(&self)->bool{
|
||||
return 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
|
||||
}
|
||||
}
|
||||
impl std::fmt::Display for CheckReport{
|
||||
fn fmt(&self,f:&mut std::fmt::Formatter<'_>)->std::fmt::Result{
|
||||
write!(f,
|
||||
"exactly_one_root={}\
|
||||
root_is_model={}\
|
||||
model_name_prefix_is_valid={}\
|
||||
model_name_is_snake_case={}\
|
||||
has_display_name={}\
|
||||
has_creator={}\
|
||||
display_name_is_title_case={}\
|
||||
exactly_one_mapstart={}\
|
||||
at_least_one_mapfinish={}\
|
||||
spawn1_exists={}\
|
||||
no_duplicate_spawns={}\
|
||||
no_duplicate_wormhole_out={}",
|
||||
self.exactly_one_root,
|
||||
self.root_is_model,
|
||||
self.model_name_prefix_is_valid,
|
||||
self.model_name_is_snake_case,
|
||||
self.has_display_name,
|
||||
self.has_creator,
|
||||
self.display_name_is_title_case,
|
||||
self.exactly_one_mapstart,
|
||||
self.at_least_one_mapfinish,
|
||||
self.spawn1_exists,
|
||||
self.no_duplicate_spawns,
|
||||
self.no_duplicate_wormhole_out,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
enum Zone{
|
||||
Start(ModeID),
|
||||
Finish(ModeID),
|
||||
}
|
||||
|
||||
#[derive(Debug,Hash,Eq,PartialEq)]
|
||||
struct ModeID(u64);
|
||||
impl ModeID{
|
||||
const MAIN:Self=Self(0);
|
||||
const BONUS:Self=Self(1);
|
||||
}
|
||||
#[allow(dead_code)]
|
||||
pub enum ZoneParseError{
|
||||
NoCaptures,
|
||||
ParseInt(core::num::ParseIntError)
|
||||
}
|
||||
impl std::str::FromStr for Zone{
|
||||
type Err=ZoneParseError;
|
||||
fn from_str(s:&str)->Result<Self,Self::Err>{
|
||||
match s{
|
||||
"MapStart"=>Ok(Self::Start(ModeID::MAIN)),
|
||||
"MapFinish"=>Ok(Self::Finish(ModeID::MAIN)),
|
||||
"BonusStart"=>Ok(Self::Start(ModeID::BONUS)),
|
||||
"BonusFinish"=>Ok(Self::Start(ModeID::BONUS)),
|
||||
other=>{
|
||||
let bonus_start_pattern=lazy_regex::lazy_regex!(r"^Bonus(\d+)Start$|^BonusStart(\d+)$");
|
||||
if let Some(captures)=bonus_start_pattern.captures(other){
|
||||
return Ok(Self::Start(ModeID(captures[1].parse().map_err(ZoneParseError::ParseInt)?)));
|
||||
}
|
||||
let bonus_finish_pattern=lazy_regex::lazy_regex!(r"^Bonus(\d+)Finish$|^BonusFinish(\d+)$");
|
||||
if let Some(captures)=bonus_finish_pattern.captures(other){
|
||||
return Ok(Self::Finish(ModeID(captures[1].parse().map_err(ZoneParseError::ParseInt)?)));
|
||||
}
|
||||
Err(ZoneParseError::NoCaptures)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[derive(Debug,Hash,Eq,PartialEq)]
|
||||
struct SpawnID(u64);
|
||||
impl SpawnID{
|
||||
const FIRST:Self=Self(1);
|
||||
}
|
||||
#[derive(Debug,Hash,Eq,PartialEq)]
|
||||
struct WormholeOutID(u64);
|
||||
|
||||
struct Counts{
|
||||
mode_start_counts:std::collections::HashMap<ModeID,u64>,
|
||||
mode_finish_counts:std::collections::HashMap<ModeID,u64>,
|
||||
spawn_counts:std::collections::HashMap<SpawnID,u64>,
|
||||
wormhole_out_counts:std::collections::HashMap<WormholeOutID,u64>,
|
||||
}
|
||||
impl Counts{
|
||||
fn new()->Self{
|
||||
Self{
|
||||
mode_start_counts:std::collections::HashMap::new(),
|
||||
mode_finish_counts:std::collections::HashMap::new(),
|
||||
spawn_counts:std::collections::HashMap::new(),
|
||||
wormhole_out_counts:std::collections::HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn check(dom:&rbx_dom_weak::WeakDom)->CheckReport{
|
||||
// empty report with all checks failed
|
||||
let mut report=CheckReport::default();
|
||||
|
||||
// extract the root instance, otherwise immediately return
|
||||
let Ok(model_instance)=get_root_instance(&dom)else{
|
||||
return report;
|
||||
};
|
||||
|
||||
report.exactly_one_root=true;
|
||||
|
||||
if model_instance.class=="Model"{
|
||||
report.root_is_model=true;
|
||||
}
|
||||
if model_instance.name==model_instance.name.to_snake_case(){
|
||||
report.model_name_is_snake_case=true;
|
||||
}
|
||||
|
||||
// extract model info
|
||||
let MapInfo{display_name,creator,game_id}=get_mapinfo(&dom,model_instance);
|
||||
|
||||
// check DisplayName
|
||||
if let Ok(display_name)=display_name{
|
||||
if !display_name.is_empty(){
|
||||
report.has_display_name=true;
|
||||
if display_name==display_name.to_title_case(){
|
||||
report.display_name_is_title_case=true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// check Creator
|
||||
if let Ok(creator)=creator{
|
||||
if !creator.is_empty(){
|
||||
report.has_creator=true;
|
||||
}
|
||||
}
|
||||
|
||||
// check GameID
|
||||
if game_id.is_ok(){
|
||||
report.model_name_prefix_is_valid=true;
|
||||
}
|
||||
|
||||
// === MODE CHECKS ===
|
||||
// count objects
|
||||
let mut counts=Counts::new();
|
||||
for instance in dom.descendants_of(model_instance.referent()){
|
||||
if class_is_a(instance.class.as_str(),"BasePart"){
|
||||
// Zones
|
||||
match instance.name.parse(){
|
||||
Ok(Zone::Start(mode_id))=>*counts.mode_start_counts.entry(mode_id).or_insert(0)+=1,
|
||||
Ok(Zone::Finish(mode_id))=>*counts.mode_finish_counts.entry(mode_id).or_insert(0)+=1,
|
||||
_=>(),
|
||||
}
|
||||
// Spawns
|
||||
let spawn_pattern=lazy_regex::lazy_regex!(r"^Spawn(\d+)$");
|
||||
if let Some(captures)=spawn_pattern.captures(instance.name.as_str()){
|
||||
if let Ok(spawn_id)=captures[1].parse(){
|
||||
*counts.spawn_counts.entry(SpawnID(spawn_id)).or_insert(0)+=1;
|
||||
}
|
||||
}
|
||||
// WormholeOuts
|
||||
let wormhole_out_pattern=lazy_regex::lazy_regex!(r"^WormholeOut(\d+)$");
|
||||
if let Some(captures)=wormhole_out_pattern.captures(instance.name.as_str()){
|
||||
if let Ok(wormhole_out_id)=captures[1].parse(){
|
||||
*counts.wormhole_out_counts.entry(WormholeOutID(wormhole_out_id)).or_insert(0)+=1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MapStart must exist && there must be exactly one of any bonus start zones.
|
||||
if counts.mode_start_counts.get(&ModeID::MAIN)==Some(&1)
|
||||
&&counts.mode_start_counts.iter().all(|(_,&c)|c==1){
|
||||
report.exactly_one_mapstart=true;
|
||||
}
|
||||
// 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=true;
|
||||
}
|
||||
// Spawn1 must exist
|
||||
if counts.spawn_counts.get(&SpawnID::FIRST).is_some(){
|
||||
report.spawn1_exists=true;
|
||||
}
|
||||
if counts.spawn_counts.iter().all(|(_,&c)|c==1){
|
||||
report.no_duplicate_spawns=true;
|
||||
}
|
||||
if counts.wormhole_out_counts.iter().all(|(_,&c)|c==1){
|
||||
report.no_duplicate_wormhole_out=true;
|
||||
}
|
||||
|
||||
report
|
||||
}
|
||||
|
||||
pub struct CheckReportAndVersion{
|
||||
pub report:CheckReport,
|
||||
pub version:u64,
|
||||
}
|
||||
|
||||
impl crate::message_handler::MessageHandler{
|
||||
pub async fn check_inner(&self,check_info:CheckRequest)->Result<CheckReportAndVersion,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_string)=info.creationContext.creator else{
|
||||
return Err(Error::CreatorTypeMustBeUser);
|
||||
};
|
||||
|
||||
// parse model version string
|
||||
let version=info.revisionId.parse().map_err(Error::ParseModelVersion)?;
|
||||
|
||||
let model_data=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=read_dom(std::io::Cursor::new(model_data)).map_err(Error::ModelFileDecode)?;
|
||||
|
||||
let report=check(&dom);
|
||||
|
||||
Ok(CheckReportAndVersion{report,version})
|
||||
}
|
||||
}
|
57
validation/src/check_mapfix.rs
Normal file
57
validation/src/check_mapfix.rs
Normal file
@ -0,0 +1,57 @@
|
||||
use crate::check::CheckReportAndVersion;
|
||||
use crate::nats_types::CheckMapfixRequest;
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug)]
|
||||
pub enum Error{
|
||||
Check(crate::check::Error),
|
||||
ApiActionMapfixCheck(submissions_api::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{}
|
||||
|
||||
impl crate::message_handler::MessageHandler{
|
||||
pub async fn check_mapfix(&self,check_info:CheckMapfixRequest)->Result<(),Error>{
|
||||
let mapfix_id=check_info.MapfixID;
|
||||
let check_result=self.check_inner(check_info.into()).await;
|
||||
|
||||
// update the mapfix depending on the result
|
||||
match check_result{
|
||||
Ok(CheckReportAndVersion{report,version})=>{
|
||||
if report.pass(){
|
||||
// update the mapfix model status to submitted
|
||||
self.api.action_mapfix_submitted(
|
||||
submissions_api::types::ActionMapfixSubmittedRequest{
|
||||
MapfixID:mapfix_id,
|
||||
ModelVersion:version,
|
||||
}
|
||||
).await.map_err(Error::ApiActionMapfixCheck)?;
|
||||
}else{
|
||||
// update the mapfix model status to request changes
|
||||
self.api.action_mapfix_request_changes(
|
||||
submissions_api::types::ActionMapfixRequestChangesRequest{
|
||||
MapfixID:mapfix_id,
|
||||
StatusMessage:report.to_string(),
|
||||
}
|
||||
).await.map_err(Error::ApiActionMapfixCheck)?;
|
||||
}
|
||||
},
|
||||
Err(e)=>{
|
||||
// TODO: report the error
|
||||
// update the mapfix model status to request changes
|
||||
self.api.action_mapfix_request_changes(
|
||||
submissions_api::types::ActionMapfixRequestChangesRequest{
|
||||
MapfixID:mapfix_id,
|
||||
StatusMessage:e.to_string(),
|
||||
}
|
||||
).await.map_err(Error::ApiActionMapfixCheck)?;
|
||||
},
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
57
validation/src/check_submission.rs
Normal file
57
validation/src/check_submission.rs
Normal file
@ -0,0 +1,57 @@
|
||||
use crate::check::CheckReportAndVersion;
|
||||
use crate::nats_types::CheckSubmissionRequest;
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug)]
|
||||
pub enum Error{
|
||||
Check(crate::check::Error),
|
||||
ApiActionSubmissionCheck(submissions_api::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{}
|
||||
|
||||
impl crate::message_handler::MessageHandler{
|
||||
pub async fn check_submission(&self,check_info:CheckSubmissionRequest)->Result<(),Error>{
|
||||
let submission_id=check_info.SubmissionID;
|
||||
let check_result=self.check_inner(check_info.into()).await;
|
||||
|
||||
// update the submission depending on the result
|
||||
match check_result{
|
||||
Ok(CheckReportAndVersion{report,version})=>{
|
||||
if report.pass(){
|
||||
// update the submission model status to submitted
|
||||
self.api.action_submission_submitted(
|
||||
submissions_api::types::ActionSubmissionSubmittedRequest{
|
||||
SubmissionID:submission_id,
|
||||
ModelVersion:version,
|
||||
}
|
||||
).await.map_err(Error::ApiActionSubmissionCheck)?;
|
||||
}else{
|
||||
// update the submission model status to request changes
|
||||
self.api.action_submission_request_changes(
|
||||
submissions_api::types::ActionSubmissionRequestChangesRequest{
|
||||
SubmissionID:submission_id,
|
||||
StatusMessage:report.to_string(),
|
||||
}
|
||||
).await.map_err(Error::ApiActionSubmissionCheck)?;
|
||||
}
|
||||
},
|
||||
Err(e)=>{
|
||||
// TODO: report the error
|
||||
// update the submission model status to request changes
|
||||
self.api.action_submission_request_changes(
|
||||
submissions_api::types::ActionSubmissionRequestChangesRequest{
|
||||
SubmissionID:submission_id,
|
||||
StatusMessage:e.to_string(),
|
||||
}
|
||||
).await.map_err(Error::ApiActionSubmissionCheck)?;
|
||||
},
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
@ -5,6 +5,9 @@ mod message_handler;
|
||||
mod nats_types;
|
||||
mod types;
|
||||
mod download;
|
||||
mod check;
|
||||
mod check_mapfix;
|
||||
mod check_submission;
|
||||
mod create;
|
||||
mod create_mapfix;
|
||||
mod create_submission;
|
||||
|
@ -7,6 +7,8 @@ pub enum HandleMessageError{
|
||||
UnknownSubject(String),
|
||||
CreateMapfix(submissions_api::Error),
|
||||
CreateSubmission(submissions_api::Error),
|
||||
CheckMapfix(crate::check_mapfix::Error),
|
||||
CheckSubmission(crate::check_submission::Error),
|
||||
UploadMapfix(crate::upload_mapfix::Error),
|
||||
UploadSubmission(crate::upload_submission::Error),
|
||||
ValidateMapfix(crate::validate_mapfix::Error),
|
||||
@ -52,6 +54,8 @@ impl MessageHandler{
|
||||
match message.subject.as_str(){
|
||||
"maptest.mapfixes.create"=>self.create_mapfix(from_slice(&message.payload)?).await.map_err(HandleMessageError::CreateMapfix),
|
||||
"maptest.submissions.create"=>self.create_submission(from_slice(&message.payload)?).await.map_err(HandleMessageError::CreateSubmission),
|
||||
"maptest.mapfixes.check"=>self.check_mapfix(from_slice(&message.payload)?).await.map_err(HandleMessageError::CheckMapfix),
|
||||
"maptest.submissions.check"=>self.check_submission(from_slice(&message.payload)?).await.map_err(HandleMessageError::CheckSubmission),
|
||||
"maptest.mapfixes.upload"=>self.upload_mapfix(from_slice(&message.payload)?).await.map_err(HandleMessageError::UploadMapfix),
|
||||
"maptest.submissions.upload"=>self.upload_submission(from_slice(&message.payload)?).await.map_err(HandleMessageError::UploadSubmission),
|
||||
"maptest.mapfixes.validate"=>self.validate_mapfix(from_slice(&message.payload)?).await.map_err(HandleMessageError::ValidateMapfix),
|
||||
|
@ -20,6 +20,20 @@ pub struct CreateMapfixRequest{
|
||||
pub TargetAssetID:u64,
|
||||
}
|
||||
|
||||
#[allow(nonstandard_style)]
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct CheckSubmissionRequest{
|
||||
pub SubmissionID:i64,
|
||||
pub ModelID:u64,
|
||||
}
|
||||
|
||||
#[allow(nonstandard_style)]
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct CheckMapfixRequest{
|
||||
pub MapfixID:i64,
|
||||
pub ModelID:u64,
|
||||
}
|
||||
|
||||
#[allow(nonstandard_style)]
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct ValidateSubmissionRequest{
|
||||
|
Loading…
x
Reference in New Issue
Block a user