This is a standalone Roll20 API for AD&D 1e Turn Undead. Select the cleric or paladin token and run the macro. The API opens a Turn Undead menu, checks the character’s effective turning level, lets you choose the undead type, rolls the turn attempt, and applies standard Roll20 markers to affected undead within range. It will read the token name for the specific monster ie skeleton. Turned undead get the screaming marker with the number of rounds shown. Destroyed or damned undead get the dead marker. If it works correctly, it will either tag selected tokens for turning or check every token within 60ft. USE THIS COMMAND: !tu1e-menu --cleric @{selected|token_id} /* * TurnUndead1E_Standalone.js * Standalone AD&D 1E Turn Undead helper for Roll20. * Does not modify or depend on GM1E / GM Sheet. * Uses standard Roll20 token markers only: * turned = screaming marker, with number showing 3d4 rounds * destroyed/damned = dead marker */ on('ready', function () { 'use strict'; var SCRIPT = 'TurnUndead1E'; var VERSION = '1.0.0-standalone-standard-markers'; var TURNED_MARKER = 'screaming'; var DESTROYED_MARKER = 'dead'; var TURN_UNDEAD_ROWS = [ { key: 'skeleton', label: 'Skeleton', values: ['10','7','4','T','T','D','D','D*','D*','D*'] }, { key: 'zombie', label: 'Zombie', values: ['13','10','7','T','T','D','D','D','D*','D*'] }, { key: 'ghoul', label: 'Ghoul', values: ['16','13','10','4','T','T','D','D','D','D*'] }, { key: 'shadow', label: 'Shadow', values: ['19','16','13','7','4','T','T','D','D','D*'] }, { key: 'wight', label: 'Wight', values: ['20','19','16','10','7','4','T','T','D','D'] }, { key: 'ghast', label: 'Ghast', values: ['--','20','19','13','10','7','4','T','T','D'] }, { key: 'wraith', label: 'Wraith', values: ['--','--','20','16','13','10','7','4','T','D'] }, { key: 'mummy', label: 'Mummy', values: ['--','--','--','19','16','13','10','7','4','T'] }, { key: 'spectre', label: 'Spectre', values: ['--','--','--','20','19','16','13','10','7','T'] }, { key: 'vampire', label: 'Vampire', values: ['--','--','--','--','20','19','16','13','10','4'] }, { key: 'ghost', label: 'Ghost', values: ['--','--','--','--','--','20','19','16','13','7'] }, { key: 'lich', label: 'Lich', values: ['--','--','--','--','--','--','20','19','16','10'] }, { key: 'special', label: 'Special**', values: ['--','--','--','--','--','--','--','20','19','13'] } ]; function esc(s) { if (s === undefined || s === null) { return ''; } s = String(s); return s.split('&').join('&amp;') .split('<').join('&lt;') .split('>').join('&gt;') .split('"').join('&quot;'); } function box(c) { return '<div style="background:#000;color:#fff;border:1px solid #aa2222;padding:6px;font-size:12px;line-height:1.25;display:block;">' + c + '</div>'; } function title(t) { return '<div style="background:#000;color:#fff;font-weight:bold;font-size:14px;border-bottom:1px solid #aa2222;margin-bottom:4px;padding-bottom:3px;">' + esc(t) + '</div>'; } function row(c) { return '<div style="background:#000;color:#fff;margin:0;">' + c + '</div>'; } function hr() { return '<div style="background:#000;border-top:1px solid #aa2222;margin:4px 0;height:0;"></div>'; } function pill(label) { return '<span style="background:#7a1f1f;color:#fff;border:1px solid #aa2222;border-radius:3px;padding:1px 5px;margin:0 1px;display:inline-block;font-size:11px;font-weight:bold;">' + esc(label) + '</span>'; } function diePill(label) { return '<span style="background:#d8a11d;color:#000;border:1px solid #f4d36a;border-radius:3px;padding:1px 6px;margin:0 1px;display:inline-block;font-size:12px;font-weight:bold;">' + esc(label) + '</span>'; } function good(s) { return '<span style="color:#51d66b;font-weight:bold;">' + esc(s) + '</span>'; } function bad(s) { return '<span style="color:#ff5555;font-weight:bold;">' + esc(s) + '</span>'; } function neutral(s) { return '<span style="color:#ddd;">' + esc(s) + '</span>'; } function btn(label, command, tip) { return '<a href="' + esc(command) + '" title="' + esc(tip || '') + '" style="background:#111;color:#fff;border:1px solid #aa2222;border-radius:3px;padding:1px 4px;margin:0 1px;display:inline-block;text-decoration:none;font-size:11px;font-weight:bold;">' + esc(label) + '</a>'; } function toInt(v, fallback) { var m; if (v === undefined || v === null || v === '') { return fallback; } m = String(v).match(/-?\d+/); if (!m) { return fallback; } return parseInt(m[0], 10); } function normalizeText(value) { return String(value || '') .toLowerCase() .replace(/\*\*/g, '') .replace(/[^a-z0-9]+/g, ' ') .replace(/^\s+|\s+$/g, ''); } function tokenById(id) { return getObj('graphic', id); } function charByToken(token) { var cid; if (!token) { return null; } cid = token.get('represents'); if (!cid) { return null; } return getObj('character', cid); } function tokenName(token) { if (!token) { return 'Unknown'; } return token.get('name') || 'Token'; } function getAttr(cid, name) { var a; if (!cid || !name) { return ''; } a = findObjs({ _type: 'attribute', _characterid: cid, name: name }, { caseInsensitive: true })[0]; return a ? (a.get('current') || '') : ''; } function getAttrNumber(cid, names, fallback) { var i, n; for (i = 0; i < names.length; i++) { n = toInt(getAttr(cid, names[i]), NaN); if (!isNaN(n)) { return n; } } return fallback; } function classSlotText(cid, slotIndex) { var names; names = [ 'class' + String(slotIndex + 1), 'class_' + String(slotIndex + 1), 'class' + String(slotIndex + 1) + '_name', 'class_' + String(slotIndex + 1) + '_name' ]; if (slotIndex === 0) { names = names.concat(['class', 'class_name', 'character_class', 'npc_class']); } return normalizeText(firstAttr(cid, names)); } function classSlotLevel(cid, slotIndex) { var names; names = [ 'level' + String(slotIndex + 1), 'level_' + String(slotIndex + 1), 'class' + String(slotIndex + 1) + '_level', 'class_' + String(slotIndex + 1) + '_level' ]; if (slotIndex === 0) { names = names.concat(['level', 'character_level', 'npc_level']); } return getAttrNumber(cid, names, 0); } function firstAttr(cid, names) { var i, v; for (i = 0; i < names.length; i++) { v = getAttr(cid, names[i]); if (String(v || '').replace(/^\s+|\s+$/g, '') !== '') { return v; } } return ''; } function turnClassInfoFromToken(token) { var ch = charByToken(token); var cid = ch ? ch.id : ''; var i, cls, lvl, bestPaladin; if (!cid) { return { canTurn: false, sourceClass: '', classLevel: 0, effectiveLevel: 0, label: 'No linked character sheet' }; } for (i = 0; i < 3; i++) { cls = classSlotText(cid, i); lvl = classSlotLevel(cid, i); if (cls.indexOf('cleric') !== -1 && lvl > 0) { return { canTurn: true, sourceClass: 'Cleric', classLevel: lvl, effectiveLevel: Math.max(1, lvl), label: 'Cleric level ' + Math.max(1, lvl) }; } if (cls.indexOf('paladin') !== -1 && lvl >= 3) { if (!bestPaladin || lvl > bestPaladin.classLevel) { bestPaladin = { canTurn: true, sourceClass: 'Paladin', classLevel: lvl, effectiveLevel: Math.max(1, lvl - 2), label: 'Paladin level ' + lvl + '; turns as Cleric level ' + Math.max(1, lvl - 2) }; } } } if (bestPaladin) { return bestPaladin; } return { canTurn: false, sourceClass: '', classLevel: 0, effectiveLevel: 0, label: 'No Cleric or eligible Paladin level found' }; } function turnUndeadColumnForLevel(level) { level = toInt(level, 1); if (level <= 1) { return 0; } if (level === 2) { return 1; } if (level === 3) { return 2; } if (level === 4) { return 3; } if (level === 5) { return 4; } if (level === 6) { return 5; } if (level === 7) { return 6; } if (level === 8) { return 7; } if (level <= 13) { return 8; } return 9; } function turnUndeadLevelBand(level) { return ['1','2','3','4','5','6','7','8','9-13','14+'][turnUndeadColumnForLevel(level)]; } function turnUndeadRow(key) { var i; key = String(key || 'skeleton').toLowerCase(); for (i = 0; i < TURN_UNDEAD_ROWS.length; i++) { if (TURN_UNDEAD_ROWS[i].key === key) { return TURN_UNDEAD_ROWS[i]; } } return TURN_UNDEAD_ROWS[0]; } function rollDice(count, sides) { var i, total = 0, parts = []; for (i = 0; i < count; i++) { parts[i] = randomInteger(sides); total += parts[i]; } return { total: total, detail: parts.join('+') }; } function rollAffectedCount(rowKey, resultCode) { var d; if (rowKey === 'special') { return { rollLabel: '1d2', count: randomInteger(2), note: '1-2 for Special**' }; } if (String(resultCode || '').indexOf('*') !== -1) { return { rollLabel: '1d6+6', count: randomInteger(6) + 6, note: '7-12 because of *' }; } d = randomInteger(12); return { rollLabel: '1d12', count: d, note: '1-12' }; } function tokenMarkerList(token) { var raw; if (!token) { return []; } raw = String(token.get('statusmarkers') || ''); return raw ? raw.split(',').filter(function (m) { return !!m; }) : []; } function baseMarkerName(marker) { return String(marker || '').split('@')[0].split('::')[0]; } function setStandardMarker(token, markerName, number) { var markers, i, base, value; if (!token || !markerName) { return; } markers = tokenMarkerList(token); markers = markers.filter(function (m) { base = baseMarkerName(m); return base !== markerName; }); value = markerName; if (number !== undefined && number !== null && String(number) !== '') { value += '@' + String(number); } markers.push(value); token.set('statusmarkers', markers.join(',')); } function clearTurnMarkers(token) { var markers; if (!token) { return; } markers = tokenMarkerList(token).filter(function (m) { var base = baseMarkerName(m); return base !== TURNED_MARKER && base !== DESTROYED_MARKER; }); token.set('statusmarkers', markers.join(',')); } function shuffleTokens(tokens) { var arr = (tokens || []).slice(0); var i, j, tmp; for (i = arr.length - 1; i > 0; i--) { j = randomInteger(i + 1) - 1; tmp = arr[i]; arr[i] = arr[j]; arr[j] = tmp; } return arr; } function tokenNameList(tokens) { var out = [], i; for (i = 0; i < (tokens || []).length; i++) { out.push(tokenName(tokens[i])); } return out.length ? out.join(', ') : 'None'; } function pageScaleFeet(page) { var scale; if (!page) { return 5; } scale = parseFloat(page.get('scale_number')); if (isNaN(scale) || scale <= 0) { scale = 5; } return scale; } function pageGridPixels(page) { var snapping; if (!page) { return 70; } snapping = parseFloat(page.get('snapping_increment')); if (isNaN(snapping) || snapping <= 0) { snapping = 1; } return 70 * snapping; } function pixelsToFeet(page, pixels) { return (pixels / pageGridPixels(page)) * pageScaleFeet(page); } function distanceFeet(tokenA, tokenB) { var page, dx, dy; if (!tokenA || !tokenB || tokenA.get('pageid') !== tokenB.get('pageid')) { return 999999; } page = getObj('page', tokenA.get('pageid')); dx = (parseFloat(tokenA.get('left')) || 0) - (parseFloat(tokenB.get('left')) || 0); dy = (parseFloat(tokenA.get('top')) || 0) - (parseFloat(tokenB.get('top')) || 0); return pixelsToFeet(page, Math.sqrt(dx * dx + dy * dy)); } function tokenTypeText(token) { var text = ''; var ch = charByToken(token); var cid = ch ? ch.id : ''; if (token) { text += ' ' + normalizeText(token.get('name')); } if (ch) { text += ' ' + normalizeText(ch.get('name')); } if (cid) { text += ' ' + normalizeText(firstAttr(cid, ['undead_type', 'monster_type', 'creature_type', 'npc_type', 'type', 'race', 'name'])); } return ' ' + text + ' '; } function tokenMatchesUndeadType(token, rowDef) { var text, expected; if (!token || !rowDef) { return false; } if (rowDef.key === 'special') { return true; } text = tokenTypeText(token); expected = normalizeText(rowDef.label); return text.indexOf(' ' + expected + ' ') !== -1 || text.indexOf(' ' + expected + 's ') !== -1; } function tokensInRadius(clericToken, radiusFeet, rowDef) { var all, i, t, out = []; if (!clericToken) { return out; } all = findObjs({ _type: 'graphic', _pageid: clericToken.get('pageid'), layer: 'objects' }) || []; for (i = 0; i < all.length; i++) { t = all[i]; if (!t || t.id === clericToken.id) { continue; } if (distanceFeet(clericToken, t) > radiusFeet) { continue; } if (!tokenMatchesUndeadType(t, rowDef)) { continue; } out.push(t); } return out; } function tokensFromIds(ids, clericTokenId, rowDef) { var out = [], seen = {}, i, id, t; for (i = 0; i < (ids || []).length; i++) { id = String(ids[i] || '').replace(/^\s+|\s+$/g, ''); if (!id || id === clericTokenId || seen[id]) { continue; } t = tokenById(id); if (!t) { continue; } if (!tokenMatchesUndeadType(t, rowDef)) { continue; } seen[id] = true; out.push(t); } return out; } function selectedTargetTokens(msg, clericTokenId, rowDef) { var out = [], seen = {}, i, sel, t; if (!msg || !msg.selected) { return out; } for (i = 0; i < msg.selected.length; i++) { sel = msg.selected[i]; if (!sel || sel._type !== 'graphic' || !sel._id || sel._id === clericTokenId || seen[sel._id]) { continue; } t = tokenById(sel._id); if (!t) { continue; } if (!tokenMatchesUndeadType(t, rowDef)) { continue; } seen[sel._id] = true; out.push(t); } return out; } function playDiceSound() { sendChat(SCRIPT, '!splay diceri'); } function resolveTurn(msg, opts) { var clericToken = tokenById(opts.cleric); var rowDef = turnUndeadRow(opts.type); var levelInfo; var level; var col; var resultCode; var targets = []; var eligibleCount; var roll; var targetNumber; var success = false; var affectedRoll; var affectedCount; var affectedTokens = []; var rounds; var i; var out = ''; if (!clericToken) { sendChat(SCRIPT, '/w gm ' + box(title('Turn Undead Error') + row(bad('Cleric token not found.')))); return; } levelInfo = turnClassInfoFromToken(clericToken); level = toInt(opts.level, 0); if (level <= 0) { level = levelInfo.effectiveLevel || 1; } col = turnUndeadColumnForLevel(level); resultCode = rowDef.values[col]; if (opts.targets && opts.targets.length) { targets = tokensFromIds(opts.targets, clericToken.id, rowDef); } else { targets = selectedTargetTokens(msg, clericToken.id, rowDef); } if (!targets.length && opts.radius > 0) { targets = tokensInRadius(clericToken, opts.radius, rowDef); } eligibleCount = targets.length; playDiceSound(); out += title(tokenName(clericToken) + ' - Turn Undead'); out += row('<b>Undead Type:</b> ' + pill(rowDef.label)); out += row('<b>Effective Cleric Level:</b> ' + pill(String(level)) + ' <span style="color:#ddd;">matrix band ' + esc(turnUndeadLevelBand(level)) + '</span>'); out += row('<b>Matrix Result:</b> ' + pill(resultCode)); if (opts.radius > 0 && (!opts.targets || !opts.targets.length)) { out += row('<b>Radius:</b> ' + pill(String(opts.radius) + ' ft')); } out += row('<b>Eligible Targets:</b> ' + pill(String(eligibleCount)) + ' <span style="color:#ddd;">' + esc(tokenNameList(targets)) + '</span>'); if (!eligibleCount) { out += row(bad('No eligible matching undead tokens found. Token or character name/attrs must include the chosen undead type, or use Special**.')); sendChat(SCRIPT, box(out)); return; } if (resultCode === '--') { out += row(bad('No effect is possible against this undead type at this level.')); sendChat(SCRIPT, box(out)); return; } if (resultCode === 'T') { success = true; out += row(good('Automatic turning.')); } else if (resultCode.indexOf('D') === 0) { success = true; out += row(good('Automatic destroyed / damned result.')); } else { targetNumber = toInt(resultCode, 20); roll = randomInteger(20); success = roll >= targetNumber; out += row('<b>Turn Roll:</b> d20 ' + diePill(String(roll)) + ' vs target ' + pill(String(targetNumber)) + ' or greater'); out += row(success ? good('SUCCESS') : bad('FAILURE')); } if (success) { affectedRoll = rollAffectedCount(rowDef.key, resultCode); affectedCount = Math.min(affectedRoll.count, eligibleCount); affectedTokens = shuffleTokens(targets).slice(0, affectedCount); out += row('<b>Number Affected Roll:</b> ' + diePill(affectedRoll.rollLabel + ' = ' + affectedRoll.count) + ' <span style="color:#ddd;">' + esc(affectedRoll.note) + '</span>'); if (affectedRoll.count > eligibleCount) { out += row(neutral('Only ' + eligibleCount + ' target(s) were available, so only ' + affectedCount + ' are affected.')); } if (resultCode.indexOf('D') === 0) { for (i = 0; i < affectedTokens.length; i++) { clearTurnMarkers(affectedTokens[i]); setStandardMarker(affectedTokens[i], DESTROYED_MARKER, ''); } out += row('<b>Destroyed / Damned:</b> ' + good(tokenNameList(affectedTokens))); out += row(neutral('Applied standard Roll20 dead marker.')); } else { rounds = rollDice(3, 4); for (i = 0; i < affectedTokens.length; i++) { clearTurnMarkers(affectedTokens[i]); setStandardMarker(affectedTokens[i], TURNED_MARKER, rounds.total); } out += row('<b>Turned:</b> ' + good(tokenNameList(affectedTokens))); out += row('<b>Rounds Turned:</b> ' + diePill('3d4 = ' + rounds.detail) + ' = ' + pill(String(rounds.total))); out += row(neutral('Applied standard Roll20 screaming marker with round number.')); } } sendChat(SCRIPT, box(out)); } function parseArgs(content) { var opts = { cleric: '', type: 'skeleton', level: 0, radius: 60, targets: [] }; var parts = content.split(/\s+--/); var i, p, k, v; for (i = 1; i < parts.length; i++) { p = parts[i]; k = p.split(/\s+/)[0]; v = p.substring(k.length).replace(/^\s+|\s+$/g, ''); if (k === 'cleric') { opts.cleric = v; } if (k === 'type') { opts.type = v; } if (k === 'level') { opts.level = toInt(v, 0); } if (k === 'radius') { opts.radius = toInt(v, 60); } if (k === 'targets') { opts.targets = v.split(/[|,;\s]+/).filter(function (x) { return !!x; }); } } return opts; } function showMenu(msg, clericTokenId) { var token = tokenById(clericTokenId); var info = turnClassInfoFromToken(token); var level = info.effectiveLevel || 1; var out = ''; var i, rd, cmd; if (!token) { sendChat(SCRIPT, '/w gm ' + box(title('Turn Undead Menu') + row(bad('Select a cleric token before using the menu macro.')))); return; } out += title(tokenName(token) + ' - Turn Undead Menu'); out += row('<b>Auto Level:</b> ' + pill(String(level)) + ' <span style="color:#ddd;">' + esc(info.label) + '</span>'); out += row(neutral('Buttons scan a 60 ft radius for matching undead type tokens and use standard Roll20 markers only.')); out += hr(); for (i = 0; i < TURN_UNDEAD_ROWS.length; i++) { rd = TURN_UNDEAD_ROWS[i]; cmd = '!tu1e --cleric ' + clericTokenId + ' --level ' + level + ' --type ' + rd.key + ' --radius 60'; out += btn(rd.label + ' ' + rd.values[turnUndeadColumnForLevel(level)], cmd, 'Turn ' + rd.label + ' within 60 ft.'); if (i === 5 || i === 10) { out += '<br>'; } } sendChat(SCRIPT, '/w gm ' + box(out)); } function showHelp() { var out = ''; out += title('TurnUndead1E Help'); out += row('<b>Menu macro:</b>'); out += row('<code>!tu1e-menu --cleric @{selected|token_id}</code>'); out += hr(); out += row('<b>Direct radius macro:</b>'); out += row('<code>!tu1e --cleric @{selected|token_id} --level ?{Effective Cleric Level|1} --type ?{Undead Type|Skeleton,skeleton|Zombie,zombie|Ghoul,ghoul|Shadow,shadow|Wight,wight|Ghast,ghast|Wraith,wraith|Mummy,mummy|Spectre,spectre|Vampire,vampire|Ghost,ghost|Lich,lich|Special**,special} --radius ?{Radius feet|60}</code>'); out += hr(); out += row('<b>Markers:</b> Turned = standard screaming marker with round number. Destroyed = standard dead marker.'); sendChat(SCRIPT, '/w gm ' + box(out)); } on('chat:message', function (msg) { var opts; if (!msg || msg.type !== 'api') { return; } if (msg.content === '!tu1e-help' || msg.content === '!turnundead1e-help') { showHelp(); return; } if (msg.content.indexOf('!tu1e-menu') === 0 || msg.content.indexOf('!turnundead1e-menu') === 0) { opts = parseArgs(msg.content); showMenu(msg, opts.cleric); return; } if (msg.content.indexOf('!tu1e ') === 0 || msg.content.indexOf('!turnundead1e ') === 0) { opts = parseArgs(msg.content); resolveTurn(msg, opts); return; } }); log(SCRIPT + ' v' + VERSION + ' ready. Commands: !tu1e-help, !tu1e-menu, !tu1e'); });