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.status, 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 };