diff --git a/openapi.yaml b/openapi.yaml index abb3424..69ea9bb 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -1641,12 +1641,25 @@ components: SubmissionTriggerCreate: required: - AssetID + - DisplayName + - Creator + - GameID type: object properties: AssetID: type: integer format: int64 minimum: 0 + DisplayName: + type: string + maxLength: 128 + Creator: + type: string + maxLength: 128 + GameID: + type: integer + format: int32 + minimum: 0 ReleaseInfo: required: - SubmissionID diff --git a/pkg/api/oas_json_gen.go b/pkg/api/oas_json_gen.go index 11defac..5f84a66 100644 --- a/pkg/api/oas_json_gen.go +++ b/pkg/api/oas_json_gen.go @@ -3128,10 +3128,25 @@ func (s *SubmissionTriggerCreate) encodeFields(e *jx.Encoder) { e.FieldStart("AssetID") e.Int64(s.AssetID) } + { + e.FieldStart("DisplayName") + e.Str(s.DisplayName) + } + { + e.FieldStart("Creator") + e.Str(s.Creator) + } + { + e.FieldStart("GameID") + e.Int32(s.GameID) + } } -var jsonFieldsNameOfSubmissionTriggerCreate = [1]string{ +var jsonFieldsNameOfSubmissionTriggerCreate = [4]string{ 0: "AssetID", + 1: "DisplayName", + 2: "Creator", + 3: "GameID", } // Decode decodes SubmissionTriggerCreate from json. @@ -3155,6 +3170,42 @@ func (s *SubmissionTriggerCreate) Decode(d *jx.Decoder) error { }(); err != nil { return errors.Wrap(err, "decode field \"AssetID\"") } + case "DisplayName": + requiredBitSet[0] |= 1 << 1 + if err := func() error { + v, err := d.Str() + s.DisplayName = string(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"DisplayName\"") + } + case "Creator": + requiredBitSet[0] |= 1 << 2 + if err := func() error { + v, err := d.Str() + s.Creator = string(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"Creator\"") + } + case "GameID": + requiredBitSet[0] |= 1 << 3 + if err := func() error { + v, err := d.Int32() + s.GameID = int32(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"GameID\"") + } default: return d.Skip() } @@ -3165,7 +3216,7 @@ func (s *SubmissionTriggerCreate) Decode(d *jx.Decoder) error { // Validate required fields. var failures []validate.FieldError for i, mask := range [1]uint8{ - 0b00000001, + 0b00001111, } { if result := (requiredBitSet[i] & mask) ^ mask; result != 0 { // Mask only required fields and check equality to mask using XOR. diff --git a/pkg/api/oas_schemas_gen.go b/pkg/api/oas_schemas_gen.go index 54d2557..31dc5ea 100644 --- a/pkg/api/oas_schemas_gen.go +++ b/pkg/api/oas_schemas_gen.go @@ -1318,7 +1318,10 @@ func (s *Submission) SetStatusID(val int32) { // Ref: #/components/schemas/SubmissionTriggerCreate type SubmissionTriggerCreate struct { - AssetID int64 `json:"AssetID"` + AssetID int64 `json:"AssetID"` + DisplayName string `json:"DisplayName"` + Creator string `json:"Creator"` + GameID int32 `json:"GameID"` } // GetAssetID returns the value of AssetID. @@ -1326,11 +1329,41 @@ func (s *SubmissionTriggerCreate) GetAssetID() int64 { return s.AssetID } +// GetDisplayName returns the value of DisplayName. +func (s *SubmissionTriggerCreate) GetDisplayName() string { + return s.DisplayName +} + +// GetCreator returns the value of Creator. +func (s *SubmissionTriggerCreate) GetCreator() string { + return s.Creator +} + +// GetGameID returns the value of GameID. +func (s *SubmissionTriggerCreate) GetGameID() int32 { + return s.GameID +} + // SetAssetID sets the value of AssetID. func (s *SubmissionTriggerCreate) SetAssetID(val int64) { s.AssetID = val } +// SetDisplayName sets the value of DisplayName. +func (s *SubmissionTriggerCreate) SetDisplayName(val string) { + s.DisplayName = val +} + +// SetCreator sets the value of Creator. +func (s *SubmissionTriggerCreate) SetCreator(val string) { + s.Creator = val +} + +// SetGameID sets the value of GameID. +func (s *SubmissionTriggerCreate) SetGameID(val int32) { + s.GameID = val +} + // Ref: #/components/schemas/Submissions type Submissions struct { Total int64 `json:"Total"` diff --git a/pkg/api/oas_validators_gen.go b/pkg/api/oas_validators_gen.go index 456bf4d..4f60361 100644 --- a/pkg/api/oas_validators_gen.go +++ b/pkg/api/oas_validators_gen.go @@ -1802,6 +1802,64 @@ func (s *SubmissionTriggerCreate) Validate() error { Error: err, }) } + if err := func() error { + if err := (validate.String{ + MinLength: 0, + MinLengthSet: false, + MaxLength: 128, + MaxLengthSet: true, + Email: false, + Hostname: false, + Regex: nil, + }).Validate(string(s.DisplayName)); err != nil { + return errors.Wrap(err, "string") + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "DisplayName", + Error: err, + }) + } + if err := func() error { + if err := (validate.String{ + MinLength: 0, + MinLengthSet: false, + MaxLength: 128, + MaxLengthSet: true, + Email: false, + Hostname: false, + Regex: nil, + }).Validate(string(s.Creator)); err != nil { + return errors.Wrap(err, "string") + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "Creator", + Error: err, + }) + } + if err := func() error { + if err := (validate.Int{ + MinSet: true, + Min: 0, + MaxSet: false, + Max: 0, + MinExclusive: false, + MaxExclusive: false, + MultipleOfSet: false, + MultipleOf: 0, + }).Validate(int64(s.GameID)); err != nil { + return errors.Wrap(err, "int") + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "GameID", + Error: err, + }) + } if len(failures) > 0 { return &validate.Error{Fields: failures} } diff --git a/pkg/model/nats.go b/pkg/model/nats.go index 91c0453..0d422af 100644 --- a/pkg/model/nats.go +++ b/pkg/model/nats.go @@ -9,6 +9,9 @@ type CreateSubmissionRequest struct { // operation_id is passed back in the response message OperationID int32 ModelID uint64 + DisplayName string + Creator string + GameID uint32 } type CreateMapfixRequest struct { diff --git a/pkg/service/submissions.go b/pkg/service/submissions.go index 5af693f..c1ce44c 100644 --- a/pkg/service/submissions.go +++ b/pkg/service/submissions.go @@ -101,8 +101,11 @@ func (svc *Service) CreateSubmission(ctx context.Context, request *api.Submissio } create_request := model.CreateSubmissionRequest{ - OperationID: operation.ID, - ModelID: ModelID, + OperationID: operation.ID, + ModelID: ModelID, + DisplayName: request.DisplayName, + Creator: request.Creator, + GameID: uint32(request.GameID), } j, err := json.Marshal(create_request) diff --git a/validation/api/src/internal.rs b/validation/api/src/internal.rs index d689981..fb418f6 100644 --- a/validation/api/src/internal.rs +++ b/validation/api/src/internal.rs @@ -167,6 +167,9 @@ impl Context{ ); action!("submissions",action_submission_submitted,config,ActionSubmissionSubmittedRequest,"status/validator-submitted",config.SubmissionID, ("ModelVersion",config.ModelVersion.to_string().as_str()) + ("DisplayName",config.DisplayName.as_str()) + ("Creator",config.Creator.as_str()) + ("GameID",config.GameID.to_string().as_str()) ); action!("submissions",action_submission_validated,config,SubmissionID,"status/validator-validated",config.0,); action!("submissions",update_submission_validated_model,config,UpdateSubmissionModelRequest,"validated-model",config.SubmissionID, @@ -196,6 +199,9 @@ impl Context{ ); action!("mapfixes",action_mapfix_submitted,config,ActionMapfixSubmittedRequest,"status/validator-submitted",config.MapfixID, ("ModelVersion",config.ModelVersion.to_string().as_str()) + ("DisplayName",config.DisplayName.as_str()) + ("Creator",config.Creator.as_str()) + ("GameID",config.GameID.to_string().as_str()) ); action!("mapfixes",action_mapfix_validated,config,MapfixID,"status/validator-validated",config.0,); action!("mapfixes",update_mapfix_validated_model,config,UpdateMapfixModelRequest,"validated-model",config.MapfixID, diff --git a/validation/api/src/types.rs b/validation/api/src/types.rs index 2a329fc..eb4e87e 100644 --- a/validation/api/src/types.rs +++ b/validation/api/src/types.rs @@ -228,6 +228,9 @@ pub struct UpdateSubmissionModelRequest{ pub struct ActionSubmissionSubmittedRequest{ pub SubmissionID:i64, pub ModelVersion:u64, + pub DisplayName:String, + pub Creator:String, + pub GameID:u32, } #[allow(nonstandard_style)] @@ -267,6 +270,9 @@ pub struct UpdateMapfixModelRequest{ pub struct ActionMapfixSubmittedRequest{ pub MapfixID:i64, pub ModelVersion:u64, + pub DisplayName:String, + pub Creator:String, + pub GameID:u32, } #[allow(nonstandard_style)] diff --git a/validation/src/check.rs b/validation/src/check.rs index 0aee2b2..0b428fa 100644 --- a/validation/src/check.rs +++ b/validation/src/check.rs @@ -1,5 +1,6 @@ +use std::collections::{HashSet,HashMap}; use crate::download::download_asset_version; -use crate::rbx_util::{class_is_a,get_mapinfo,get_root_instance,read_dom,MapInfo,ReadDomError}; +use crate::rbx_util::{class_is_a,get_mapinfo,get_root_instance,read_dom,ReadDomError,GameID,ParseGameIDError,MapInfo,GetRootInstanceError,StringValueError}; use heck::{ToSnakeCase,ToTitleCase}; @@ -10,6 +11,7 @@ pub enum Error{ CreatorTypeMustBeUser, Download(crate::download::Error), ModelFileDecode(ReadDomError), + GetRootInstance(GetRootInstanceError), } impl std::fmt::Display for Error{ fn fmt(&self,f:&mut std::fmt::Formatter<'_>)->std::fmt::Result{ @@ -38,117 +40,30 @@ 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"), - } - } -} - -#[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{ Start(ModeID), Finish(ModeID), } -#[derive(Debug,Hash,Eq,PartialEq)] +#[derive(Clone,Copy,Debug,Hash,Eq,PartialEq)] struct ModeID(u64); impl ModeID{ const MAIN:Self=Self(0); const BONUS:Self=Self(1); + fn write_start_zone(&self,f:&mut std::fmt::Formatter<'_>)->std::fmt::Result{ + match self{ + ModeID(0)=>write!(f,"MapStart"), + ModeID(1)=>write!(f,"BonusStart"), + ModeID(other)=>write!(f,"Bonus{other}Start"), + } + } + fn write_finish_zone(&self,f:&mut std::fmt::Formatter<'_>)->std::fmt::Result{ + match self{ + ModeID(0)=>write!(f,"MapFinish"), + ModeID(1)=>write!(f,"BonusFinish"), + ModeID(other)=>write!(f,"Bonus{other}Finish"), + } + } } #[allow(dead_code)] pub enum ZoneParseError{ @@ -188,66 +103,37 @@ impl SpawnID{ 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>, + mode_start_counts:HashMap<ModeID,u64>, + mode_finish_counts:HashMap<ModeID,u64>, + spawn_counts:HashMap<SpawnID,u64>, + wormhole_out_counts: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(), + mode_start_counts:HashMap::new(), + mode_finish_counts:HashMap::new(), + spawn_counts:HashMap::new(), + wormhole_out_counts:HashMap::new(), } } } -pub fn check(dom:&rbx_dom_weak::WeakDom)->CheckReport{ - // 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 - let Ok(model_instance)=get_root_instance(&dom)else{ - return 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; - } + let model_instance=get_root_instance(&dom)?; // extract model info - let MapInfo{display_name,creator,game_id}=get_mapinfo(&dom,model_instance); + let map_info=get_mapinfo(&dom,model_instance); - // check DisplayName - if let Ok(display_name)=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)=creator{ - if !creator.is_empty(){ - report.has_creator=Check::Pass; - } - } - - // check GameID - if game_id.is_ok(){ - report.model_name_prefix_is_valid=Check::Pass; - } - - // === MODE CHECKS === // count objects let mut counts=Counts::new(); for instance in dom.descendants_of(model_instance.referent()){ @@ -275,34 +161,318 @@ pub fn check(dom:&rbx_dom_weak::WeakDom)->CheckReport{ } } - // 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=Check::Pass; - } - // 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; - } + Ok(ModelInfo{ + model_class:model_instance.class.as_str(), + model_name:model_instance.name.as_str(), + map_info, + counts, + }) +} - report +// check if an observed string matches and expected string +pub struct StringCheck<'a,T,Str>(Result<T,StringCheckContext<'a,Str>>); +pub struct StringCheckContext<'a,Str>{ + observed:&'a str, + expected:Str, +} +impl<'a,Str> StringCheckContext<'a,Str> + where + &'a str:PartialEq<Str>, +{ + fn check<T>(self,value:T)->StringCheck<'a,T,Str>{ + if self.observed==self.expected{ + StringCheck(Ok(value)) + }else{ + StringCheck(Err(self)) + } + } +} +impl<'a,Str:std::fmt::Display> std::fmt::Display for StringCheckContext<'a,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; +pub struct StringEmptyCheck<Context>(Result<Context,StringEmpty>); +impl std::fmt::Display for StringEmpty{ + fn fmt(&self,f:&mut std::fmt::Formatter<'_>)->std::fmt::Result{ + write!(f,"Empty string") + } +} + +// check for duplicate objects +pub struct DuplicateCheckContext<ID>(HashMap<ID,u64>); +pub struct DuplicateCheck<ID>(Result<(),DuplicateCheckContext<ID>>); +impl<ID> DuplicateCheckContext<ID>{ + fn check(self)->DuplicateCheck<ID>{ + let Self(mut set)=self; + // remove correct entries + set.retain(|_,&mut c|c!=1); + // if any entries remain, they are incorrect + if set.is_empty(){ + DuplicateCheck(Ok(())) + }else{ + DuplicateCheck(Err(Self(set))) + } + } +} + +// check that there is at least one +pub struct AtLeastOneMatchingAndNoExtraCheckContext<ID>{ + extra:HashMap<ID,u64>, + missing:HashSet<ID>, +} +pub struct AtLeastOneMatchingAndNoExtraCheck<ID>(Result<(),AtLeastOneMatchingAndNoExtraCheckContext<ID>>); +impl<ID> AtLeastOneMatchingAndNoExtraCheckContext<ID>{ + fn new(initial_set:HashMap<ID,u64>)->Self{ + Self{ + extra:initial_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 extra,mut missing}=self; + // remove correct entries + for (id,_) in reference_set{ + if extra.remove(id).is_none(){ + // the set did not contain a required item. This is a fail + missing.insert(*id); + } + } + // if any entries remain, they are incorrect + if extra.is_empty()&&missing.is_empty(){ + AtLeastOneMatchingAndNoExtraCheck(Ok(())) + }else{ + AtLeastOneMatchingAndNoExtraCheck(Err(Self{extra,missing})) + } + } +} + +pub struct MapInfoOwned{ + pub display_name:String, + pub creator:String, + pub game_id:GameID, +} + +// crazy! +pub struct MapCheck<'a>{ + model_class:StringCheck<'a,(),&'a str>, + model_name:StringCheck<'a,(),String>, + display_name:Result<StringEmptyCheck<StringCheck<'a,&'a str,String>>,StringValueError>, + creator:Result<StringEmptyCheck<&'a str>,StringValueError>, + game_id:Result<GameID,ParseGameIDError>, + mapstart:Result<(),()>, + mode_start_counts:DuplicateCheck<ModeID>, + mode_finish_counts:AtLeastOneMatchingAndNoExtraCheck<ModeID>, + spawn1:Result<(),()>, + 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:"Model", + }.check(()); + + let model_name=StringCheckContext{ + observed:self.model_name, + expected: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(Err(StringEmpty)) + }else{ + StringEmptyCheck(Ok(StringCheckContext{ + observed:display_name, + expected:display_name.to_title_case(), + }.check(display_name))) + } + }); + + // check Creator + let creator=self.map_info.creator.map(|creator|{ + if creator.is_empty(){ + StringEmptyCheck(Err(StringEmpty)) + }else{ + StringEmptyCheck(Ok(creator)) + } + }); + + // 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(){ + Ok(()) + }else{ + Err(()) + }; + + // Spawn1 must exist + let spawn1=if self.counts.spawn_counts.get(&SpawnID::FIRST).is_some(){ + Ok(()) + }else{ + Err(()) + }; + + // 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(Ok(())), + model_name:StringCheck(Ok(())), + display_name:Ok(StringEmptyCheck(Ok(StringCheck(Ok(display_name))))), + creator:Ok(StringEmptyCheck(Ok(creator))), + game_id:Ok(game_id), + mapstart:Ok(()), + mode_start_counts:DuplicateCheck(Ok(())), + mode_finish_counts:AtLeastOneMatchingAndNoExtraCheck(Ok(())), + spawn1:Ok(()), + spawn_counts:DuplicateCheck(Ok(())), + wormhole_out_counts:DuplicateCheck(Ok(())), + }=>{ + Ok(MapInfoOwned{ + display_name:display_name.to_owned(), + creator:creator.to_owned(), + game_id, + }) + }, + other=>Err(other), + } + } +} + +fn comma_separated<T,F>(f:&mut std::fmt::Formatter<'_>,mut it:impl Iterator<Item=T>,custom_write:F)->std::fmt::Result +where + F:Fn(&mut std::fmt::Formatter<'_>,T)->std::fmt::Result, +{ + if let Some(t)=it.next(){ + custom_write(f,t)?; + for t in it{ + write!(f,", ")?; + custom_write(f,t)?; + } + } + Ok(()) +} + +impl<'a> std::fmt::Display for MapCheck<'a>{ + fn fmt(&self,f:&mut std::fmt::Formatter<'_>)->std::fmt::Result{ + if let StringCheck(Err(context))=&self.model_class{ + writeln!(f,"Invalid Model Class: {context}")?; + } + if let StringCheck(Err(context))=&self.model_name{ + writeln!(f,"Invalid Model Name: {context}")?; + } + match &self.display_name{ + Ok(StringEmptyCheck(Ok(StringCheck(Ok(_)))))=>(), + Ok(StringEmptyCheck(Ok(StringCheck(Err(context)))))=>writeln!(f,"Invalid DisplayName: {context}")?, + Ok(StringEmptyCheck(Err(context)))=>writeln!(f,"Invalid DisplayName: {context}")?, + Err(StringValueError::ObjectNotFound)=>writeln!(f,"Missing DisplayName StringValue")?, + Err(StringValueError::ValueNotSet)=>writeln!(f,"DisplayName Value not set")?, + Err(StringValueError::NonStringValue)=>writeln!(f,"DisplayName Value is not a String")?, + } + match &self.creator{ + Ok(StringEmptyCheck(Ok(_)))=>(), + Ok(StringEmptyCheck(Err(context)))=>writeln!(f,"Invalid Creator: {context}")?, + Err(StringValueError::ObjectNotFound)=>writeln!(f,"Missing Creator StringValue")?, + Err(StringValueError::ValueNotSet)=>writeln!(f,"Creator Value not set")?, + Err(StringValueError::NonStringValue)=>writeln!(f,"Creator Value is not a String")?, + } + if let Err(_parse_game_id_error)=&self.game_id{ + writeln!(f,"Model name must be prefixed with bhop_ surf_ or flytrials_")?; + } + if let Err(())=&self.mapstart{ + writeln!(f,"Model has no MapStart")?; + } + if let DuplicateCheck(Err(DuplicateCheckContext(context)))=&self.mode_start_counts{ + write!(f,"Duplicate start zones: ")?; + comma_separated(f,context.iter(),|f,(mode_id,count)|{ + mode_id.write_start_zone(f)?; + write!(f,"({count} duplicates)")?; + Ok(()) + })?; + writeln!(f,"")?; + } + if let AtLeastOneMatchingAndNoExtraCheck(Err(context))=&self.mode_finish_counts{ + // perhaps there are extra end zones (context.extra) + if !context.extra.is_empty(){ + write!(f,"Extra finish zones with no matching start zone: ")?; + comma_separated(f,context.extra.iter(),|f,(mode_id,_count)| + mode_id.write_finish_zone(f))?; + writeln!(f,"")?; + } + // perhaps there are missing end zones (context.missing) + if !context.missing.is_empty(){ + write!(f,"Missing finish zones: ")?; + comma_separated(f,context.missing.iter(),|f,mode_id| + mode_id.write_finish_zone(f) + )?; + writeln!(f,"")?; + } + } + if let Err(())=&self.spawn1{ + writeln!(f,"Model has no Spawn1")?; + } + if let DuplicateCheck(Err(DuplicateCheckContext(context)))=&self.spawn_counts{ + write!(f,"Duplicate spawn zones: ")?; + comma_separated(f,context.iter(),|f,(SpawnID(spawn_id),count)|{ + write!(f,"Spawn{spawn_id}({count} duplicates)")?; + Ok(()) + })?; + writeln!(f,"")?; + } + if let DuplicateCheck(Err(DuplicateCheckContext(context)))=&self.wormhole_out_counts{ + write!(f,"Duplicate wormhole out: ")?; + comma_separated(f,context.iter(),|f,(WormholeOutID(wormhole_out_id),count)|{ + write!(f,"WormholeOut{wormhole_out_id}({count} duplicates)")?; + Ok(()) + })?; + writeln!(f,"")?; + } + Ok(()) + } } pub struct CheckReportAndVersion{ - pub report:CheckReport, + pub status:Result<MapInfoOwned,String>, pub version:u64, } @@ -329,8 +499,15 @@ impl crate::message_handler::MessageHandler{ // decode dom (slow!) let dom=maybe_gzip.read_with(read_dom,read_dom).map_err(Error::ModelFileDecode)?; - let report=check(&dom); + // extract information from the model + let model_info=get_model_info(&dom).map_err(Error::GetRootInstance)?; - Ok(CheckReportAndVersion{report,version}) + // 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=map_check.pass().map_err(|e|e.to_string()); + + Ok(CheckReportAndVersion{status,version}) } } diff --git a/validation/src/check_mapfix.rs b/validation/src/check_mapfix.rs index a562ed0..2187fe3 100644 --- a/validation/src/check_mapfix.rs +++ b/validation/src/check_mapfix.rs @@ -21,23 +21,27 @@ impl crate::message_handler::MessageHandler{ // update the mapfix depending on the result match check_result{ - Ok(CheckReportAndVersion{report,version})=>{ - if report.pass(){ + Ok(CheckReportAndVersion{status,version})=>{ + match status{ // update the mapfix model status to submitted + Ok(map_info)=> self.api.action_mapfix_submitted( submissions_api::types::ActionMapfixSubmittedRequest{ MapfixID:mapfix_id, ModelVersion:version, + DisplayName:map_info.display_name, + Creator:map_info.creator, + GameID:map_info.game_id as u32, } - ).await.map_err(Error::ApiActionMapfixCheck)?; - }else{ + ).await.map_err(Error::ApiActionMapfixCheck)?, // update the mapfix model status to request changes + Err(report)=> self.api.action_mapfix_request_changes( submissions_api::types::ActionMapfixRequestChangesRequest{ MapfixID:mapfix_id, - ErrorMessage:report.to_string(), + ErrorMessage:report, } - ).await.map_err(Error::ApiActionMapfixCheck)?; + ).await.map_err(Error::ApiActionMapfixCheck)?, } }, Err(e)=>{ diff --git a/validation/src/check_submission.rs b/validation/src/check_submission.rs index 05d0f18..d0bd80c 100644 --- a/validation/src/check_submission.rs +++ b/validation/src/check_submission.rs @@ -21,23 +21,27 @@ impl crate::message_handler::MessageHandler{ // update the submission depending on the result match check_result{ - Ok(CheckReportAndVersion{report,version})=>{ - if report.pass(){ + Ok(CheckReportAndVersion{status,version})=>{ + match status{ // update the submission model status to submitted + Ok(map_info)=> self.api.action_submission_submitted( submissions_api::types::ActionSubmissionSubmittedRequest{ SubmissionID:submission_id, ModelVersion:version, + DisplayName:map_info.display_name, + Creator:map_info.creator, + GameID:map_info.game_id as u32, } - ).await.map_err(Error::ApiActionSubmissionCheck)?; - }else{ + ).await.map_err(Error::ApiActionSubmissionCheck)?, // update the submission model status to request changes + Err(report)=> self.api.action_submission_request_changes( submissions_api::types::ActionSubmissionRequestChangesRequest{ SubmissionID:submission_id, - ErrorMessage:report.to_string(), + ErrorMessage:report, } - ).await.map_err(Error::ApiActionSubmissionCheck)?; + ).await.map_err(Error::ApiActionSubmissionCheck)?, } }, Err(e)=>{ diff --git a/validation/src/create.rs b/validation/src/create.rs index c6ea948..f29d4ca 100644 --- a/validation/src/create.rs +++ b/validation/src/create.rs @@ -1,5 +1,5 @@ use crate::download::download_asset_version; -use crate::rbx_util::{get_root_instance,get_mapinfo,read_dom,MapInfo,ReadDomError,GetRootInstanceError,ParseGameIDError}; +use crate::rbx_util::{get_root_instance,get_mapinfo,read_dom,MapInfo,ReadDomError,GetRootInstanceError,GameID}; #[allow(dead_code)] #[derive(Debug)] @@ -9,7 +9,6 @@ pub enum Error{ Download(crate::download::Error), ModelFileDecode(ReadDomError), GetRootInstance(GetRootInstanceError), - ParseGameID(ParseGameIDError), } impl std::fmt::Display for Error{ fn fmt(&self,f:&mut std::fmt::Formatter<'_>)->std::fmt::Result{ @@ -24,10 +23,10 @@ pub struct CreateRequest{ } #[allow(nonstandard_style)] pub struct CreateResult{ - pub AssetOwner:i64, - pub DisplayName:String, - pub Creator:String, - pub GameID:i32, + pub AssetOwner:u64, + pub DisplayName:Option<String>, + pub Creator:Option<String>, + pub GameID:Option<GameID>, pub AssetVersion:u64, } impl crate::message_handler::MessageHandler{ @@ -63,13 +62,11 @@ impl crate::message_handler::MessageHandler{ game_id, }=get_mapinfo(&dom,model_instance); - let game_id=game_id.map_err(Error::ParseGameID)?; - Ok(CreateResult{ - AssetOwner:user_id as i64, - DisplayName:display_name.unwrap_or_default().to_owned(), - Creator:creator.unwrap_or_default().to_owned(), - GameID:game_id as i32, + AssetOwner:user_id, + DisplayName:display_name.ok().map(ToOwned::to_owned), + Creator:creator.ok().map(ToOwned::to_owned), + GameID:game_id.ok(), AssetVersion:asset_version, }) } diff --git a/validation/src/create_mapfix.rs b/validation/src/create_mapfix.rs index 010df43..9980c53 100644 --- a/validation/src/create_mapfix.rs +++ b/validation/src/create_mapfix.rs @@ -24,10 +24,11 @@ impl crate::message_handler::MessageHandler{ // call create on api self.api.create_mapfix(submissions_api::types::CreateMapfixRequest{ OperationID:create_info.OperationID, - AssetOwner:create_request.AssetOwner, - DisplayName:create_request.DisplayName.as_str(), - Creator:create_request.Creator.as_str(), - GameID:create_request.GameID, + AssetOwner:create_request.AssetOwner as i64, + DisplayName:create_request.DisplayName.as_deref().unwrap_or_default(), + Creator:create_request.Creator.as_deref().unwrap_or_default(), + // not great TODO: make this great + GameID:create_request.GameID.unwrap_or(crate::rbx_util::GameID::Bhop) as i32, AssetID:create_info.ModelID, AssetVersion:create_request.AssetVersion, TargetAssetID:create_info.TargetAssetID, diff --git a/validation/src/create_submission.rs b/validation/src/create_submission.rs index 370e80d..06485d4 100644 --- a/validation/src/create_submission.rs +++ b/validation/src/create_submission.rs @@ -1,5 +1,6 @@ use crate::nats_types::CreateSubmissionRequest; use crate::create::CreateRequest; +use crate::rbx_util::GameID; #[allow(dead_code)] #[derive(Debug)] @@ -19,13 +20,29 @@ impl crate::message_handler::MessageHandler{ let create_request=self.create_inner(CreateRequest{ ModelID:create_info.ModelID, }).await.map_err(Error::Create)?; + + // grab values from submission form, otherwise try to fill blanks from map data + let display_name=if create_info.DisplayName.is_empty(){ + create_request.DisplayName.as_deref().unwrap_or_default() + }else{ + create_info.DisplayName.as_str() + }; + + let creator=if create_info.Creator.is_empty(){ + create_request.Creator.as_deref().unwrap_or_default() + }else{ + create_info.Creator.as_str() + }; + + let game_id=create_info.GameID.try_into().ok().or(create_request.GameID).unwrap_or(GameID::Bhop); + // call create on api self.api.create_submission(submissions_api::types::CreateSubmissionRequest{ OperationID:create_info.OperationID, - AssetOwner:create_request.AssetOwner, - DisplayName:create_request.DisplayName.as_str(), - Creator:create_request.Creator.as_str(), - GameID:create_request.GameID, + AssetOwner:create_request.AssetOwner as i64, + DisplayName:display_name, + Creator:creator, + GameID:game_id as i32, AssetID:create_info.ModelID, AssetVersion:create_request.AssetVersion, }).await.map_err(Error::ApiActionSubmissionCreate)?; diff --git a/validation/src/nats_types.rs b/validation/src/nats_types.rs index bca6646..357e69b 100644 --- a/validation/src/nats_types.rs +++ b/validation/src/nats_types.rs @@ -10,6 +10,9 @@ pub struct CreateSubmissionRequest{ // operation_id is passed back in the response message pub OperationID:i32, pub ModelID:u64, + pub DisplayName:String, + pub Creator:String, + pub GameID:u32, } #[allow(nonstandard_style)] diff --git a/validation/src/rbx_util.rs b/validation/src/rbx_util.rs index 92f8fa4..db50048 100644 --- a/validation/src/rbx_util.rs +++ b/validation/src/rbx_util.rs @@ -70,6 +70,18 @@ impl std::str::FromStr for GameID{ return Err(ParseGameIDError); } } +pub struct GameIDError; +impl TryFrom<u32> for GameID{ + type Error=GameIDError; + fn try_from(value:u32)->Result<Self,Self::Error>{ + match value{ + 1=>Ok(GameID::Bhop), + 2=>Ok(GameID::Surf), + 5=>Ok(GameID::FlyTrials), + _=>Err(GameIDError) + } + } +} pub struct MapInfo<'a>{ pub display_name:Result<&'a str,StringValueError>, @@ -77,6 +89,7 @@ pub struct MapInfo<'a>{ pub game_id:Result<GameID,ParseGameIDError>, } +#[derive(Debug)] pub enum StringValueError{ ObjectNotFound, ValueNotSet, diff --git a/web/src/app/submit/_game.tsx b/web/src/app/submit/_game.tsx new file mode 100644 index 0000000..e754601 --- /dev/null +++ b/web/src/app/submit/_game.tsx @@ -0,0 +1,65 @@ +import { FormControl, Select, InputLabel, MenuItem } from "@mui/material"; +import { styled } from '@mui/material/styles'; +import InputBase from '@mui/material/InputBase'; +import React from "react"; +import { SelectChangeEvent } from "@mui/material"; + +// TODO: Properly style everything instead of pasting 🤚 + +type GameSelectionProps = { + game: number; + setGame: React.Dispatch<React.SetStateAction<number>>; +}; + +const BootstrapInput = styled(InputBase)(({ theme }) => ({ + 'label + &': { + marginTop: theme.spacing(3), + }, + '& .MuiInputBase-input': { + backgroundColor: '#0000', + color: '#FFF', + border: '1px solid rgba(175, 175, 175, 0.66)', + fontSize: 16, + padding: '10px 26px 10px 12px', + transition: theme.transitions.create(['border-color', 'box-shadow']), + fontFamily: [ + '-apple-system', + 'BlinkMacSystemFont', + '"Segoe UI"', + 'Roboto', + '"Helvetica Neue"', + 'Arial', + 'sans-serif', + '"Apple Color Emoji"', + '"Segoe UI Emoji"', + '"Segoe UI Symbol"', + ].join(','), + '&:focus': { + borderRadius: 4, + borderColor: '#80bdff', + boxShadow: '0 0 0 0.2rem rgba(0,123,255,.25)', + }, + }, + })); + +export default function GameSelection({ game, setGame }: GameSelectionProps) { + const handleChange = (event: SelectChangeEvent) => { + setGame(Number(event.target.value)); // TODO: Change later!! there's 100% a proper way of doing this + }; + + return ( + <FormControl> + <InputLabel sx={{ color: "#646464" }}>Game</InputLabel> + <Select + value={String(game)} + label="Game" + onChange={handleChange} + input={<BootstrapInput />} + > + <MenuItem value={1}>Bhop</MenuItem> + <MenuItem value={2}>Surf</MenuItem> + <MenuItem value={3}>Fly Trials</MenuItem> + </Select> + </FormControl> + ); +} \ No newline at end of file diff --git a/web/src/app/submit/page.tsx b/web/src/app/submit/page.tsx index a0b09ee..b0a71e9 100644 --- a/web/src/app/submit/page.tsx +++ b/web/src/app/submit/page.tsx @@ -2,19 +2,25 @@ import { Button, TextField } from "@mui/material" +import GameSelection from "./_game"; import SendIcon from '@mui/icons-material/Send'; import Webpage from "@/app/_components/webpage" +import React, { useState } from "react"; import "./(styles)/page.scss" interface SubmissionPayload { AssetID: number; + DisplayName: string; + Creator: string; + GameID: number; } interface IdResponse { OperationID: number; } export default function SubmissionInfoPage() { + const [game, setGame] = useState(1); const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => { event.preventDefault(); @@ -23,6 +29,9 @@ export default function SubmissionInfoPage() { const formData = new FormData(form); const payload: SubmissionPayload = { + DisplayName: (formData.get("display-name") as string) ?? "unknown", // TEMPORARY! TODO: Change + Creator: (formData.get("creator") as string) ?? "unknown", // TEMPORARY! TODO: Change + GameID: game, AssetID: Number((formData.get("asset-id") as string) ?? "0"), }; @@ -64,7 +73,10 @@ export default function SubmissionInfoPage() { <span className="spacer form-spacer"></span> </header> <form onSubmit={handleSubmit}> - <TextField className="form-field" id="asset-id" name="asset-id" label="Asset ID" variant="outlined"/> + <TextField className="form-field" id="asset-id" name="asset-id" label="Asset ID (required)" variant="outlined"/> + <TextField className="form-field" id="display-name" name="display-name" label="Display Name" variant="outlined"/> + <TextField className="form-field" id="creator" name="creator" label="Creator" variant="outlined"/> + <GameSelection game={game} setGame={setGame} /> <span className="spacer form-spacer"></span> <Button type="submit" variant="contained" startIcon={<SendIcon/>} sx={{ width: "400px",