I hope I am not stepping on anyones toes by posting this here. This is a Mod script derived from and a replacement for EasyExperience that will collect expierence points from killed monsters and distribute them to the players. The script handles both 2014 and 2024 sheets, for NPCs and PCs alike. Key behaviors as they landed: HP for every NPC (2014 or 2024) comes from the token's own Bar 1 value first, since that's what actually stays current at my table, with the 2014 sheet's hp attribute only as a fallback. !exp award posts results to public chat so players see their own XP, and it never touches the configured party list — that's a standing roster you only need to set once with !exp config . The "still alive" report in !exp collect is now just a clean HP readout, no diagnostic clutter. It speaks in chat as "XP Collector" and the persistent data still lives under state.XP2024Pool . Commands: !exp config (/add/remove), !exp collect , !exp custom <amt> , !exp pool , !exp award , !exp clearparty , !exp help . /* ============================================================ D&D 5e XP COLLECTOR SYSTEM Roll20 Mod/API Script VERSION: 7.0 Code written by Claude, as directed by John Edsall. Supports: - D&D 5e 2024 NPCs - D&D 5e 2014 NPCs - D&D 5e 2024 PCs - D&D 5e 2014 PCs Commands: !exp config Replace the whole party with the selected PCs !exp config add Add the selected PCs to the party !exp config remove Remove the selected PCs from the party !exp collect Collect XP from selected dead NPCs !exp custom <amt> Manually add (or, with a negative number, remove) XP from the pool !exp pool Show the current XP pool !exp award Distribute the entire XP pool. Unlike every other command, results are posted to PUBLIC chat (not whispered), so players can see their own XP awards. !exp clearparty Clear the configured party !exp help List these commands in chat Collection rules: - Token must represent an NPC/monster with an XP value. - NPC must have HP <= 0. - Pink marker is the collection flag. - Collecting XP adds the Pink marker. - Running !exp collect again on a Pink-marked token does nothing. - Removing the Pink marker makes the token collectible again. - XP is pooled until !exp award is used. - After successful distribution, only the pool total is reset to 0. The configured party is NEVER cleared/modified by !exp award — it's a standing roster, kept for next time. - Every command whispers its results to the GM only, EXCEPT !exp award, which posts to public chat. 2024 NPC: XP = getComputed(characterId, "npc_xp") HP = token bar1_value (per-token; does NOT need to be linked to the sheet — see get2024HP's comments) 2014 NPC: XP = npc_xp attribute HP = token bar1_value, falling back to the hp attribute only if Bar 1 itself is unusable (empty/non-numeric). Bar 1 is checked FIRST for 2014 NPCs too — live testing showed the hp attribute going stale/unused while Bar 1 stayed accurate (see get2014HP's comments) 2024 PC: XP = getComputed(characterId, "experience") to read, setComputed(characterId, "experience", args:[value]) to write. 2014 PC: XP = experience attribute ------------------------------------------------------------ CHANGELOG (condensed — the project's conversation history has the full blow-by-blow debugging trail if it's ever needed): v4.1 distributeXP no longer treats a failed PC XP read as a 0 baseline; a partial failure leaves an accurate "still owed" pool/party for a safe retry instead of risking a double-pay or a silently wrong total. v5.0 THE ACTUAL FIX for "PCs never get XP": setComputed's "args" must be an array (args: [value]), not an object (args: {value: value}) — the object form silently threw with no try/catch around it, aborting distribution with no visible error. Fixed, and wrapped in try/catch so a future write failure is reported instead of going silent. v5.1 !exp party renamed to !exp config. v5.2 State key renamed state.XP2024Pool -> state.ClaudeXPPool (reverted in v6.6) as a defensive guess against a same-named-key collision with one of this campaign's ~40 other installed scripts. Added boot-time state logging, real party member names (not just a count) in !exp config/!exp pool, and !exp help. v5.3 Added !exp custom <amount> for manual pool adjustments. v5.4 Added !exp config add / !exp config remove. v5.5-v6.3 Debugging trail for "!exp collect can't determine HP" and "still alive" misreads on both sheet versions. Landed on: current HP for BOTH 2014 and 2024 NPCs is read from the token's own Bar 1 first — per-token, so it's correct whether or not Bar 1 is linked to the sheet, which matters for a "template" monster character shared by many tokens — falling back to the 2014 sheet's 'hp' attribute only when Bar 1 itself is unusable. Diagnostic messages show the raw Bar 1 / attribute values side by side so a mismatch is visible instead of guessed at. v6.2 Traced "party disappears between sessions" to a Roll20 save-timing gap (state isn't written to persistent storage the instant a script changes it) rather than a script bug or a key collision — nothing to fix in code, since there's no API for a script to force a save. v6.4 !exp award posts its results to PUBLIC chat instead of whispering the GM, so players see their own XP awards. Every other command stays GM-only. v6.5 !exp award no longer touches the party list at all — it only resets the pool total. The party is now a standing roster that persists across as many collect/award cycles as wanted, instead of needing !exp config every time. Trade-off: retrying !exp award after a partial failure can give already-paid members an extra share of what's left (see distributeXP's header comment for why). v6.6 State key reverted to state.XP2024Pool — the v5.2 collision concern was ruled out by v6.2's finding. v6.7 Removed two chat messages at the GM's request: the "party remains configured for next time" sentence and the Roll20 save-timing tip (added in v6.2). v6.8 Code cleanup pass: deduplicated the repeated 'hp' attribute lookup and Bar 1 parsing logic into shared helpers (XPSystem.readBar1HP, XPSystem.find2014HPAttr), and condensed this changelog from a much longer version-by-version essay. No behavior changes. v6.9 Removed the "(source: token Bar 1 ...)" diagnostic text from the "Still alive" list in !exp collect — knowing the monster is still alive is enough, no need to explain where that HP number came from. Added an authorship line to the header. v7.0 Renamed to D&D 5e XP Collector System (was "XP Pool System") ahead of the first real-game beta test. Purely a naming change — the title/help text and the "XP Collector" chat name it speaks under — no behavior change. state.XP2024Pool keeps its name. ------------------------------------------------------------ */ var XPSystem = XPSystem || {}; XPSystem.VERSION = "7.0"; /* ========================================================= INITIALIZATION ========================================================= */ on("ready", function () { /* * Do NOT reset state here. * * The XP pool should survive API sandbox restarts and * new game sessions — Roll20's "state" object is saved * with the campaign itself, not per-session. * * MIGRATION: this was briefly renamed to state.ClaudeXPPool * (v5.2-v6.5) out of caution that the more generic * state.XP2024Pool name could collide with another of this * campaign's ~40 installed scripts touching "state" broadly. * At the GM's request it's back to state.XP2024Pool. If data * is still sitting under the old ClaudeXPPool key, migrate * it once rather than losing it. */ if ( !state.XP2024Pool && state.ClaudeXPPool ) { state.XP2024Pool = state.ClaudeXPPool; delete state.ClaudeXPPool; log( "D&D XP Collector System: migrated old state.ClaudeXPPool " + "-> state.XP2024Pool." ); } if (!state.XP2024Pool) { state.XP2024Pool = { total: 0, party: [] }; } /* * Log the pool/party contents on every boot. If the party * ever appears to "disappear" between sessions again, this * line in the Mod Script Output Console tells us for * certain whether state itself came back empty (a real * persistence/collision problem) or came back fine and * something else is the issue (e.g. stale character IDs * from re-imported characters no longer resolving). */ log( "D&D XP Collector System v" + XPSystem.VERSION + " ready. Pool: " + (state.XP2024Pool.total || 0) + " XP, party: " + (state.XP2024Pool.party || []).length + " character(s) [" + (state.XP2024Pool.party || []).join(", ") + "]." ); }); /* ========================================================= GENERAL HELPERS ========================================================= */ XPSystem.whisperGM = function (message) { sendChat( "XP Collector", "/w gm " + message ); }; /* * Sends a message to the PUBLIC chat, visible to everyone at * the table — unlike whisperGM, which only the GM sees. Used * for !exp award's results, since players generally want to see * how much XP they just received. */ XPSystem.sendPublic = function (message) { sendChat( "XP Collector", message ); }; XPSystem.getSelectedTokens = function (msg) { if (!msg.selected || !msg.selected.length) { return []; } return msg.selected .filter(function (selection) { return selection._type === "graphic"; }) .map(function (selection) { return getObj( "graphic", selection._id ); }) .filter(function (token) { return token; }); }; XPSystem.getCharacterId = function (token) { return token.get("represents"); }; /* ========================================================= PARTY NAME RESOLUTION ========================================================= Resolves stored character IDs to display names, and separates out any that no longer resolve to a real character. A stale ID (character deleted, or replaced by a re-import that creates a new character object with a new ID) is the most likely way a configured party can LOOK like it "disappeared" even though state.XP2024Pool itself came back from persistence just fine. ========================================================= */ XPSystem.getPartyNames = function (party) { var names = []; var staleIds = []; (party || []).forEach(function (characterId) { var character = getObj( "character", characterId ); if (character) { names.push( character.get("name") || "(unnamed character)" ); } else { staleIds.push(characterId); } }); return { names: names, staleIds: staleIds }; }; /* ========================================================= SHEET TYPE ========================================================= */ XPSystem.is2024 = function (character) { if (!character) { return false; } var sheetName = character.get("charactersheetname") || ""; return sheetName .toLowerCase() .indexOf("2024") !== -1; }; /* ========================================================= SHARED HP HELPERS ========================================================= Small building blocks used by get2014HP, get2024HP, and the !exp collect diagnostics — pulled out here so the "how do we read Bar 1" and "where's the 2014 hp attribute" logic each exist in exactly one place. ========================================================= */ /* * Reads a token's Bar 1 as a current-HP number. Returns null if * there's no token, or Bar 1's current value is empty/blank/ * non-numeric. This is per-token and works whether or not Bar 1 * is linked to the character sheet. */ XPSystem.readBar1HP = function (token) { if (!token) { return null; } var value = token.get("bar1_value"); if ( value === null || value === undefined || value === "" ) { return null; } var hp = parseFloat(value); return isNaN(hp) ? null : hp; }; /* * Finds the 2014 sheet's 'hp' attribute for a character, or * returns undefined if there isn't one. Used both to actually * read HP (get2014HP) and to explain diagnostics (collectXP). */ XPSystem.find2014HPAttr = function (characterId) { return findObjs({ _type: "attribute", characterid: characterId, name: "hp" })[0]; }; /* ========================================================= 2014 NPC XP ========================================================= */ XPSystem.get2014NPCXP = function (characterId) { var attr = findObjs({ _type: "attribute", characterid: characterId, name: "npc_xp" })[0]; if (!attr) { return null; } var xp = parseInt( attr.get("current"), 10 ); return isNaN(xp) ? null : xp; }; /* ========================================================= 2014 NPC HP ========================================================= */ XPSystem.get2014HP = function (characterId, token) { /* * Bar 1 is checked FIRST now, ahead of the 'hp' attribute — * this priority was flipped after live testing on two * different NPCs both pointed the same way: * * - Goblin tokens sharing one character had no 'hp' * attribute at all, but Bar 1 correctly showed 0 once * killed. * * - Hilmer (a unique, non-shared NPC) was reduced to 0 HP * in play — his token's Bar 1 correctly read "0" — but * his character's 'hp' attribute was still stuck at 58, * his starting HP. Something in this game applies damage * to the TOKEN (a damage-application tool, or the GM * dragging the bar directly) without writing the result * back to the character sheet's 'hp' attribute. * * Conclusion: for this table, Bar 1 is the value that's * actually kept current during play, and the sheet's 'hp' * attribute can't be trusted as primary — it may be blank, * missing, or simply stale. Bar 1 is tried first, and the * 'hp' attribute is only a fallback for a token where Bar 1 * itself was never set. */ var barHP = XPSystem.readBar1HP(token); if (barHP !== null) { return barHP; } /* * Bar 1 wasn't usable (token missing, or its Bar 1 is empty/ * non-numeric) — fall back to the 'hp' attribute, if any. */ var attr = XPSystem.find2014HPAttr(characterId); if (!attr) { return null; } var hp = parseFloat( attr.get("current") ); return isNaN(hp) ? null : hp; }; /* ========================================================= 2024 NPC XP ========================================================= */ XPSystem.get2024NPCXP = async function (characterId) { try { var value = await getComputed({ characterId: characterId, property: "npc_xp" }); var xp = parseInt( value, 10 ); return isNaN(xp) ? null : xp; } catch (error) { log( "2024 NPC XP error: " + error ); return null; } }; /* ========================================================= 2024 NPC HP ========================================================= IMPORTANT: The 2024 Roll20 sheet's hp_current computed property was found by diagnostic testing to return the HP maximum rather than the token's current HP. The diagnostic showed: Bar 1 Value: 94 Bar 1 Max: 94 Bar 1 Link: hp Therefore the token's Bar 1 value is used as the authoritative current HP for 2024 NPCs. This reads the HP actually displayed on the token, which is also correct for "template" monster characters shared by many tokens (e.g. several Goblin tokens all pointing at one Goblin character): Bar 1 is per-token, so it works whether or not it's linked to the sheet. Many tables intentionally leave it UNLINKED in that setup, so that damaging one token's Bar 1 doesn't change the shared sheet and affect every other token of that creature — that is expected, not a misconfiguration. ========================================================= */ XPSystem.get2024HP = function (token) { return XPSystem.readBar1HP(token); }; /* ========================================================= PC XP - READ ========================================================= Returns: - a number (including 0) for a successful read, where 0 is a genuine "no XP tracked yet" result (2014 PC with no experience attribute). - null specifically when a 2024 PC's computed XP read THROWS. This is distinct from 0 on purpose: callers must NOT treat a failed read as "starts at 0", since that would silently overwrite the PC's real total with just their pool share. ========================================================= */ XPSystem.getPCXP = async function ( characterId, sheet2024 ) { /* * 2024 PC */ if (sheet2024) { try { var value = await getComputed({ characterId: characterId, property: "experience" }); var xp = parseInt( value, 10 ); /* * A non-numeric computed value is treated as * a genuine read failure, not a 0 XP total. */ return isNaN(xp) ? null : xp; } catch (error) { log( "2024 PC XP error: " + error ); return null; } } /* * 2014 PC * * No attribute at all is a legitimate "never tracked * XP" case, so 0 is the correct default here. */ var attr = findObjs({ _type: "attribute", characterid: characterId, name: "experience" })[0]; if (!attr) { return 0; } var legacyXP = parseInt( attr.get("current"), 10 ); return isNaN(legacyXP) ? 0 : legacyXP; }; /* ========================================================= PC XP - WRITE ========================================================= */ XPSystem.setPCXP = async function ( characterId, sheet2024, value ) { /* * 2024 PC * * IMPORTANT: setComputed's "args" must be an ARRAY of * positional arguments (args: [value]), NOT an object * (args: {value: value}). The object form throws inside * setComputed — confirmed via live testing on a real * character (see v5.0 patch notes above). The try/catch * here turns that kind of failure into a normal thrown * Error with a clear message, instead of an opaque * sandbox-internal exception, so distributeXP's own * try/catch around this call can report it through the * usual "distribution stopped" whisper rather than the * whole thing going silent. */ if (sheet2024) { try { await setComputed({ characterId: characterId, property: "experience", args: [value] }); } catch (error) { throw new Error( "setComputed(experience) failed for character " + characterId + ": " + error ); } return; } /* * 2014 PC */ var attr = findObjs({ _type: "attribute", characterid: characterId, name: "experience" })[0]; if (attr) { attr.set( "current", value ); } else { createObj("attribute", { characterid: characterId, name: "experience", current: value }); } }; /* ========================================================= PINK MARKER / COLLECTION FLAG ========================================================= Pink is intentionally the authoritative collection flag. If the GM removes the Pink marker, the token becomes eligible for XP collection again. ========================================================= */ XPSystem.wasCollected = function (token) { return token.get("status_pink") === true; }; XPSystem.markCollected = function (token) { token.set( "status_pink", true ); }; /* ========================================================= COLLECT XP ========================================================= */ XPSystem.collectXP = async function (msg) { var tokens = XPSystem.getSelectedTokens(msg); if (!tokens.length) { XPSystem.whisperGM( "<b>No tokens selected.</b><br><br>" + "Select one or more dead monster/NPC tokens " + "and use <b>!exp collect</b>." ); return; } var collected = []; var alreadyCollected = []; var stillAlive = []; var notNPC = []; var noHP = []; var noXP = []; var totalCollected = 0; /* * Process every selected token independently. */ for (var i = 0; i < tokens.length; i++) { var token = tokens[i]; var characterId = XPSystem.getCharacterId(token); /* * Token must represent a character. */ if (!characterId) { notNPC.push( token.get("name") || "Unnamed token" ); continue; } var character = getObj( "character", characterId ); if (!character) { notNPC.push( token.get("name") || "Unnamed token" ); continue; } var characterName = character.get("name") || token.get("name") || "Unnamed"; /* * Pink marker means XP has already been * collected from this token. */ if (XPSystem.wasCollected(token)) { alreadyCollected.push( characterName ); continue; } var sheet2024 = XPSystem.is2024(character); var xp; var hp; /* * Read XP and HP according to sheet version. */ if (sheet2024) { /* * 2024 NPC XP comes from the character sheet. */ xp = await XPSystem.get2024NPCXP( characterId ); /* * 2024 NPC CURRENT HP comes from * TOKEN BAR 1, NOT hp_current. */ hp = XPSystem.get2024HP( token ); } else { /* * 2014 NPCs continue to use attributes. */ xp = XPSystem.get2014NPCXP( characterId ); hp = XPSystem.get2014HP( characterId, token ); } /* * NPC XP is required. * * PCs normally have no numeric npc_xp value, * so this also prevents accidental XP collection * from PC tokens. */ if ( xp === null || xp <= 0 ) { noXP.push( characterName ); continue; } /* * HP is required so we can determine whether * the creature is actually dead. * * Include WHY it couldn't be determined, since this is * almost always a per-TOKEN configuration issue rather * than a script bug. For BOTH sheet versions, current * HP ultimately comes from the token's own Bar 1 value * whenever the sheet-side source is unavailable (2024: * always; 2014: only when the 'hp' attribute row doesn't * exist yet — see get2014HP's comments). So this now * only fires when Bar 1 itself is also empty, which just * means nobody has ever set a value into it on this * particular token — the character sheet and XP are * otherwise fine. This is why the same monster can work * on one token and fail on another copy of "the same" * creature. * * NOTE: Bar 1 being UNLINKED from the sheet is normal * and expected, not the problem. Many tables (this one * included) reuse a single "Goblin" character across * many tokens, so Bar 1 is deliberately left unlinked — * each token's Bar 1 tracks that individual token's HP, * while the shared character sheet is not touched by * damage to any one copy. Linking Bar 1 to the sheet * would make every token of that creature share one HP * total, which is the opposite of what's wanted here. * The fix for an empty Bar 1 is simply to set a value in * it on that token, never to link it. */ if (hp === null) { /* * Dump the raw value of all three token bars, plus * the 2014 'hp' attribute if one exists. * * "Bar 1" in this script always means the API's * bar1_value/bar1_max — but what a GM sees as the * "top" or "first" bar on the token can actually be * a different bar depending on this game's token * marker/bar display settings, and a bar can also * have its Max filled in with Current left blank * (which looks fine on the token but reads as empty * here). Showing all three raw values, and the raw * 'hp' attribute value when one exists, makes it * obvious at a glance exactly why this token was * skipped instead of guessing. */ var barDiag = "Bar1: " + JSON.stringify(token.get("bar1_value")) + "/" + JSON.stringify(token.get("bar1_max")) + ", Bar2: " + JSON.stringify(token.get("bar2_value")) + "/" + JSON.stringify(token.get("bar2_max")) + ", Bar3: " + JSON.stringify(token.get("bar3_value")) + "/" + JSON.stringify(token.get("bar3_max")); var hpReason; if (sheet2024) { hpReason = "Bar 1's current value is empty — 2024 NPC " + "HP is read from it directly; set it on this " + "token. It does NOT need to be linked to the " + "sheet — for a token sharing its sheet with " + "other copies of the same creature it should " + "stay unlinked."; } else { /* * Bar 1 is checked FIRST for 2014 NPCs (see * get2014HP's comments) — the 'hp' attribute is * only a fallback. So reaching here means Bar 1 * itself was empty/non-numeric AND the fallback * also failed. */ var hpAttr = XPSystem.find2014HPAttr(characterId); barDiag += ", hp attribute: " + ( hpAttr ? JSON.stringify(hpAttr.get("current")) : "(none)" ); hpReason = "this token's Bar 1 current value is empty, " + "and " + ( hpAttr ? "the fallback 'hp' attribute exists but " + "isn't a usable number either (see below)" : "there's no 'hp' attribute to fall back to" ) + " — set Bar 1's current value on this token " + "to its HP (no linking needed)."; } noHP.push( characterName + " (" + hpReason + ")" + "<br>  <i>(" + barDiag + ")</i>" ); continue; } /* * Creature is still alive — no XP to collect. Just report * its current HP; no need to explain where that number * came from. */ if (hp > 0) { stillAlive.push( characterName + " (" + hp + " HP)" ); continue; } /* * DEAD: * * Add XP to the pool. */ state.XP2024Pool.total += xp; totalCollected += xp; /* * Mark this token Pink so XP cannot be * collected from it again unless the GM * removes the Pink marker. */ XPSystem.markCollected(token); collected.push( characterName + " — " + xp.toLocaleString() + " XP" ); } /* ===================================================== RESULT MESSAGE ===================================================== */ var output = "<b>XP Collection</b><br><br>"; if (collected.length) { output += "<b>Collected:</b><br>"; collected.forEach( function (entry) { output += "☠ " + entry + "<br>"; } ); output += "<br><b>Added to pool: " + totalCollected.toLocaleString() + " XP</b><br><br>"; } if (alreadyCollected.length) { output += "<b>Already collected:</b><br>"; alreadyCollected.forEach( function (entry) { output += "• " + entry + "<br>"; } ); output += "<br>"; } if (stillAlive.length) { output += "<b>Still alive — no XP:</b><br>"; stillAlive.forEach( function (entry) { output += "• " + entry + "<br>"; } ); output += "<br>"; } if (notNPC.length) { output += "<b>Skipped — not an NPC:</b><br>"; notNPC.forEach( function (entry) { output += "• " + entry + "<br>"; } ); output += "<br>"; } if (noXP.length) { output += "<b>Skipped — no NPC XP value:</b><br>"; noXP.forEach( function (entry) { output += "• " + entry + "<br>"; } ); output += "<br>"; } if (noHP.length) { output += "<b>Skipped — HP could not be determined:</b><br>"; noHP.forEach( function (entry) { output += "• " + entry + "<br>"; } ); output += "<br>"; } output += "<b>Current XP Pool: " + state.XP2024Pool.total.toLocaleString() + " XP</b>"; XPSystem.whisperGM(output); }; /* ========================================================= CONFIGURE PARTY ========================================================= */ /* * mode is one of: * "replace" (default) — the selected characters BECOME the * whole party, replacing whoever was * configured before. * "add" — the selected characters are added * to the existing party, keeping * everyone already configured. * "remove" — the selected characters are removed * from the existing party, leaving * everyone else configured. */ XPSystem.configureParty = function ( msg, mode ) { mode = mode || "replace"; var tokens = XPSystem.getSelectedTokens(msg); if (!tokens.length) { var usageByMode = { replace: "Select all PCs who should receive XP and " + "use <b>!exp config</b>.", add: "Select the PC(s) to add and use " + "<b>!exp config add</b>.", remove: "Select the PC(s) to remove and use " + "<b>!exp config remove</b>." }; XPSystem.whisperGM( "<b>No tokens selected.</b><br><br>" + usageByMode[mode] ); return; } /* * Resolve selected tokens to a de-duplicated list of * character IDs, same as before. */ var selectedIds = []; tokens.forEach( function (token) { var characterId = token.get("represents"); if (!characterId) { return; } var character = getObj( "character", characterId ); if (!character) { return; } if ( selectedIds.indexOf(characterId) === -1 ) { selectedIds.push(characterId); } } ); if (!selectedIds.length) { XPSystem.whisperGM( "<b>No valid characters selected.</b>" ); return; } var existingParty = state.XP2024Pool.party || []; var party; var changedNames = []; var unchangedCount = 0; if (mode === "add") { party = existingParty.slice(); selectedIds.forEach(function (characterId) { if (party.indexOf(characterId) === -1) { party.push(characterId); var character = getObj("character", characterId); changedNames.push( character ? character.get("name") : "(unnamed character)" ); } else { unchangedCount++; } }); } else if (mode === "remove") { party = existingParty.filter(function (characterId) { return selectedIds.indexOf(characterId) === -1; }); selectedIds.forEach(function (characterId) { if (existingParty.indexOf(characterId) !== -1) { var character = getObj("character", characterId); changedNames.push( character ? character.get("name") : "(unnamed character)" ); } else { unchangedCount++; } }); } else { party = selectedIds; } state.XP2024Pool.party = party; var resolved = XPSystem.getPartyNames(party); var output; if (mode === "add") { output = "<b>Added to XP Party:</b><br>"; if (changedNames.length) { changedNames.forEach(function (name) { output += "+ " + name + "<br>"; }); } else { output += "<i>(nobody new — already in the party)</i><br>"; } if (unchangedCount) { output += "<i>" + unchangedCount + " selected character(s) were already in the " + "party.</i><br>"; } } else if (mode === "remove") { output = "<b>Removed from XP Party:</b><br>"; if (changedNames.length) { changedNames.forEach(function (name) { output += "− " + name + "<br>"; }); } else { output += "<i>(nobody removed — none of the selected " + "characters were in the party)</i><br>"; } if (unchangedCount) { output += "<i>" + unchangedCount + " selected character(s) weren't in the party.</i><br>"; } } else { output = "<b>XP Party Configured</b><br><br>" + party.length + " character(s) will receive pooled XP:<br>"; } output += "<br><b>Current party (" + party.length + "):</b><br>"; if (resolved.names.length) { resolved.names.forEach(function (name) { output += "• " + name + "<br>"; }); } else { output += "<i>(empty)</i><br>"; } XPSystem.whisperGM(output); }; /* ========================================================= SHOW XP POOL ========================================================= */ XPSystem.showPool = function () { var pool = state.XP2024Pool.total || 0; var party = state.XP2024Pool.party || []; var output = "<b>Current XP Pool</b><br><br>" + "Total: <b>" + pool.toLocaleString() + " XP</b><br><br>"; if (!party.length) { output += "<i>No party configured.</i><br><br>" + "Select the PCs and use <b>!exp config</b>."; XPSystem.whisperGM(output); return; } /* * Show the exact mathematical split. */ var baseShare = Math.floor( pool / party.length ); var remainder = pool % party.length; var resolved = XPSystem.getPartyNames(party); output += "Party members: <b>" + party.length + "</b><br>"; resolved.names.forEach(function (name) { output += "• " + name + "<br>"; }); if (resolved.staleIds.length) { output += "<br><b style='color:red'>" + resolved.staleIds.length + " configured character(s) no longer exist</b> " + "(deleted, or re-imported as a new character with a " + "new ID) and will block !exp award. Run " + "<b>!exp config</b> again with the current tokens " + "selected to fix this.<br>"; } output += "Base share: <b>" + baseShare.toLocaleString() + " XP</b><br>"; if (remainder > 0) { output += "Remainder: <b>" + remainder.toLocaleString() + " XP</b><br>"; } output += "<br><i>!exp award will distribute the entire pool.</i>"; XPSystem.whisperGM(output); }; /* ========================================================= AWARD XP ========================================================= BEHAVIOR: The PARTY LIST IS NEVER MODIFIED by this function. A GM asked for this explicitly: the party is a standing roster (who's currently adventuring together), not something that should need to be re-configured with !exp config after every single award. Only the pool TOTAL changes here, decremented as each character is successfully paid. - If distribution stops partway (missing character, or a PC XP read/write failure), the pool total accurately reflects only what's LEFT to distribute, and the party list is completely untouched either way. - Re-running !exp award after fixing the problem splits whatever's left in the pool across the (unchanged) party again. NOTE: this means anyone already paid earlier in a partially-failed run will receive an ADDITIONAL share from what's left if you retry — since the party isn't shrunk to track "who's already been paid this batch", there's no way to exclude them from that retry's split. This trade-off only matters for a partial-failure-then- retry, not for a normal, fully successful !exp award. ========================================================= */ XPSystem.distributeXP = async function () { var pool = state.XP2024Pool.total || 0; /* * Copy the party array. This local snapshot is used purely * to iterate and compute the split — state.XP2024Pool.party * itself is never written to by this function, so there's no * risk of mutating the array while iterating it; the copy is * just to keep this function's view of "who's being paid * this run" stable even if something else changes the party * mid-loop (there's an await in here for each character). */ var party = (state.XP2024Pool.party || []).slice(); /* * Nothing to distribute. */ if (pool <= 0) { XPSystem.whisperGM( "<b>XP Pool is empty.</b>" ); return; } /* * Need a party. */ if (!party.length) { XPSystem.whisperGM( "<b>No party configured.</b><br><br>" + "Select the PCs and use <b>!exp config</b> first." ); return; } /* * Divide the XP. * * Remainder XP is distributed one point at a time * to the first members of the configured party. * * Therefore ALL XP is distributed and none is lost, * for THIS invocation's split. If a later invocation * is needed after a partial failure, the split is * recomputed fresh from whatever pool/party remain. */ var baseShare = Math.floor( pool / party.length ); var remainder = pool % party.length; var output = "<b>XP Distribution</b><br><br>" + "Total pool: <b>" + pool.toLocaleString() + " XP</b><br><br>"; var successfulDistributions = 0; for ( var i = 0; i < party.length; i++ ) { var characterId = party[i]; var character = getObj( "character", characterId ); /* * If a configured character no longer exists, * stop and DO NOT touch the pool further. The party * list is never touched by this function at all. * * This protects the remaining XP from being lost. */ if (!character) { output += "<br><b>XP distribution stopped.</b><br>" + "A configured party character could not be found. " + "Remaining pool (" + state.XP2024Pool.total.toLocaleString() + " XP) has <b>NOT</b> been changed. Fix the party " + "and run <b>!exp award</b> again to pay the rest."; XPSystem.whisperGM(output); return; } var sheet2024 = XPSystem.is2024(character); var oldXP = await XPSystem.getPCXP( characterId, sheet2024 ); /* * A null result means the XP read genuinely * failed — do NOT treat it as a 0 baseline, since * that would overwrite this character's real XP * total with just their pool share. Stop here; * nothing has been written for this character, and * the pool remains exactly as owed. */ if (oldXP === null) { output += "<br><b>XP distribution stopped.</b><br>" + "Could not read current XP for <b>" + character.get("name") + "</b> (read failed). " + "Remaining pool (" + state.XP2024Pool.total.toLocaleString() + " XP) has <b>NOT</b> been changed. Fix the " + "issue and run <b>!exp award</b> again to pay " + "the rest."; XPSystem.whisperGM(output); return; } /* * Give one additional XP to the first * 'remainder' characters in THIS invocation's * split. */ var share = baseShare + ( i < remainder ? 1 : 0 ); var newXP = oldXP + share; /* * Write the new XP. * * Wrapped in try/catch: if the write throws (e.g. a * sandbox-side sheet error), state is left exactly as * owed — nothing has been decremented for this * character yet — and !exp award can simply be run * again after the underlying problem is fixed. */ try { await XPSystem.setPCXP( characterId, sheet2024, newXP ); } catch (writeError) { output += "<br><b>XP distribution stopped.</b><br>" + "Writing XP failed for <b>" + character.get("name") + "</b>: " + String(writeError) + "<br>" + "Remaining pool (" + state.XP2024Pool.total.toLocaleString() + " XP) has <b>NOT</b> been changed. Fix the " + "issue and run <b>!exp award</b> again to pay " + "the rest."; XPSystem.whisperGM(output); return; } /* * The write succeeded — immediately remove this * character's share from the live POOL TOTAL only. * The party list itself is never touched here — see * the function-level comment above for why, and for * the retry trade-off that comes with it. */ state.XP2024Pool.total -= share; successfulDistributions++; output += "<b>" + character.get("name") + "</b>: " + oldXP.toLocaleString() + " → " + newXP.toLocaleString() + " XP " + "(+" + share.toLocaleString() + ")<br>"; } /* * Every configured character in this invocation * successfully received their XP. The pool total should * already be at (or extremely close to) 0 from the * incremental decrements above — this is just an explicit, * tidy reset to guard against any float rounding drift. * * The PARTY LIST IS NOT CLEARED. It stays exactly as * configured, ready for the next !exp collect / !exp award * cycle without needing to re-run !exp config. */ if ( successfulDistributions === party.length ) { state.XP2024Pool.total = 0; output += "<br><b>XP Pool cleared.</b>"; } /* * Distribution results are sent to PUBLIC chat, not * whispered — players generally want to see how much XP * they just received. The "stopped"/error branches above * still whisper to the GM only, since those are GM-facing * troubleshooting info, not something to show the table. */ XPSystem.sendPublic(output); }; /* ========================================================= CUSTOM XP (manual pool adjustment) ========================================================= !exp custom <amount> Adds <amount> XP directly to the pool without requiring a dead NPC token — for story awards, DM fiat, correcting a mistake, etc. Accepts a negative amount to remove XP from the pool (e.g. to undo an accidental !exp custom), but never lets the pool go below 0. ========================================================= */ XPSystem.addCustomXP = function (rawArg) { if ( rawArg === undefined || rawArg === null || rawArg.trim() === "" ) { XPSystem.whisperGM( "<b>Usage:</b> !exp custom <amount><br>" + "Example: <b>!exp custom 500</b> adds 500 XP to " + "the pool. A negative amount (e.g. " + "<b>!exp custom -200</b>) removes XP instead." ); return; } var amount = parseInt(rawArg.trim(), 10); if ( isNaN(amount) || String(amount) !== rawArg.trim() || amount === 0 ) { XPSystem.whisperGM( "<b>\"" + rawArg + "\" isn't a whole, non-zero number.</b><br>" + "Usage: !exp custom <amount> (e.g. " + "!exp custom 500, or !exp custom -200 to remove)." ); return; } var currentTotal = state.XP2024Pool.total || 0; if ( amount < 0 && (-amount) > currentTotal ) { XPSystem.whisperGM( "<b>Can't remove " + (-amount).toLocaleString() + " XP</b> — the pool only has " + currentTotal.toLocaleString() + " XP in it. Nothing was changed." ); return; } state.XP2024Pool.total = currentTotal + amount; XPSystem.whisperGM( ( amount > 0 ? "<b>Added " + amount.toLocaleString() + " XP" : "<b>Removed " + (-amount).toLocaleString() + " XP" ) + " to the pool.</b><br>" + "New pool total: <b>" + state.XP2024Pool.total.toLocaleString() + " XP</b>." ); }; /* ========================================================= CLEAR PARTY ========================================================= */ XPSystem.clearParty = function () { /* * Clear only the configured party. * * The current XP pool is preserved. */ state.XP2024Pool.party = []; XPSystem.whisperGM( "<b>XP Party cleared.</b><br>" + "The current XP pool was not changed." ); }; /* ========================================================= HELP ========================================================= */ XPSystem.showHelp = function () { XPSystem.whisperGM( "<b>D&D XP Collector System v" + XPSystem.VERSION + "</b><br><br>" + "<b>!exp config</b> — select the PCs who should " + "receive pooled XP, then run this. Replaces the " + "whole party list each time it's run.<br>" + "<b>!exp config add</b> — select PC(s) and run this " + "to add them to the existing party without disturbing " + "anyone else already configured.<br>" + "<b>!exp config remove</b> — select PC(s) and run " + "this to take them out of the party, leaving everyone " + "else configured.<br><br>" + "<b>!exp collect</b> — select one or more dead NPC/" + "monster tokens (HP ≤ 0) and run this to add their " + "XP to the pool. A token can only be collected from " + "once (marked with a Pink status marker); remove the " + "Pink marker to make it collectible again.<br><br>" + "<b>!exp custom <amount></b> — manually add XP " + "to the pool without a dead NPC token (e.g. " + "<b>!exp custom 500</b> for a story award). Use a " + "negative number (e.g. <b>!exp custom -200</b>) to " + "remove XP instead; the pool won't go below 0." + "<br><br>" + "<b>!exp pool</b> — show the current pooled XP total, " + "the configured party, and the exact split " + "!exp award would currently pay out.<br><br>" + "<b>!exp award</b> — split the entire pool evenly " + "across the configured party (any remainder goes one " + "point at a time to the first members) and reset the " + "pool once everyone's paid. The party itself is NOT " + "cleared — it stays configured for next time. Results " + "are posted to PUBLIC chat so players can see their XP " + "(unlike every other command here, which whispers to " + "the GM only)." + "<br><br>" + "<b>!exp clearparty</b> — clear the configured party " + "only. The current XP pool is left untouched.<br><br>" + "<b>!exp help</b> — show this message." ); }; /* ========================================================= COMMAND HANDLER ========================================================= */ on("chat:message", function (msg) { if (msg.type !== "api") { return; } var trimmedContent = msg.content.trim(); var command = trimmedContent.toLowerCase(); /* * !exp custom takes an argument (the amount), so it can't * be matched by the exact-string switch below like the * other commands. Handled as a prefix check instead. */ if (command.indexOf("!exp custom") === 0) { XPSystem.addCustomXP( trimmedContent.slice("!exp custom".length).trim() ); return; } /* * !exp config optionally takes an "add" or "remove" * sub-command, so it's also handled as a prefix check * rather than the exact-string switch below. * * !exp config -> replace the whole party * !exp config add -> add selected PCs to the party * !exp config remove -> remove selected PCs from it */ if (command.indexOf("!exp config") === 0) { var configArg = trimmedContent .slice("!exp config".length) .trim() .toLowerCase(); if ( configArg === "" || configArg === "add" || configArg === "remove" ) { XPSystem.configureParty( msg, configArg === "" ? "replace" : configArg ); } else { XPSystem.whisperGM( "<b>\"" + configArg + "\" isn't a valid !exp config option.</b><br>" + "Use <b>!exp config</b> (replace the party), " + "<b>!exp config add</b>, or " + "<b>!exp config remove</b>." ); } return; } switch (command) { case "!exp collect": XPSystem.collectXP(msg); break; case "!exp pool": XPSystem.showPool(); break; case "!exp award": XPSystem.distributeXP(); break; case "!exp clearparty": XPSystem.clearParty(); break; case "!exp help": XPSystem.showHelp(); break; } });