2024-04-16 02:15:35 +00:00
const axios = require ( "axios" ) . default ;
2024-04-29 22:51:32 +00:00
const { RobloxFile } = require ( "rbxm-parser" ) ;
2024-08-22 03:48:30 +00:00
const noblox = require ( "noblox.js" ) ;
2024-04-16 02:15:35 +00:00
// https://create.roblox.com/docs/reference/engine/enums/AssetType
const AssetType = {
Image : 1 ,
TShirt : 2 ,
Audio : 3 ,
Mesh : 4 ,
Lua : 5 ,
Hat : 8 ,
Place : 9 ,
Model : 10 ,
Shirt : 11 ,
Pants : 12 ,
Decal : 13 ,
Head : 17 ,
Face : 18 ,
Gear : 19 ,
Badge : 21 ,
Animation : 24 ,
GamePass : 34 ,
Plugin : 38 ,
MeshPart : 40
} ;
async function getAssetInfo ( assetId ) {
2024-08-22 03:48:30 +00:00
const jar = noblox . options . jar ;
const xcsrf = await noblox . getGeneralToken ( jar ) ;
const res = await noblox . http ( ` https://apis.roblox.com/user/cloud/v2/creator-store-products/PRODUCT_NAMESPACE_CREATOR_MARKETPLACE_ASSET-PRODUCT_TYPE_MODEL- ${ assetId } ` , {
method : "GET" ,
resolveWithFullResponse : true ,
jar : jar ,
headers : {
"X-CSRF-TOKEN" : xcsrf ,
"Content-Type" : "application/json"
}
2024-04-16 02:15:35 +00:00
} ) ;
2024-08-22 03:48:30 +00:00
if ( res . statusCode < 200 || res . statusCode >= 300 ) {
2024-04-16 02:15:35 +00:00
return {
2024-08-27 02:38:17 +00:00
status : res . statusCode ,
2024-08-22 03:48:30 +00:00
isModel : false
2024-04-24 06:28:07 +00:00
} ;
2024-04-16 02:15:35 +00:00
}
2024-08-22 03:48:30 +00:00
const assetInfo = JSON . parse ( res . body ) ;
const quantity = assetInfo . purchasePrice . quantity ;
const isFree = quantity . significand === 0 && quantity . exponent === 0 ;
const forSale = assetInfo . published && assetInfo . purchasable ;
2024-08-21 21:55:26 +00:00
2024-04-16 02:15:35 +00:00
return {
2024-08-22 03:48:30 +00:00
status : res . statusCode ,
isModel : true ,
id : assetInfo . modelAssetId ,
creatorId : + assetInfo . userSeller ,
2024-08-21 21:55:26 +00:00
isFree : isFree ,
forSale : forSale
2024-04-24 06:28:07 +00:00
} ;
2024-04-16 02:15:35 +00:00
}
2024-08-22 03:48:30 +00:00
async function buyModel ( modelId ) {
const reqJson = {
expectedPrice : { currencyCode : "USD" , quantity : { significand : 0 , exponent : 0 } } ,
productKey : {
productNamespace : "PRODUCT_NAMESPACE_CREATOR_MARKETPLACE_ASSET" ,
productTargetId : ` ${ modelId } ` ,
productType : "PRODUCT_TYPE_MODEL"
}
} ;
const jar = noblox . options . jar ;
const xcsrf = await noblox . getGeneralToken ( jar ) ;
const res = await noblox . http ( "https://apis.roblox.com/marketplace-fiat-service/v1/product/purchase" , {
method : "POST" ,
resolveWithFullResponse : true ,
jar : jar ,
headers : {
"X-CSRF-TOKEN" : xcsrf ,
"Content-Type" : "application/json"
} ,
body : JSON . stringify ( reqJson )
} ) ;
const resJson = JSON . parse ( res . body ) ;
// Return true if purchased, false otherwise
return res . statusCode >= 200 && res . statusCode < 300 && resJson . purchaseTransactionStatus === "PURCHASE_TRANSACTION_STATUS_SUCCESS" ;
}
2024-04-24 06:28:07 +00:00
const SubmissionColumnsString = "map_id,unix_timestamp,user_id,username,display_name,creator\n" ;
2024-04-17 00:58:24 +00:00
const SubmissionColumn = {
ModelID : 0 ,
UnixTimestamp : 1 ,
UserID : 2 ,
2024-04-24 06:28:07 +00:00
Username : 3 ,
DisplayName : 4 ,
Creator : 5
2024-04-17 00:58:24 +00:00
} ;
function getSubmissionLine ( line ) {
const modelId = Number ( line [ SubmissionColumn . ModelID ] ) ;
const timestamp = Number ( line [ SubmissionColumn . UnixTimestamp ] ) ;
let userId = "" ;
let username = "" ;
2024-04-24 06:28:07 +00:00
let displayName = "" ;
let creator = "" ;
2024-04-17 00:58:24 +00:00
// Backwards compatibility
if ( line . length > 2 ) {
userId = Number ( line [ SubmissionColumn . UserID ] ) ;
username = line [ SubmissionColumn . Username ] ;
}
2024-04-24 06:28:07 +00:00
if ( line . length > 4 ) {
displayName = line [ SubmissionColumn . DisplayName ] ;
creator = line [ SubmissionColumn . Creator ] ;
}
2024-04-17 00:58:24 +00:00
return {
modelId : modelId ,
timestamp : timestamp ,
userId : userId ,
2024-04-24 06:28:07 +00:00
username : username ,
displayName : displayName ,
creator : creator
} ;
}
function safeCsvFormat ( cell ) {
// https://stackoverflow.com/questions/46637955/write-a-string-containing-commas-and-double-quotes-to-csv
if ( cell . replace ( / /g , '' ) . match ( /[\s,"]/ ) ) {
return '"' + cell . replace ( /"/g , '""' ) + '"' ;
}
return cell ;
}
function createSubmissionLine ( modelId , timestamp , userId , username , displayName , creator ) {
return ` ${ modelId } , ${ timestamp } , ${ userId } , ${ safeCsvFormat ( username ) } , ${ safeCsvFormat ( displayName ) } , ${ safeCsvFormat ( creator ) } \n ` ;
}
/ * *
* @ param { RobloxModel } model
* /
function getMapStringValues ( model ) {
if ( model . Roots . length < 1 ) {
return undefined ;
}
const root = model . Roots [ 0 ] ;
const values = root . FindChildrenOfClass ( "StringValue" , ( child ) => child . Name === "DisplayName" || child . Name === "Creator" ) ;
if ( values . length !== 2 ) {
return undefined ;
}
let name , creator ;
if ( values [ 0 ] . Name === "DisplayName" ) {
name = values [ 0 ] . Value ;
creator = values [ 1 ] . Value ;
}
else {
name = values [ 1 ] . Value ;
creator = values [ 0 ] . Value ;
}
return {
displayName : name ,
creator : creator
} ;
}
function capitalize ( str ) {
if ( ! str ) return "" ;
return str [ 0 ] . toUpperCase ( ) + str . slice ( 1 ) ;
}
async function validateMapAsset ( assetId , game ) {
2024-04-26 02:08:40 +00:00
const modelDomRes = await axios . get ( "https://assetdelivery.roblox.com/v1/asset/" , {
params : { id : assetId } ,
responseEncoding : "binary" ,
responseType : "arraybuffer"
} ) ;
2024-04-29 22:51:32 +00:00
const model = RobloxFile . ReadFromBuffer ( modelDomRes . data ) ;
2024-04-24 06:28:07 +00:00
if ( ! model ) {
// For whatever reason we couldn't parse the model, so we'll just skip doing validation
return {
valid : true ,
displayName : "" ,
creator : ""
} ;
}
const errors = [ ] ;
if ( model . Roots . length > 1 ) {
errors . push ( "Your map has more than one root part, it must have a single `Model` as the root." ) ;
return {
valid : false ,
errors : errors
} ;
}
const root = model . Roots [ 0 ] ;
if ( ! root . IsA ( "Model" ) ) {
errors . push ( ` The root part of your model is a \` ${ root . ClassName } \` , it needs to be a \` Model \` instead. ` ) ;
}
else {
const prefix = ( game === "deathrun" ) ? "dr" : game ;
if ( ! root . Name . startsWith ( prefix + "_" ) ) {
errors . push ( ` Your root model's name is \` ${ root . Name } \` , its name must start with \` ${ prefix } _ \` . ` ) ;
}
if ( ! /^[a-z0-9_]*$/ . test ( root . Name ) ) {
errors . push ( ` Your root model's name is \` ${ root . Name } \` which contains invalid characters. It must only contain lowercase alphanumeric characters separated by underscores. ` ) ;
}
}
const values = getMapStringValues ( model ) ;
if ( ! values ) {
2024-04-24 16:03:25 +00:00
errors . push ( "Your map is missing a `StringValue` named `Creator` and/or a `StringValue` named `DisplayName`. You must add both to your map and they must be parented directly to the root model. These are used to set the map's creator and name in-game." ) ;
2024-04-24 06:28:07 +00:00
}
else {
if ( ! values . creator ) {
errors . push ( "Your map's `Creator` `StringValue` does not have a `Value`." ) ;
}
if ( ! values . displayName ) {
errors . push ( "Your map's `DisplayName` `StringValue` does not have a `Value`." ) ;
}
else {
const checkName = capitalize ( values . displayName ) ;
if ( values . displayName !== checkName ) {
errors . push ( ` Your map's \` DisplayName \` must be capitalized. You may change it from \` ${ values . displayName } \` to \` ${ checkName } \` . ` ) ;
}
}
}
2024-05-01 00:07:18 +00:00
const mapStarts = root . FindDescendantsOfClass ( "BasePart" , ( part ) => part . Name === "MapStart" ) ;
if ( mapStarts . length !== 1 ) {
errors . push ( "Your map must have exactly one part named `MapStart`." ) ;
2024-04-24 06:28:07 +00:00
}
2024-05-01 00:07:18 +00:00
const mapFinishes = root . FindDescendantsOfClass ( "BasePart" , ( part ) => part . Name === "MapFinish" ) ;
if ( mapFinishes . length < 1 ) {
errors . push ( "Your map must have at least one part named `MapFinish`." ) ;
}
const mapParts = Array . from ( mapStarts ) ;
mapParts . push ( ... mapFinishes ) ;
for ( const part of mapParts ) {
2024-05-01 20:20:58 +00:00
if ( part . CanCollide ) {
2024-05-01 00:07:18 +00:00
errors . push ( "The `MapStart` and `MapFinish` parts in your map must have the `CanCollide` property disabled." ) ;
break ;
}
}
const allSpawns = root . FindDescendantsOfClass ( "BasePart" , ( part ) => part . Name . startsWith ( "Spawn" ) && ! isNaN ( part . Name . slice ( 5 ) ) ) ;
const spawnNameSet = new Set ( ) ;
const duplicateSpawns = new Set ( ) ;
for ( const spawn of allSpawns ) {
const name = spawn . Name ;
if ( spawnNameSet . has ( name ) ) {
duplicateSpawns . add ( name ) ;
}
else {
spawnNameSet . add ( name ) ;
}
}
if ( ! spawnNameSet . has ( "Spawn1" ) ) {
errors . push ( "Your map must have a part named `Spawn1`." ) ;
2024-04-24 06:28:07 +00:00
}
2024-05-01 00:07:18 +00:00
const numDups = duplicateSpawns . size ;
if ( numDups > 0 ) {
const sortedSpawns = Array . from ( duplicateSpawns ) ;
sortedSpawns . sort ( ) ;
const firstFive = sortedSpawns . slice ( 0 , 5 ) . map ( ( name ) => ` \` ${ name } \` ` ) ;
if ( numDups > 1 && numDups <= 5 ) {
firstFive [ firstFive . length - 1 ] = "and " + firstFive [ firstFive . length - 1 ] ;
}
let msg = numDups === 1 ? "Your map has a duplicate `Spawn` part: " : "Your map has duplicate `Spawn` parts: " ;
msg += firstFive . join ( ", " ) ;
if ( numDups > 5 ) {
msg += ` , and ${ numDups - 5 } more ` ;
}
msg += ". There can only be one instance of each `Spawn` part." ;
errors . push ( msg ) ;
2024-04-24 06:28:07 +00:00
}
// Why does ModuleScript not inherit from Script, and/or why does BaseScript not have a Source property?
2024-05-01 00:07:18 +00:00
const scripts = root . FindDescendantsOfClass ( "Script" ) ;
scripts . push ( ... root . FindDescendantsOfClass ( "ModuleScript" ) ) ;
const sourceSet = new Set ( ) ;
let numDuplicateScripts = 0 ;
let hasIllegalKeywords = false ;
for ( const script of scripts ) {
const source = script . Source ;
if ( ! hasIllegalKeywords && sourceHasIllegalKeywords ( source ) ) {
hasIllegalKeywords = true ;
}
if ( sourceSet . has ( source ) ) {
++ numDuplicateScripts ;
}
else {
sourceSet . add ( source ) ;
}
}
if ( hasIllegalKeywords ) {
2024-04-24 06:28:07 +00:00
errors . push ( "Your map has a `Script` that contains the keyword `getfenv` or `require`. You must remove these." ) ;
}
2024-05-01 00:07:18 +00:00
if ( numDuplicateScripts > 50 ) {
errors . push ( "Your map has over 50 duplicate `Script`s. You must consolidate your scripts to less than 50 duplicates." ) ;
}
2024-04-24 06:28:07 +00:00
if ( errors . length > 0 ) {
return {
valid : false ,
errors : errors
} ;
}
return {
valid : true ,
displayName : values . displayName ,
creator : values . creator
2024-04-17 00:58:24 +00:00
} ;
}
2024-04-24 06:28:07 +00:00
function sourceHasIllegalKeywords ( source ) {
return source && ( source . includes ( "getfenv" ) || source . includes ( "require" ) ) ;
}
function getValidationMessage ( validation , game , errorOnFail ) {
if ( validation . valid ) {
return ` ✅ Your map is valid! (game: ${ game } ) ` ;
}
let msg = ` ${ errorOnFail ? "🚫" : "⚠️" } **Your map has problems.** (game: ${ game } ) ` ;
for ( const error of validation . errors ) {
msg += ` \n * ${ error } ` ;
}
return msg ;
2024-04-17 00:58:24 +00:00
}
2024-08-22 03:48:30 +00:00
module . exports = { AssetType , getAssetInfo , buyModel , SubmissionColumn , SubmissionColumnsString , getSubmissionLine , createSubmissionLine , validateMapAsset , getValidationMessage , safeCsvFormat } ;