Parse map models and validate them (#13)
The enhancements: - Added map validation that checks for some common errors that maps have - /take: Does map validation but will still take the map even if there are problems - /take: Improved the help text and added links to the maptest places - /submit: Does map validation and will not let you submit if there are problems - /submissions: Now shows the map's display name and creator - Added an ESLint config and tidied some things up - Updated some of the packages Reviewed-on: #13 Co-authored-by: Carter Penterman <carterpenterman@gmail.com> Co-committed-by: Carter Penterman <carterpenterman@gmail.com>
This commit is contained in:
175
common.js
175
common.js
@ -1,4 +1,5 @@
|
||||
const axios = require("axios").default;
|
||||
const { RobloxModel } = require("rbxm-parser");
|
||||
|
||||
// https://create.roblox.com/docs/reference/engine/enums/AssetType
|
||||
const AssetType = {
|
||||
@ -34,7 +35,7 @@ async function getAssetInfo(assetId) {
|
||||
if (res.status < 200 || res.status > 300) {
|
||||
return {
|
||||
status: res.status
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const data = res.data.data;
|
||||
@ -49,16 +50,18 @@ async function getAssetInfo(assetId) {
|
||||
price: assetInfo.product.price,
|
||||
productId: assetInfo.product.productId,
|
||||
forSale: assetInfo.product.isForSaleOrIsPublicDomain
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const SubmissionColumnsString = "map_id,unix_timestamp,user_id,username\n";
|
||||
const SubmissionColumnsString = "map_id,unix_timestamp,user_id,username,display_name,creator\n";
|
||||
|
||||
const SubmissionColumn = {
|
||||
ModelID: 0,
|
||||
UnixTimestamp: 1,
|
||||
UserID: 2,
|
||||
Username: 3
|
||||
Username: 3,
|
||||
DisplayName: 4,
|
||||
Creator: 5
|
||||
};
|
||||
|
||||
function getSubmissionLine(line) {
|
||||
@ -66,6 +69,8 @@ function getSubmissionLine(line) {
|
||||
const timestamp = Number(line[SubmissionColumn.UnixTimestamp]);
|
||||
let userId = "";
|
||||
let username = "";
|
||||
let displayName = "";
|
||||
let creator = "";
|
||||
|
||||
// Backwards compatibility
|
||||
if (line.length > 2) {
|
||||
@ -73,16 +78,170 @@ function getSubmissionLine(line) {
|
||||
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
|
||||
username: username,
|
||||
displayName: displayName,
|
||||
creator: creator
|
||||
};
|
||||
}
|
||||
|
||||
function createSubmissionLine(modelId, timestamp, userId, username) {
|
||||
return `${modelId},${timestamp},${userId},${username}\n`;
|
||||
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;
|
||||
}
|
||||
|
||||
module.exports = { AssetType, getAssetInfo, SubmissionColumn, SubmissionColumnsString, getSubmissionLine, createSubmissionLine };
|
||||
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 model = await RobloxModel.ReadFromAssetId(assetId);
|
||||
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 name and creator 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 mapParts = root.FindDescendantsOfClass("BasePart", (part) => part.Name === "MapStart" || part.Name === "MapFinish");
|
||||
if (mapParts.length !== 2 || mapParts[0].Name === mapParts[1].Name) {
|
||||
errors.push("Your map must have exactly one part named `MapStart` and one part named `MapFinish`.");
|
||||
}
|
||||
else if (mapParts[0].CanCollide || mapParts[1].CanCollide) {
|
||||
errors.push("The `MapStart` and `MapFinish` parts in your map must have the `CanCollide` property disabled.");
|
||||
}
|
||||
|
||||
const spawnOnes = root.FindDescendantsOfClass("BasePart", (part) => part.Name === "Spawn1");
|
||||
if (spawnOnes.length !== 1) {
|
||||
errors.push("Your map must have exactly one part named `Spawn1`.");
|
||||
}
|
||||
|
||||
// Why does ModuleScript not inherit from Script, and/or why does BaseScript not have a Source property?
|
||||
const illegalScript = root.FindFirstDescendantOfClass("Script", (script) => sourceHasIllegalKeywords(script.Source));
|
||||
const illegalModuleScript = root.FindFirstDescendantOfClass("ModuleScript", (script) => sourceHasIllegalKeywords(script.Source));
|
||||
if (illegalScript || illegalModuleScript) {
|
||||
errors.push("Your map has a `Script` that contains the keyword `getfenv` or `require`. You must remove these.");
|
||||
}
|
||||
|
||||
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, SubmissionColumn, SubmissionColumnsString, getSubmissionLine, createSubmissionLine, validateMapAsset, getValidationMessage, safeCsvFormat };
|
Reference in New Issue
Block a user