Here is the start of a structure. This returns the special case text at the top of the chart (ie, 'hit', 'slam', 'stun', etc.). More information could be tracked along the way (like what the start column was, what the offset column became, etc.), but like I said, this is the start of a structure. Change it as you need. Assumptions I made along the way: ...attacks will have only 1 type (only one column need be consulted from the set of 'edge attack', 'killing', etc. ...the attacking character will be selected ...the attributes representing Fighting, Agility, Strength, and Endurance are on the sheet and are accessible via lowercase (typically true) ...if your offset would move the column beyond the edge of the table, stop at the first/last column I have this working in a console version (you can drop it in a console outside of Roll20), but when you have to return something from the character sheet (dynamically getting the attribute for the column to start in), you have to take it to Roll20... so this is the version that drops that into the revealing module pattern I discussed earlier. The command line it expects is: !fase --type|<attack type> --offset|<number> The type is mandatory; offset is optional. Argument delimiter can be a pipe or a hash, so this is functionally equivalent: !fase --type#<attack type> --offset#<number> There isn't any "nice" reporting (like, "Hey, you didn't select a token", or "hey, that token doesn't represent a character"). It will just look like the script does nothing if the appropriate conditions aren't met. However, I marked the spots where you would want to send that kind of notification out. Also, it doesn't output anything except a statement to the script's log panel (in the script page for the campaign) reporting the output of all of the figuring. So if you want to see the results of your command line, check there, or change the script to output a chat message. While I tested the language pulling the attack result, the part of the script tying in Roll20 functionality is untested (I didn't have time to mock up an environment). // opening comment prevents poor closure management in previous script var API_Meta = API_Meta || {}; API_Meta.FASERIP = { offset: Number.MAX_SAFE_INTEGER, lineCount: -1 }; { try { throw new Error(''); } catch (e) { API_Meta.FASERIP.offset = (parseInt(e.stack.split(/\n/)[1].replace(/^.*:(\d+):.*$/, '$1'), 10) - (5)); } } const FASERIP = (() => { // ================================================== // VERSION // ================================================== const apiproject = 'FASERIP'; API_Meta[apiproject].version = '0.0.1'; const vd = new Date(1656364376097); const versionInfo = () => { log(`${apiproject} v${API_Meta[apiproject].version}, ${vd.getFullYear()}/${vd.getMonth() + 1}/${vd.getDate()} -- offset ${API_Meta[apiproject].offset}`); return; }; const getResultColor = ((c, r, o) => { const columnBreakpoints = { 'shift-0': { startG: '66', startY: '95', startR: '100' }, feeble: { startG: '61', startY: '91', startR: '100' }, poor: { startG: '56', startY: '86', startR: '100' }, typical: { startG: '51', startY: '81', startR: '98' }, good: { startG: '46', startY: '75', startR: '98' }, excellent: { startG: '41', startY: '71', startR: '95' }, remarkable: { startG: '36', startY: '66', startR: '95' }, incredible: { startG: '31', startY: '61', startR: '91' }, amazing: { startG: '26', startY: '56', startR: '91' }, monstrous: { startG: '21', startY: '51', startR: '86' }, unearthly: { startG: '16', startY: '46', startR: '86' }, 'shift-x': { startG: '11', startY: '41', startR: '81' }, 'shift-y': { startG: '07', startY: '41', startR: '81' }, 'shift-z': { startG: '04', startY: '36', startR: '75' }, class1000: { startG: '02', startY: '36', startR: '75' }, class3000: { startG: '02', startY: '31', startR: '71' }, class5000: { startG: '02', startY: '26', startR: '66' }, beyond: { startG: '02', startY: '21', startR: '61' } }; const readTable = (col = '', roll, offset = 0) => { if (!columnBreakpoints[col.toLowerCase()]) return; let lcCol = col.toLowerCase(); let colSet = Object.keys(columnBreakpoints); let breakpoints; if (offset > 0) { breakpoints = columnBreakpoints[colSet[Math.min(colSet.length - 1, colSet.indexOf(lcCol) + offset)]]; } else if (offset < 0) { breakpoints = columnBreakpoints[colSet[Math.max(0, colSet.indexOf(lcCol) + offset)]]; } else { breakpoints = columnBreakpoints[lcCol]; } if (roll <= breakpoints.startG) return 'white'; if (roll <= breakpoints.startY) return 'green'; if (roll <= breakpoints.startR) return 'yellow'; return 'red'; }; return (c, r, o) => readTable(c, r, o); })(); const attackTypeResults = { white: { ba: 'Miss', ea: 'Miss', sh: "Miss", te: "Miss", tb: "Miss", en: "Miss", fo: "Miss", gp: "Miss", gb: "Miss", es: "Miss", ch: "Miss", do: "None", ev: "Autohit", bl: "-6 CS", ca: "Autohit", stun: "1-10", slam: "Gr. Slam", kill: "En. Loss" }, green: { ba: "Hit", ea: "Hit", sh: "Hit", te: "Hit", tb: "Hit", en: "Hit", fo: "Hit", gp: "Miss", gb: "Take", es: "Miss", ch: "Hit", do: "-2 CS", ev: "Evasion", bl: "-4 CS", ca: "Miss", stun: "1", slam: "1 area", kill: "E/S" }, yellow: { ba: "Slam", ea: "Stun", sh: "Bullseye", te: "Stun", tb: "Hit", en: "Bullseye", fo: "Bullseye", gp: "partial", gb: "Grab", es: "Escape", ch: "Slam", do: "-4 CS", ev: "+1 CS", bl: "-2 CS", ca: "Damage", stun: "No", slam: "Stagger", kill: "No" }, red: { ba: "Stun", ea: "Kill", sh: "Kill", te: "Kill", tb: "Stun", en: "Kill", fo: "Stun", gp: "Hold", gb: "Break", es: "Reverse", ch: "Stun", do: "-6 CS", ev: "+2 CS", bl: "+1 CS", ca: "Catch", stun: "No", slam: "No", kill: "No" } }; const getStartingCol = (character, attType) => { let attrName = { ba: "fighting", ea: "fighting", sh: "agility", te: "agility", tb: "agility", en: "agility", fo: "agility", gp: "strength", gb: "strength", es: "strength", ch: "endurance", do: "agility", ev: "fighting", bl: "strength", ca: "agility", stun: "endurance", slam: "endurance", kill: "endurance" }; if (!character || !attrName[attType]) return; return getAttrByName(character.id, attrName[attType]); }; const handleInput = (msg) => { if (msg.type !== 'api' || !/^!fase/.test(msg.content)) return; if (!msg.selected || !msg.selected.length) return; // can put notification that a selected token is required, here let argrx = /([^#|]+)(?:#|\|)?(.*)/g, res, attType = '', offset = 0; let args = msg.content.split(/\s+--/) // split at argument delimiter .slice(1) // drop the api tag .forEach(a => { // split each arg at # or |, (foo#bar becomes [foo, bar]) argrx.lastIndex = 0; if (argrx.test(a)) { argrx.lastIndex = 0; res = argrx.exec(a); switch (res[1].toLowerCase()) { case 'type': attType = res[2]; break; case 'offset': offset = !isNaN(Number(res[2])) ? Number(res[2]) : 0; break; default: break; } } }); if (attType === '') return; // can put notification here that no attack type was chosen let character = findObjs({ type: 'character', id: findObjs({ type: 'token', id: msg.selected[0] })[0].get('represents') }); if (!character) return; // can put notification that no character was found for the selected token, here let roll = randomInteger(100); let startingCol = getStartingCol(character, attType); let colorResult = getResultColor(startingCol, roll, offset); if (!attackTypeResults[colorResult] || !Object.keys(attackTypeResults[colorResult]).includes(attType.toLowerCase())) return; log(attackTypeResults[colorResult.toLowerCase()][attType.toLowerCase()]); return; }; regHandlers = () => { on('chat:message', handleInput); }; on('ready', () => { versionInfo(); regHandlers(); }); return { }; })(); { try { throw new Error(''); } catch (e) { API_Meta.FASERIP.lineCount = (parseInt(e.stack.split(/\n/)[1].replace(/^.*:(\d+):.*$/, '$1'), 10) - API_Meta.FASERIP.offset); } }