This is a short script for dual sheet games using both the 2014 and 2024 sheets for D&D 5e by Roll20. It is primarily intended to help users who have a large number of established spell casting PCs or NPCs using the 2014 sheet, who still have 2014 spells, but who want to consistently use 2024 spell descriptions. This solves the problem caused by 2014 sheet characters being able to build 2024 characters from the Compendium, but not being able to use or accept the updated spell descriptions. If you cast a spell from a 2014 sheet, the script will whisper a button into chat that links to the counterpart spell from the 2024 compendium. The spell is pre-set to use the Free Basic Rules, but if you have the PHB, you can comment out the FBR line and uncomment the PHB line. These are clearly marked in the code. If you try to run this with the PHB code, but do not own the PHB, the link will only go to the PHB unlock page. If you cast a spell for which there is no 2024 counterpart in the PHB or FBR, the link will harmlessly display a Page Not Found error. This script does not actually cast the spell, and is intended as a reference only. on("chat:message", (msg) => { try { if (!msg) return; const c = msg.content || ""; const template = msg.rolltemplate; if (!template) return; // Detection: spelldesc_link OR explicit spell template OR spelllevel const hasSpellDescLink = c.includes("{{spelldesc_link="); const isSpellTemplate = template === "spell"; const hasSpellLevel = /{{spelllevel=/i.test(c); const isSpell = hasSpellDescLink || isSpellTemplate || hasSpellLevel; if (!isSpell) return; // Extract raw spell name from {{name=...}} or {{rname=...}} const nameMatch = c.match(/{{name=([^}]+)}}/i); const rnameMatch = c.match(/{{rname=([^}]+)}}/i); let rawName = (nameMatch?.[1] || rnameMatch?.[1] || "").trim(); if (!rawName) return; // CLEAN the name const mdLinkMatch = rawName.match(/^\s*\[([^\]]+)\]\([^)]+\)\s*$/); if (mdLinkMatch) { rawName = mdLinkMatch[1]; } rawName = rawName.replace(/<[^>]*>/g, ""); // strip HTML rawName = rawName.replace(/["']/g, ""); // strip quotes const spellName = rawName.trim(); if (!spellName) return; // Build URL const urlName = encodeURIComponent(spellName); //########################################################################## // COMMENT OUT ONE OF THE FOLLOWING LINES, DEPENDING ON THE SOURCE YOU OWN //########################################################################## const url = `<a href="https://roll20.net/compendium/dnd5e/Spells:${urlName}?expansion=33335`" rel="nofollow">https://roll20.net/compendium/dnd5e/Spells:${urlName}?expansion=33335`</a>; // FREE BASIC RULES //const url = `<a href="https://roll20.net/compendium/dnd5e/Spells:${urlName}?expansion=32231`" rel="nofollow">https://roll20.net/compendium/dnd5e/Spells:${urlName}?expansion=32231`</a>; // PHB 2024, IF OWNED // HTML-escape display text const escapeHtml = (s) => s .replace(/&/g, "&amp;") .replace(/</g, "&lt;") .replace(/>/g, "&gt;") .replace(/"/g, "&quot;") .replace(/'/g, "&#39;"); const display = escapeHtml(spellName); // Build styled button const button = `<a href="${url}" target="_blank" rel="noopener" style="modesto-poster, sans-serif; position:relative; left:-5px; top:-30px; margin-bottom:-32px; border:1px solid #444; border-radius:4px; text-align:center; font-weight:bold; background-color:#000; color:#ddd; display:block; text-decoration:none; padding:3px 6px;"> <span style="color:#c7a16b">2024: </span>${display} </a>`; // Whisper target normalization let target = msg.who || ""; if (target.endsWith(" (GM)")) { target = "gm"; } //sendChat("", `/w "${target}" ${button}`); sendChat("", `/w gm ${button}`); } catch (err) { log("SpellLinker ERROR: " + err); } });