forked from StrafesNET/maps-service
849 lines
28 KiB
Rust
849 lines
28 KiB
Rust
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,ReadDomError,GameID,ParseGameIDError,MapInfo,GetRootInstanceError,StringValueError};
|
|
|
|
use heck::{ToSnakeCase,ToTitleCase};
|
|
|
|
#[allow(dead_code)]
|
|
#[derive(Debug)]
|
|
pub enum Error{
|
|
ModelInfoDownload(rbx_asset::cloud::GetError),
|
|
CreatorTypeMustBeUser,
|
|
Download(crate::download::Error),
|
|
ModelFileDecode(ReadDomError),
|
|
GetRootInstance(GetRootInstanceError),
|
|
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{}
|
|
|
|
#[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(Clone,Copy,Debug,Hash,Eq,PartialEq)]
|
|
struct ModeID(u64);
|
|
impl ModeID{
|
|
const MAIN:Self=Self(0);
|
|
const BONUS:Self=Self(1);
|
|
}
|
|
enum Zone{
|
|
Start,
|
|
Finish,
|
|
Anticheat,
|
|
}
|
|
struct ModeElement{
|
|
zone:Zone,
|
|
mode_id:ModeID,
|
|
}
|
|
#[allow(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::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 bonus_start_pattern=lazy_regex::lazy_regex!(r"^(?:Force)?(Teleport|SpawnAt|Trigger)(\d+)$");
|
|
if let Some(captures)=bonus_start_pattern.captures(s){
|
|
return Ok(StageElement{
|
|
behaviour:StageElementBehaviour::Teleport,
|
|
stage_id:StageID(captures[1].parse().map_err(IDParseError::ParseInt)?),
|
|
});
|
|
}
|
|
// Spawn
|
|
let bonus_finish_pattern=lazy_regex::lazy_regex!(r"^Spawn(\d+)$");
|
|
if let Some(captures)=bonus_finish_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 bonus_start_pattern=lazy_regex::lazy_regex!(r"^WormholeIn(\d+)$");
|
|
if let Some(captures)=bonus_start_pattern.captures(s){
|
|
return Ok(Self{
|
|
behaviour:WormholeBehaviour::In,
|
|
wormhole_id:WormholeID(captures[1].parse().map_err(IDParseError::ParseInt)?),
|
|
});
|
|
}
|
|
let bonus_finish_pattern=lazy_regex::lazy_regex!(r"^WormholeOut(\d+)$");
|
|
if let Some(captures)=bonus_finish_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}"),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Count various map elements
|
|
#[derive(Default)]
|
|
struct Counts<'a>{
|
|
mode_start_counts:HashMap<ModeID,Vec<&'a str>>,
|
|
mode_finish_counts:HashMap<ModeID,Vec<&'a str>>,
|
|
mode_anticheat_counts:HashMap<ModeID,Vec<&'a str>>,
|
|
teleport_counts:HashMap<StageID,Vec<&'a str>>,
|
|
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>,
|
|
}
|
|
|
|
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();
|
|
for instance in dom.descendants_of(model_instance.referent()){
|
|
if class_is_a(instance.class.as_str(),"BasePart"){
|
|
// Zones
|
|
match instance.name.parse(){
|
|
Ok(ModeElement{zone:Zone::Start,mode_id})=>counts.mode_start_counts.entry(mode_id).or_default().push(instance.name.as_str()),
|
|
Ok(ModeElement{zone:Zone::Finish,mode_id})=>counts.mode_finish_counts.entry(mode_id).or_default().push(instance.name.as_str()),
|
|
Ok(ModeElement{zone:Zone::Anticheat,mode_id})=>counts.mode_anticheat_counts.entry(mode_id).or_default().push(instance.name.as_str()),
|
|
Err(_)=>(),
|
|
}
|
|
// Spawns & Teleports
|
|
match instance.name.parse(){
|
|
Ok(StageElement{behaviour:StageElementBehaviour::Teleport,stage_id})=>counts.teleport_counts.entry(stage_id).or_default().push(instance.name.as_str()),
|
|
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(_)=>(),
|
|
}
|
|
}
|
|
}
|
|
|
|
ModelInfo{
|
|
model_class:model_instance.class.as_str(),
|
|
model_name:model_instance.name.as_str(),
|
|
map_info,
|
|
counts,
|
|
}
|
|
}
|
|
|
|
// check if an observed string matches an 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>,
|
|
{
|
|
/// Compute the StringCheck, passing through the provided value on success.
|
|
fn check<T>(self,value:T)->StringCheck<'a,T,Str>{
|
|
if self.observed==self.expected{
|
|
StringCheck(Ok(value))
|
|
}else{
|
|
StringCheck(Err(self))
|
|
}
|
|
}
|
|
}
|
|
impl<Str:std::fmt::Display> std::fmt::Display for StringCheckContext<'_,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,
|
|
}
|
|
|
|
// Named dummy types for readability
|
|
struct Exists;
|
|
struct Absent;
|
|
|
|
/// The result of every map check.
|
|
struct MapCheck<'a>{
|
|
// === METADATA CHECKS ===
|
|
// The root must be of class Model
|
|
model_class:StringCheck<'a,(),&'static str>,
|
|
// Model's name must be in snake case
|
|
model_name:StringCheck<'a,(),String>,
|
|
// Map must have a StringValue named DisplayName.
|
|
// Value must not be empty, must be in title case.
|
|
display_name:Result<Result<StringCheck<'a,&'a str,String>,StringEmpty>,StringValueError>,
|
|
// Map must have a StringValue named Creator.
|
|
// Value must not be empty.
|
|
creator:Result<Result<&'a str,StringEmpty>,StringValueError>,
|
|
// 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 str>>,
|
|
// At least one finish zone for each start zone, and no finishes with no start
|
|
mode_finish_counts:SetDifferenceCheck<SetDifferenceCheckContextAtLeastOne<ModeID,Vec<&'a str>>>,
|
|
// Check for dangling MapAnticheat zones (no associated MapStart)
|
|
mode_anticheat_counts:SetDifferenceCheck<SetDifferenceCheckContextAllowNone<ModeID,Vec<&'a str>>>,
|
|
// Spawn1 must exist
|
|
spawn1:Result<Exists,Absent>,
|
|
// Check for dangling Teleport# (no associated Spawn#)
|
|
teleport_counts:SetDifferenceCheck<SetDifferenceCheckContextAllowNone<StageID,Vec<&'a str>>>,
|
|
// 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>,
|
|
}
|
|
|
|
impl<'a> ModelInfo<'a>{
|
|
fn check(self)->MapCheck<'a>{
|
|
// Check class is exactly "Model"
|
|
let model_class=StringCheckContext{
|
|
observed:self.model_class,
|
|
expected:"Model",
|
|
}.check(());
|
|
|
|
// Check model name is snake case
|
|
let model_name=StringCheckContext{
|
|
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=self.map_info.display_name.map(|display_name|{
|
|
check_empty(display_name).map(|display_name|StringCheckContext{
|
|
observed:display_name,
|
|
expected:display_name.to_title_case(),
|
|
}.check(display_name))
|
|
});
|
|
|
|
// Check Creator is not empty
|
|
let creator=self.map_info.creator.map(check_empty);
|
|
|
|
// 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 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);
|
|
|
|
MapCheck{
|
|
model_class,
|
|
model_name,
|
|
display_name,
|
|
creator,
|
|
game_id,
|
|
mapstart,
|
|
mode_start_counts,
|
|
mode_finish_counts,
|
|
mode_anticheat_counts,
|
|
spawn1,
|
|
teleport_counts,
|
|
spawn_counts,
|
|
wormhole_in_counts,
|
|
wormhole_out_counts,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl MapCheck<'_>{
|
|
fn result(self)->Result<MapInfoOwned,Result<MapCheckList,serde_json::Error>>{
|
|
match self{
|
|
MapCheck{
|
|
model_class:StringCheck(Ok(())),
|
|
model_name:StringCheck(Ok(())),
|
|
display_name:Ok(Ok(StringCheck(Ok(display_name)))),
|
|
creator:Ok(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(())),
|
|
spawn1:Ok(Exists),
|
|
teleport_counts:SetDifferenceCheck(Ok(())),
|
|
spawn_counts:DuplicateCheck(Ok(())),
|
|
wormhole_in_counts:SetDifferenceCheck(Ok(())),
|
|
wormhole_out_counts:DuplicateCheck(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)
|
|
}
|
|
}
|
|
|
|
#[derive(serde::Serialize)]
|
|
struct CheckSummary{
|
|
name:&'static str,
|
|
summary:String,
|
|
passed:bool,
|
|
details:serde_json::Value,
|
|
}
|
|
impl CheckSummary{
|
|
const fn passed(name:&'static str)->Self{
|
|
Self{
|
|
name,
|
|
summary:String::new(),
|
|
passed:true,
|
|
details:serde_json::Value::Null,
|
|
}
|
|
}
|
|
}
|
|
macro_rules! summary{
|
|
($name:literal,$summary:expr,$details:expr)=>{
|
|
CheckSummary{
|
|
name:$name,
|
|
summary:$summary,
|
|
passed:false,
|
|
details:serde_json::to_value($details)?,
|
|
}
|
|
};
|
|
}
|
|
macro_rules! summary_format{
|
|
($name:literal,$fmt:literal,$details:expr)=>{
|
|
CheckSummary{
|
|
name:$name,
|
|
summary:format!($fmt),
|
|
passed:false,
|
|
details:serde_json::to_value($details)?,
|
|
}
|
|
};
|
|
}
|
|
|
|
// 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{
|
|
StringCheck(Ok(()))=>CheckSummary::passed("ModelClass"),
|
|
StringCheck(Err(context))=>summary_format!("ModelClass","Invalid model class: {context}",()),
|
|
};
|
|
let model_name=match &self.model_name{
|
|
StringCheck(Ok(()))=>CheckSummary::passed("ModelName"),
|
|
StringCheck(Err(context))=>summary_format!("ModelName","Model name must have snake_case: {context}",()),
|
|
};
|
|
let display_name=match &self.display_name{
|
|
Ok(Ok(StringCheck(Ok(_))))=>CheckSummary::passed("DisplayName"),
|
|
Ok(Ok(StringCheck(Err(context))))=>summary_format!("DisplayName","DisplayName must have Title Case: {context}",()),
|
|
Ok(Err(context))=>summary_format!("DisplayName","Invalid DisplayName: {context}",()),
|
|
Err(StringValueError::ObjectNotFound)=>summary!("DisplayName","Missing DisplayName StringValue".to_owned(),()),
|
|
Err(StringValueError::ValueNotSet)=>summary!("DisplayName","DisplayName Value not set".to_owned(),()),
|
|
Err(StringValueError::NonStringValue)=>summary!("DisplayName","DisplayName Value is not a String".to_owned(),()),
|
|
};
|
|
let creator=match &self.creator{
|
|
Ok(Ok(_))=>CheckSummary::passed("Creator"),
|
|
Ok(Err(context))=>summary_format!("Creator","Invalid Creator: {context}",()),
|
|
Err(StringValueError::ObjectNotFound)=>summary!("Creator","Missing Creator StringValue".to_owned(),()),
|
|
Err(StringValueError::ValueNotSet)=>summary!("Creator","Creator Value not set".to_owned(),()),
|
|
Err(StringValueError::NonStringValue)=>summary!("Creator","Creator Value is not a String".to_owned(),()),
|
|
};
|
|
let game_id=match &self.game_id{
|
|
Ok(_)=>CheckSummary::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)=>CheckSummary::passed("MapStart"),
|
|
Err(Absent)=>summary_format!("MapStart","Model has no MapStart",()),
|
|
};
|
|
let duplicate_start=match &self.mode_start_counts{
|
|
DuplicateCheck(Ok(()))=>CheckSummary::passed("DuplicateStart"),
|
|
DuplicateCheck(Err(DuplicateCheckContext(context)))=>{
|
|
let context=Separated::new(", ",||context.iter().map(|(&mode_id,names)|
|
|
Duplicates::new(ModeElement{zone:Zone::Start,mode_id},names.len())
|
|
));
|
|
summary_format!("DuplicateStart","Duplicate start zones: {context}",())
|
|
}
|
|
};
|
|
let (extra_finish,missing_finish)=match &self.mode_finish_counts{
|
|
SetDifferenceCheck(Ok(()))=>(CheckSummary::passed("ExtraFinish"),CheckSummary::passed("MissingFinish")),
|
|
SetDifferenceCheck(Err(context))=>(
|
|
if context.extra.is_empty(){
|
|
CheckSummary::passed("ExtraFinish")
|
|
}else{
|
|
let plural=if context.extra.len()==1{"zone"}else{"zones"};
|
|
let context=Separated::new(", ",||context.extra.iter().map(|(&mode_id,_names)|
|
|
ModeElement{zone:Zone::Finish,mode_id}
|
|
));
|
|
summary_format!("ExtraFinish","No matching start zone for finish {plural}: {context}",())
|
|
},
|
|
if context.missing.is_empty(){
|
|
CheckSummary::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(()))=>CheckSummary::passed("DanglingAnticheat"),
|
|
SetDifferenceCheck(Err(context))=>{
|
|
if context.extra.is_empty(){
|
|
CheckSummary::passed("DanglingAnticheat")
|
|
}else{
|
|
let plural=if context.extra.len()==1{"zone"}else{"zones"};
|
|
let context=Separated::new(", ",||context.extra.iter().map(|(&mode_id,_names)|
|
|
ModeElement{zone:Zone::Anticheat,mode_id}
|
|
));
|
|
summary_format!("DanglingAnticheat","No matching start zone for anticheat {plural}: {context}",())
|
|
}
|
|
}
|
|
};
|
|
let spawn1=match &self.spawn1{
|
|
Ok(Exists)=>CheckSummary::passed("Spawn1"),
|
|
Err(Absent)=>summary_format!("Spawn1","Model has no Spawn1",()),
|
|
};
|
|
let dangling_teleport=match &self.teleport_counts{
|
|
SetDifferenceCheck(Ok(()))=>CheckSummary::passed("DanglingTeleport"),
|
|
SetDifferenceCheck(Err(context))=>{
|
|
let unique_names:HashSet<_>=context.extra.values().flat_map(|names|names.iter().copied()).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(()))=>CheckSummary::passed("DuplicateSpawn"),
|
|
DuplicateCheck(Err(DuplicateCheckContext(context)))=>{
|
|
let context=Separated::new(", ",||context.iter().map(|(&stage_id,&names)|
|
|
Duplicates::new(StageElement{behaviour:StageElementBehaviour::Spawn,stage_id},names as usize)
|
|
));
|
|
summary_format!("DuplicateSpawn","Duplicate Spawn: {context}",())
|
|
}
|
|
};
|
|
let (extra_wormhole_in,missing_wormhole_in)=match &self.wormhole_in_counts{
|
|
SetDifferenceCheck(Ok(()))=>(CheckSummary::passed("ExtraWormholeIn"),CheckSummary::passed("MissingWormholeIn")),
|
|
SetDifferenceCheck(Err(context))=>(
|
|
if context.extra.is_empty(){
|
|
CheckSummary::passed("ExtraWormholeIn")
|
|
}else{
|
|
let context=Separated::new(", ",||context.extra.iter().map(|(&wormhole_id,_names)|
|
|
WormholeElement{behaviour:WormholeBehaviour::In,wormhole_id}
|
|
));
|
|
summary_format!("ExtraWormholeIn","WormholeIn with no matching WormholeOut: {context}",())
|
|
},
|
|
if context.missing.is_empty(){
|
|
CheckSummary::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(()))=>CheckSummary::passed("DuplicateWormholeOut"),
|
|
DuplicateCheck(Err(DuplicateCheckContext(context)))=>{
|
|
let context=Separated::new(", ",||context.iter().map(|(&wormhole_id,&names)|
|
|
Duplicates::new(WormholeElement{behaviour:WormholeBehaviour::Out,wormhole_id},names as usize)
|
|
));
|
|
summary_format!("DuplicateWormholeOut","Duplicate WormholeOut: {context}",())
|
|
}
|
|
};
|
|
Ok(MapCheckList{checks:Box::new([
|
|
model_class,
|
|
model_name,
|
|
display_name,
|
|
creator,
|
|
game_id,
|
|
mapstart,
|
|
duplicate_start,
|
|
extra_finish,
|
|
missing_finish,
|
|
dangling_anticheat,
|
|
spawn1,
|
|
dangling_teleport,
|
|
duplicate_spawns,
|
|
extra_wormhole_in,
|
|
missing_wormhole_in,
|
|
duplicate_wormhole_out,
|
|
])})
|
|
}
|
|
}
|
|
|
|
#[derive(serde::Serialize)]
|
|
struct MapCheckList{
|
|
checks:Box<[CheckSummary;16]>,
|
|
}
|
|
impl MapCheckList{
|
|
fn summary(&self)->String{
|
|
Separated::new("; ",||self.checks.iter().filter_map(|check|
|
|
(!check.passed).then_some(check.summary.as_str())
|
|
)).to_string()
|
|
}
|
|
}
|
|
|
|
pub struct Summary{
|
|
pub summary:String,
|
|
pub json:serde_json::Value,
|
|
}
|
|
|
|
pub struct CheckReportAndVersion{
|
|
pub status:Result<MapInfoOwned,Summary>,
|
|
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)=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)?;
|
|
|
|
// 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(summary))=>Err(Summary{
|
|
summary:summary.summary(),
|
|
json:serde_json::to_value(&summary).map_err(Error::ToJsonValue)?,
|
|
}),
|
|
Err(Err(e))=>return Err(Error::ToJsonValue(e)),
|
|
};
|
|
|
|
Ok(CheckReportAndVersion{status,version})
|
|
}
|
|
}
|