351 lines
11 KiB
JavaScript
351 lines
11 KiB
JavaScript
const axios = require("axios").default;
|
|
const { RobloxFile } = require("rbxm-parser");
|
|
const noblox = require("noblox.js");
|
|
|
|
// 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) {
|
|
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"
|
|
}
|
|
});
|
|
|
|
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
return {
|
|
status: res.statusCode,
|
|
isModel: false
|
|
};
|
|
}
|
|
|
|
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;
|
|
|
|
return {
|
|
status: res.statusCode,
|
|
isModel: true,
|
|
id: assetInfo.modelAssetId,
|
|
creatorId: +assetInfo.userSeller,
|
|
isFree: isFree,
|
|
forSale: forSale
|
|
};
|
|
}
|
|
|
|
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";
|
|
}
|
|
|
|
const SubmissionColumnsString = "map_id,unix_timestamp,user_id,username,display_name,creator\n";
|
|
|
|
const SubmissionColumn = {
|
|
ModelID: 0,
|
|
UnixTimestamp: 1,
|
|
UserID: 2,
|
|
Username: 3,
|
|
DisplayName: 4,
|
|
Creator: 5
|
|
};
|
|
|
|
function getSubmissionLine(line) {
|
|
const modelId = Number(line[SubmissionColumn.ModelID]);
|
|
const timestamp = Number(line[SubmissionColumn.UnixTimestamp]);
|
|
let userId = "";
|
|
let username = "";
|
|
let displayName = "";
|
|
let creator = "";
|
|
|
|
// Backwards compatibility
|
|
if (line.length > 2) {
|
|
userId = Number(line[SubmissionColumn.UserID]);
|
|
username = line[SubmissionColumn.Username];
|
|
}
|
|
|
|
if (line.length > 4) {
|
|
displayName = line[SubmissionColumn.DisplayName];
|
|
creator = line[SubmissionColumn.Creator];
|
|
}
|
|
|
|
return {
|
|
modelId: modelId,
|
|
timestamp: timestamp,
|
|
userId: userId,
|
|
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) {
|
|
const modelDomRes = await axios.get("https://assetdelivery.roblox.com/v1/asset/", {
|
|
params: { id: assetId },
|
|
responseEncoding: "binary",
|
|
responseType: "arraybuffer"
|
|
});
|
|
|
|
const model = RobloxFile.ReadFromBuffer(modelDomRes.data);
|
|
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) {
|
|
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.");
|
|
}
|
|
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}\`.`);
|
|
}
|
|
}
|
|
}
|
|
|
|
const mapStarts = root.FindDescendantsOfClass("BasePart", (part) => part.Name === "MapStart");
|
|
if (mapStarts.length !== 1) {
|
|
errors.push("Your map must have exactly one part named `MapStart`.");
|
|
}
|
|
|
|
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) {
|
|
if (part.CanCollide) {
|
|
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`.");
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
// Why does ModuleScript not inherit from Script, and/or why does BaseScript not have a Source property?
|
|
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) {
|
|
errors.push("Your map has a `Script` that contains the keyword `getfenv` or `require`. You must remove these.");
|
|
}
|
|
|
|
if (numDuplicateScripts > 50) {
|
|
errors.push("Your map has over 50 duplicate `Script`s. You must consolidate your scripts to less than 50 duplicates.");
|
|
}
|
|
|
|
if (errors.length > 0) {
|
|
return {
|
|
valid: false,
|
|
errors: errors
|
|
};
|
|
}
|
|
|
|
return {
|
|
valid: true,
|
|
displayName: values.displayName,
|
|
creator: values.creator
|
|
};
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
module.exports = { AssetType, getAssetInfo, buyModel, SubmissionColumn, SubmissionColumnsString, getSubmissionLine, createSubmissionLine, validateMapAsset, getValidationMessage, safeCsvFormat }; |