"use strict"; const zlib = require('zlib') const blocks = require('../misc/analysis/blocks.json') const colorStuff = require('../misc/analysis/colorProperties.json') const init = require('../misc/analysis/initialProperties.json') const properties = require('../misc/analysis/objectProperties.json') const ids = require('../misc/analysis/objects.json') module.exports = async (app, req, res, level) => { level ||= { name: (req.body.name || "Unnamed").slice(0, 64), data: (req.body.data || "") } let unencrypted = level.data.startsWith('kS') // some gdps'es don't encrypt level data let levelString = unencrypted ? level.data : Buffer.from(level.data, 'base64') if (unencrypted) { const raw_data = level.data const response_data = analyze_level(level, raw_data) return res.send(response_data) } else { zlib.unzip(levelString, (err, buffer) => { if (err) return res.status(500).send("-2") const raw_data = buffer.toString() const response_data = analyze_level(level, raw_data) return res.send(response_data) }) } } /** * Sorts any `Object` by its keys * @param {{}} obj * @param {PropertyKey} [sortBy] optional inner key to sort */ const sortObj = (obj, sortBy) => { let keys = Object.keys(obj) .sort((a,b) => sortBy ? obj[b][sortBy] - obj[a][sortBy] : obj[b] - obj[a]) let sorted = {} keys.forEach(x => {sorted[x] = obj[x]}) return sorted } /** * game-object (**not** JS `Object`) parser * @param {string} obj * @param {string} splitter * @param {string[]} name_arr * @param {boolean} [valid_only] */ const parse_obj = (obj, splitter, name_arr, valid_only) => { const s_obj = obj.split(splitter) let robtop_obj = {} // semi-useless optimization depending on where at node js you're at for (let i = 0, obj_l = s_obj.length; i < obj_l; i += 2) { let k_name = s_obj[i] if (s_obj[i] in name_arr) { if (!valid_only) k_name = name_arr[s_obj[i]] robtop_obj[k_name] = s_obj[i + 1] } } return robtop_obj } /** * @param {{}} level * @param {string} rawData */ function analyze_level(level, rawData) { let response = {}; let blockCounts = {}; let miscCounts = {}; /**@type {string[]}*/ let triggerGroups = []; let highDetail = 0 /**@type {{}[]}*/ let alphaTriggers = []; let misc_objects = {}; let block_ids = {}; for (const [name, object_ids] of Object.entries(ids.misc)) { const copied_ids = object_ids.slice(1) // funny enough, shift effects the original id list copied_ids.forEach(object_id => { misc_objects[object_id] = name }) } for (const [name, object_ids] of Object.entries(blocks)) object_ids.forEach(object_id => { block_ids[object_id] = name }); /**@type {(string|{})[]}*/ const data = rawData.split(";") const header = data.shift() let level_portals = []; let level_coins = []; let level_text = []; // "why are these Objects instead of Arrays?" @Rudxain let orb_array = {}; let trigger_array = {}; let last = 0 const obj_length = data.length for (let i = 0; i < obj_length; ++i) { let obj = parse_obj(data[i], ',', properties) let {id} = obj if (id in ids.portals) { obj.portal = ids.portals[id] level_portals.push(obj) } else if (id in ids.coins) { obj.coin = ids.coins[id] level_coins.push(obj) } else if (id in ids.orbs) { obj.orb = ids.orbs[id] const orb = orb_array[obj.orb] orb_array[obj.orb] = orb ? +orb + 1 : 1 } else if (id in ids.triggers) { obj.trigger = ids.triggers[id] if (obj.trigger in trigger_array) { trigger_array[obj.trigger]++ } else { trigger_array[obj.trigger] = 1 } } if (obj.message) { level_text.push(obj) } if (obj.triggerGroups) obj.triggerGroups.split('.').forEach(x => triggerGroups.push(x)) if (obj.highDetail == 1) highDetail++ if (id in misc_objects) { const name = misc_objects[id] if (name in miscCounts) { miscCounts[name][0]++ } else { miscCounts[name] = [1, ids.misc[name][0]] } } if (id in block_ids) { const name = block_ids[id] if (name in blockCounts) { blockCounts[name]++ } else { blockCounts[name] = 1 } } // sometimes the field doesn't exist if (obj.x) last = Math.max(last, obj.x) // invisible triggers if (obj.trigger == "Alpha") alphaTriggers.push(obj) data[i] = obj } let invisTriggers = [] alphaTriggers.forEach(tr => { if ( tr.x < 500 && !tr.touchTriggered && !tr.spawnTriggered && tr.opacity == 0 && tr.duration == 0 && alphaTriggers.filter(x => x.targetGroupID == tr.targetGroupID).length == 1 ) invisTriggers.push(Number(tr.targetGroupID)) }) response.level = {}; ['name', 'id', 'author', 'playerID', 'accountID', 'large'].forEach(k => {response.level[k] = level[k]}) response.objects = data.length - 2 response.highDetail = highDetail response.settings = {} // "I have no idea what to name this lmao" @Rudxain let WTF = x => Math.floor(x.x / (Math.max(last, 529) + 340) * 100) response.portals = level_portals.sort((a, b) => parseInt(a.x) - parseInt(b.x)).map(x => x.portal + " " + WTF(x) + "%").join(", ") response.coins = level_coins.sort((a, b) => parseInt(a.x) - parseInt(b.x)).map(WTF) response.coinsVerified = level.verifiedCoins /**@param {number[]} arr*/ const sum = arr => arr.reduce((a, x) => a + x, 0) response.orbs = orb_array response.orbs.total = sum(Object.values(orb_array)) // we already have an array of objects, use it response.triggers = trigger_array response.triggers.total = sum(Object.values(trigger_array)) response.triggerGroups = {} response.blocks = sortObj(blockCounts) response.misc = sortObj(miscCounts, '0') response.colors = [] triggerGroups.forEach(x => { if (response.triggerGroups['Group ' + x]) response.triggerGroups['Group ' + x]++ else response.triggerGroups['Group ' + x] = 1 }) response.triggerGroups = sortObj(response.triggerGroups) let triggerKeys = Object.keys(response.triggerGroups).map(x => Number(x.slice(6))) response.triggerGroups.total = triggerKeys.length // find alpha group with the most objects response.invisibleGroup = triggerKeys.find(x => invisTriggers.includes(x)) response.text = level_text.sort((a, b) => parseInt(a.x) - parseInt(b.x)).map(x => [Buffer.from(x.message, 'base64').toString(), Math.round(x.x / last * 99) + "%"]) const header_response = parse_header(header) response.settings = header_response.settings response.colors = header_response.colors response.dataLength = rawData.length response.data = rawData return response } function parse_header(/**@type {string}*/ header) { let response = {} response.settings = {} response.colors = [] const header_keyed = parse_obj(header, ',', init.values, true) Object.keys(header_keyed).forEach(k => { let val = init.values[k] /**@type {string}*/ let name = val[0] let property = header_keyed[k] switch (val[1]) { case 'list': val = init[(val[0] + "s")][property]; break case 'number': val = Number(property); break case 'bump': val = Number(property) + 1; break case 'bool': val = property != "0"; break case 'extra-legacy-color': { // scope? // you can only imagine my fear when i discovered this was a thing // these literally are keys set the value, and to convert this to the color list we have to do this fun messy thing that shouldn't exist // since i wrote the 1.9 color before this, a lot of explaination will be there instead const colorInfo = name.split('-') /** r,g,b */ const color = colorInfo[2] const channel = colorInfo[1] // first we create the color object if (color == 'r') response.colors.push({"channel": channel, "opacity": 1}) // from here we touch the color object let currentChannel = response.colors.find(k => k.channel == channel) if (color == 'blend') currentChannel.blending = true // only one color has blending though lol if (color == 'pcol' && property != 0) currentChannel.pColor = property currentChannel[color] = property break } case 'legacy-color': { // if a level has a legacy color, we can assume that it does not have a kS38 at all const color = parse_obj(property, "_", colorStuff.properties) let colorObj = color // so here we parse the color to something understandable by the rest // slightly smart naming but it is also pretty gross // in a sense - the name would be something like legacy-G -> G const colorVal = name.split('-').at(-1) colorObj.channel = colorVal // from here stuff can continue as normal, ish if (colorObj.pColor == "-1" || colorObj.pColor == "0") delete colorObj.pColor colorObj.opacity = 1 // 1.9 colors don't have this! if (colorObj?.blending === '1') colorObj.blending = true // 1.9 colors manage to always think they're blending - they're not else delete colorObj.blending switch (colorVal) { case '3DL': response.colors.splice(4, 0, colorObj) // hardcode the position of 3DL, it typically goes at the end due to how RobTop make the headers break case 'Line': { colorObj.blending = true; response.colors.push(colorObj) // in line with 2.1 behavior break } default: response.colors.push(colorObj) // bruh whatever was done to make the color list originally was long break } break } case 'colors': { let colorList = property.split("|") colorList.forEach((x, y) => { const color = parse_obj(x, "_", colorStuff.properties) let colorObj = color if (!color.channel) return colorList = colorList.filter((_, i) => y != i) if (colorStuff.channels[colorObj.channel]) colorObj.channel = colorStuff.channels[colorObj.channel] if (colorObj.channel > 1000) return if (colorStuff.channels[colorObj.copiedChannel]) colorObj.copiedChannel = colorStuff.channels[colorObj.copiedChannel] if (colorObj.copiedChannel > 1000) delete colorObj.copiedChannel if (colorObj.pColor == "-1") delete colorObj.pColor if (colorObj.blending) colorObj.blending = true if (colorObj.copiedHSV) { let hsv = colorObj.copiedHSV.split("a") colorObj.copiedHSV = {} hsv.forEach((x, y) => { colorObj.copiedHSV[colorStuff.hsv[y]] = x }) colorObj.copiedHSV['s-checked'] = colorObj.copiedHSV['s-checked'] == 1 colorObj.copiedHSV['v-checked'] = colorObj.copiedHSV['v-checked'] == 1 if (colorObj.copyOpacity == 1) colorObj.copyOpacity = true } colorObj.opacity = +Number(colorObj.opacity).toFixed(2) colorList[y] = colorObj }) // we assume this is only going to be run once so... some stuff can go here colorList = colorList.filter(x => typeof x == "object") if (!colorList.find(x => x.channel == "Obj")) colorList.push({"r": "255", "g": "255", "b": "255", "channel": "Obj", "opacity": "1"}) const specialSort = ["BG", "G", "G2", "Line", "Obj", "3DL"] let specialColors = colorList.filter(x => isNaN(x.channel)).sort((a, b) => specialSort.indexOf(a.channel) > specialSort.indexOf(b.channel)) let regularColors = colorList.filter(x => !isNaN(x.channel)).sort((a, b) => a.channel - b.channel) response.colors = specialColors.concat(regularColors) break } } response.settings[name] = val }) if (!response.settings.ground || response.settings.ground > 17) response.settings.ground = 1 if (!response.settings.background || response.settings.background > 20) response.settings.background = 1 if (!response.settings.font) response.settings.font = 1 response.settings.alternateLine = response.settings.alternateLine == 2 Object.keys(response.settings).filter(k => { // this should be parsed into color list instead if (k.includes('legacy')) delete response.settings[k] }) delete response.settings['colors'] return response }