GDBrowser/api/analyze.js
Ricardo Fernández Serrata 5e86a88689 Update analyze.js
2022-10-05 20:39:25 -04:00

371 lines
14 KiB
JavaScript

"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
}