Parse map models and validate them (#13)
Some checks failed
continuous-integration/drone/push Build is failing

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:
Carter Penterman 2024-04-24 06:28:07 +00:00 committed by Quaternions
parent f8476577d6
commit 3a167cf686
8 changed files with 267 additions and 45 deletions

6
bot.js
View File

@ -1,8 +1,8 @@
const fs = require('node:fs');
const { Client, Collection, Intents } = require("discord.js");
const { Client, Collection } = require("discord.js");
const {token} = require("./config/config.json");
const client = new Client({intents: [Intents.FLAGS.GUILDS]});
const client = new Client({intents: ["Guilds"]});
client.commands = new Collection();
const commandFiles = fs.readdirSync('./commands').filter(file => file.endsWith('.js'));
@ -29,7 +29,7 @@ client.on('interactionCreate', async interaction => {
}
});
client.on("error", async error => {
client.on("error", async _error => {
});

View File

@ -1,13 +1,17 @@
const { SlashCommandBuilder } = require('@discordjs/builders');
const { MessageAttachment } = require("discord.js");
const { AttachmentBuilder } = require("discord.js");
const fs = require('node:fs');
const { submissions, commands } = require("../config/config.js");
const { parse } = require("csv-parse/sync");
const { getSubmissionLine } = require("../common.js");
const { getSubmissionLine, safeCsvFormat } = require("../common.js");
const Sugar = require("sugar-date");
const { Buffer } = require("buffer");
/**
* @param {import('discord.js').ChatInputCommandInteraction} interaction
*/
async function execute(interaction) {
const game = interaction.options.getString("game");
const game = interaction.options.getString("game", true);
const fname = submissions[game];
if (fname === undefined) {
@ -53,7 +57,7 @@ async function execute(interaction) {
const lines = parse(csvFile, {delimiter: ',', fromLine: 2});
let csvString = "map_id,unix_timestamp,date_string,user_id,username\n";
let csvString = "map_id,unix_timestamp,date_string,user_id,username,display_name,creator\n";
let found = false;
for (let lineStr of lines) {
const line = getSubmissionLine(lineStr);
@ -66,7 +70,7 @@ async function execute(interaction) {
found = true;
const dateStr = new Date(line.timestamp * 1000).toLocaleString("en-US", {dateStyle: "short"});
csvString += `${line.modelId},${line.timestamp},${dateStr},${line.userId},${line.username}\n`;
csvString += `${line.modelId},${line.timestamp},${dateStr},${line.userId},${safeCsvFormat(line.username)},${safeCsvFormat(line.displayName)},${safeCsvFormat(line.creator)}\n`;
}
if (!found) {
@ -82,7 +86,7 @@ async function execute(interaction) {
return;
}
const file = new MessageAttachment(Buffer.from(csvString), fname);
const file = new AttachmentBuilder(Buffer.from(csvString), { name: fname });
const dateRangeStr = getDateRangeString(afterTimestamp, beforeTimestamp);
if (dateRangeStr) {
await interaction.reply({content: `Using date range ${dateRangeStr}:`, files: [file]});

View File

@ -4,14 +4,14 @@ const fs = require('node:fs');
const noblox = require("noblox.js");
const axios = require("axios").default;
const { submissions, commands, cookies } = require("../config/config.js");
const { AssetType, getAssetInfo, SubmissionColumnsString, createSubmissionLine, getSubmissionLine } = require("../common.js");
const { AssetType, getAssetInfo, SubmissionColumnsString, createSubmissionLine, getSubmissionLine, validateMapAsset, getValidationMessage } = require("../common.js");
async function robloxUserFromDiscord(id) {
if (isNaN(id)) return undefined;
try {
const res = await axios.get(`https://api.fiveman1.net/v1/users/${id}`);
return res.data.result.robloxId;
} catch (error) {
} catch {
return undefined;
}
}
@ -21,11 +21,14 @@ async function robloxUsernameFromId(id) {
try {
const res = await axios.get(`https://users.roblox.com/v1/users/${id}`);
return res.data.name;
} catch (error) {
} catch {
return undefined;
}
}
/**
* @param {import('discord.js').ChatInputCommandInteraction} interaction
*/
async function execute(interaction) {
const userId = await robloxUserFromDiscord(interaction.user.id);
if (!userId) {
@ -33,7 +36,7 @@ async function execute(interaction) {
await interaction.reply({content: msg, ephemeral: true});
return;
}
const game = interaction.options.getString("game");
const game = interaction.options.getString("game", true);
const fname = submissions[game];
if (fname === undefined) {
@ -45,7 +48,7 @@ async function execute(interaction) {
fs.writeFileSync(fname, SubmissionColumnsString);
}
const id = interaction.options.getInteger("asset_id");
const id = interaction.options.getInteger("asset_id", true);
await noblox.setCookie(cookies[game]);
// Check that the bot owns this model
@ -74,23 +77,38 @@ async function execute(interaction) {
const csvFile = fs.readFileSync(fname);
const lines = parse(csvFile, {delimiter: ',', fromLine: 2});
let csvString = SubmissionColumnsString;
for (let lineStr of lines) {
const line = getSubmissionLine(lineStr);
if (id === line.modelId) {
await interaction.reply({content: `This map (id: ${id}) was already submitted on <t:${line.timestamp}:d>.`, ephemeral: true});
return;
}
}
csvString += createSubmissionLine(line.modelId, line.timestamp, line.userId, line.username);
const mapValidatePromise = validateMapAsset(id, game);
const message = await interaction.reply("⌛ Validating map...");
const validation = await mapValidatePromise;
const msg = getValidationMessage(validation, game, true);
await message.edit(msg);
if (!validation.valid) {
await interaction.followUp("Due to having problems, your map was **NOT submitted**.");
return;
}
let csvString = SubmissionColumnsString;
for (let lineStr of lines) {
const line = getSubmissionLine(lineStr);
csvString += createSubmissionLine(line.modelId, line.timestamp, line.userId, line.username, line.displayName, line.creator);
}
const unixTimestamp = Math.round(+new Date()/1000);
csvString += createSubmissionLine(id, unixTimestamp, userId, await robloxUsernameFromId(userId));
csvString += createSubmissionLine(id, unixTimestamp, userId, await robloxUsernameFromId(userId), validation.displayName, validation.creator);
fs.writeFileSync(fname, csvString);
await interaction.reply(`Map (id: ${id}) successfully submitted.`);
await interaction.followUp(`Map (id: ${id}) successfully submitted.`);
}
module.exports = {

View File

@ -1,17 +1,20 @@
const { SlashCommandBuilder } = require('@discordjs/builders');
const noblox = require("noblox.js");
const { cookies, commands } = require("../config/config.js");
const { AssetType, getAssetInfo } = require("../common.js");
const { cookies, commands, gamePlaces } = require("../config/config.js");
const { AssetType, getAssetInfo, validateMapAsset, getValidationMessage } = require("../common.js");
/**
* @param {import('discord.js').ChatInputCommandInteraction} interaction
*/
async function execute(interaction) {
const game = interaction.options.getString("game");
const game = interaction.options.getString("game", true);
const cookie = cookies[game];
if (cookie === undefined) {
await interaction.reply({content: "Invalid game specified!", ephemeral: true});
return;
}
const id = interaction.options.getInteger("asset_id");
const id = interaction.options.getInteger("asset_id", true);
await noblox.setCookie(cookie);
// Check that the bot doesn't already own this asset
if (await noblox.getOwnership(await noblox.getCurrentUser("UserID"), id, "Asset")) {
@ -46,16 +49,23 @@ async function execute(interaction) {
Creator: {
Id: assetInfo.creatorId
}
}
};
await noblox.buy({product: productInfo, price: 0});
await interaction.reply(
const buyPromise = noblox.buy({product: productInfo, price: 0});
const mapValidatePromise = validateMapAsset(id, game);
const message = await interaction.reply("⌛ Validating map...");
const validation = await mapValidatePromise;
const msg = getValidationMessage(validation, game, false);
await message.edit(msg);
await buyPromise;
await interaction.followUp(
`
Now that your map (id: ${id}) has been taken by the ${game} maptest bot you can load it into the ${game} maptest place. To load your map, join the game and say
\`\`\`
!map ${id}
\`\`\`Read what it says. If your map successfully loaded type !rtv and then choose your map.
If it did not load successfully, you can expand the chat to view the full error message by clicking and dragging on the edge of the chat.
Now that your [map (id: ${id})](<https://create.roblox.com/store/asset/${id}/>) has been taken by the bot you can load it into the [${game} maptest place](<${gamePlaces[game]}>).
To load your map, join the game and do \`!map ${id}\`. If your map successfully loaded, do \`!rtv\` and then choose your map.
Otherwise, you can expand the chat to view the full error message by clicking and dragging on the edge of the chat.
`
);
}

175
common.js
View File

@ -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 };

View File

@ -1,14 +1,24 @@
const { bhopCookie, surfCookie, deathrunCookie, flytrialsCookie } = require("./config.json");
const cookies = {
bhop: bhopCookie,
surf: surfCookie,
deathrun: deathrunCookie,
flytrials: flytrialsCookie,
};
const submissions = {};
const commands = [];
for (const game in cookies) {
submissions[game] = "files/" + game + "_submissions.csv";
commands.push({name: game, value: game});
}
module.exports = { cookies, submissions, commands };
const gamePlaces = {
bhop: "https://www.roblox.com/games/517201717/",
surf: "https://www.roblox.com/games/517206177/",
deathrun: "https://www.roblox.com/games/6870563649/",
flytrials: "https://www.roblox.com/games/12724901535/"
};
module.exports = { cookies, submissions, commands, gamePlaces };

15
eslint.config.mjs Normal file
View File

@ -0,0 +1,15 @@
import globals from "globals";
import pluginJs from "@eslint/js";
export default [
{files: ["**/*.js"], languageOptions: {sourceType: "commonjs"}},
{languageOptions: { globals: globals.browser }},
pluginJs.configs.recommended,
{
rules: {
"no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }],
"semi": "error"
}
}
];

View File

@ -1,13 +1,19 @@
{
"dependencies": {
"@discordjs/builders": "^0.13.0",
"@discordjs/rest": "^0.4.1",
"axios": "^0.27.2",
"@discordjs/builders": "^1.7.0",
"@discordjs/rest": "^2.2.0",
"axios": "^1.6.8",
"csv-parse": "^5.0.4",
"discord-api-types": "^0.32.1",
"discord.js": "^13.6.0",
"discord-api-types": "^0.37.81",
"discord.js": "^14.14.1",
"noblox.js": "^4.15.1",
"node-csv": "^0.1.2",
"rbxm-parser": "^1.0.4",
"sugar-date": "^2.0.6"
},
"devDependencies": {
"@eslint/js": "^9.1.1",
"eslint": "^9.1.1",
"globals": "^15.0.0"
}
}