const axios = require("axios").default; const { RobloxModel } = require("rbxm-parser"); // 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 res = await axios.get("https://apis.roblox.com/toolbox-service/v1/items/details", { params: { assetIds: assetId }, validateStatus: (status) => status === 403 || status === 404 || (status >= 200 && status < 300) // Allow 403/404 as a valid status (don't throw an error) }); if (res.status < 200 || res.status > 300) { return { status: res.status }; } const data = res.data.data; const assetInfo = data[0]; return { status: res.status, id: assetId, name: assetInfo.asset.name, typeId: assetInfo.asset.typeId, creatorId: assetInfo.creator.id, price: assetInfo.product.price, productId: assetInfo.product.productId, forSale: assetInfo.product.isForSaleOrIsPublicDomain }; } 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 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 };