diff --git a/GameAssist/0.1.1.0/GameAssist.js b/GameAssist/0.1.1.0/GameAssist.js
new file mode 100644
index 000000000..1dbb8ba3d
--- /dev/null
+++ b/GameAssist/0.1.1.0/GameAssist.js
@@ -0,0 +1,1159 @@
+// =============================
+// === GameAssist v0.1.1.0 ===
+// === Author: Mord Eagle ===
+// =============================
+// Released under the MIT License (see https://opensource.org/licenses/MIT)
+//
+// Copyright (c) 2025 Mord Eagle
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+// THE SOFTWARE.
+(() => {
+ 'use strict';
+
+ const VERSION = '0.1.1.0';
+ const STATE_KEY = 'GameAssist';
+ const MODULES = {};
+ let READY = false;
+
+ // ————— QUEUE + WATCHDOG —————
+ let _busy = false;
+ let _lastStart = 0;
+ const _queue = [];
+ const DEFAULT_TIMEOUT = 30000;
+ const WATCHDOG_INTERVAL = 15000;
+
+ function _enqueue(task, priority = 0, timeout = DEFAULT_TIMEOUT) {
+ _queue.push({ task, priority, enqueuedAt: Date.now(), timeout });
+ _queue.sort((a,b) => b.priority - a.priority || a.enqueuedAt - b.enqueuedAt);
+ _runNext();
+ }
+
+ function _runNext() {
+ if (_busy || !_queue.length) return;
+ const { task, timeout } = _queue.shift();
+ _busy = true;
+ _lastStart = Date.now();
+
+ const timer = setTimeout(() => {
+ GameAssist.log('Core', `Task timed out after ${timeout}ms`, 'WARN');
+ _busy = false;
+ _runNext();
+ }, timeout);
+
+ Promise.resolve()
+ .then(task)
+ .catch(err => GameAssist.log('Core', `Error in task: ${err.message}`, 'ERROR'))
+ .finally(() => {
+ clearTimeout(timer);
+ _busy = false;
+ const duration = Date.now() - _lastStart;
+ GameAssist._metrics.taskDurations.push(duration);
+ GameAssist._metrics.lastUpdate = new Date().toISOString();
+ _runNext();
+ });
+ }
+
+ setInterval(() => {
+ if (_busy && Date.now() - _lastStart > DEFAULT_TIMEOUT * 2) {
+ GameAssist.log('Core', 'Watchdog forced queue reset', 'WARN');
+ _busy = false;
+ _runNext();
+ }
+ }, WATCHDOG_INTERVAL);
+
+ // ————— HANDLER TRACKING —————
+ globalThis._handlers = globalThis._handlers || {};
+ const originalOn = typeof globalThis.on === 'function' ? globalThis.on : null;
+ const originalOff = typeof globalThis.off === 'function' ? globalThis.off : null;
+
+ globalThis.on = (event, handler) => {
+ globalThis._handlers[event] = globalThis._handlers[event] || [];
+ globalThis._handlers[event].push(handler);
+ if (typeof originalOn === 'function') {
+ return originalOn(event, handler);
+ }
+ };
+
+ globalThis.off = (event, handler) => {
+ if (!globalThis._handlers[event]) return;
+ globalThis._handlers[event] = globalThis._handlers[event].filter(h => h !== handler);
+ if (typeof originalOff === 'function') {
+ return originalOff(event, handler);
+ }
+ };
+
+ // ————— UTILITIES —————
+ function _parseArgs(content) {
+ const args = {}, pattern = /--(\w+)(?:\s+("[^"]*"|[^\s]+))?/g;
+ let m;
+ while ((m = pattern.exec(content))) {
+ let v = m[2] || true;
+ if (typeof v === 'string') {
+ if (/^".*"$/.test(v)) v = v.slice(1, -1);
+ else if (/^\d+$/.test(v)) v = parseInt(v, 10);
+ else if (/,/.test(v)) v = v.split(',');
+ }
+ args[m[1]] = v;
+ }
+ return { cmd: content.split(/\s+/)[0], args };
+ }
+
+ function getState(mod) {
+ state[STATE_KEY] = state[STATE_KEY] || { config: {} };
+ state[STATE_KEY][mod] = state[STATE_KEY][mod] || { config: {}, runtime: {} };
+ return state[STATE_KEY][mod];
+ }
+ function saveState(mod, data) {
+ state[STATE_KEY] = state[STATE_KEY] || { config: {} };
+ state[STATE_KEY][mod] = Object.assign(getState(mod), data);
+ }
+ function clearState(mod) {
+ if (state[STATE_KEY]?.[mod]) delete state[STATE_KEY][mod];
+ }
+
+ function auditState() {
+ const root = state[STATE_KEY] || {};
+ Object.keys(root).forEach(k => {
+ if (k === 'config') return;
+ if (!MODULES[k]) {
+ GameAssist.log('Core', `Unexpected state branch: ${k}`, 'WARN');
+ delete root[k];
+ } else {
+ const branch = root[k];
+ if (!branch.config || !branch.runtime) {
+ GameAssist.log('Core', `Malformed state for ${k}`, 'WARN');
+ delete root[k];
+ }
+ }
+ });
+ GameAssist._metrics.stateAudits++;
+ GameAssist._metrics.lastUpdate = new Date().toISOString();
+ }
+
+ function seedDefaults() {
+ Object.entries(MODULES).forEach(([name, mod]) => {
+ const cfg = getState(name).config;
+ if (cfg.enabled === undefined) cfg.enabled = mod.enabled;
+ });
+ }
+
+ function _sanitize(str = '') {
+ return str.toString()
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/'/g, ''');
+ }
+
+ // ————— COMPATIBILITY —————
+ const KNOWN_SCRIPTS = [
+ 'tokenmod.js','universaltvttimporter.js','npc-hp.js','wolfpack.js',
+ 'critfumble.js','rana-curse.js','statusinfo.js','npc death tracker.js',
+ 'customizable roll listener.js','5th edition ogl by roll20 companion.js'
+ ];
+ function normalizeScriptName(n) {
+ return (n||'')
+ .toLowerCase()
+ .replace(/\.js$/, '')
+ .replace(/[\s_]+/g, '-')
+ .replace(/[^\w-]/g, '');
+ }
+ function auditCompatibility() {
+ if (!GameAssist.flags.DEBUG_COMPAT) return;
+ const known = KNOWN_SCRIPTS.map(normalizeScriptName);
+ const active = Object.keys(state.api?.scripts || {}).map(normalizeScriptName);
+ const good = active.filter(n => known.includes(n));
+ const bad = active.filter(n => !known.includes(n));
+ GameAssist.log('Compat', '✅ Known: ' + (good.join(', ') || 'none'));
+ GameAssist.log('Compat', '❓ Unknown: ' + (bad.join(', ') || 'none'));
+ GameAssist.log('Compat', '🔌 Events: ' + GameAssist._plannedEvents.join(', '));
+ GameAssist.log('Compat', '💬 Commands: ' + GameAssist._plannedChatPrefixes.join(', '));
+ }
+
+ // ————— CONFIG PARSER —————
+ function parseConfigValue(raw) {
+ raw = raw.trim();
+ if (raw === 'true') return true;
+ if (raw === 'false') return false;
+ if (!isNaN(raw)) return Number(raw);
+ if ((raw.startsWith('{') && raw.endsWith('}')) || (raw.startsWith('[') && raw.endsWith(']'))) {
+ try { return JSON.parse(raw); }
+ catch { GameAssist.log('Config', 'Invalid JSON: ' + _sanitize(raw)); }
+ }
+ return raw;
+ }
+
+ // ————— GameAssist CORE —————
+ const GameAssist = {
+ _metrics: {
+ commands: 0,
+ messages: 0,
+ errors: 0,
+ stateAudits: 0,
+ taskDurations: [],
+ lastUpdate: null
+ },
+ _plannedEvents: [],
+ _plannedChatPrefixes: [],
+ _listeners: {},
+ _commandHandlers: {},
+ _eventHandlers: {},
+ config: {},
+ flags: { DEBUG_COMPAT: false },
+
+ log(mod, msg, level = 'INFO') {
+ const timestamp = new Date().toLocaleTimeString();
+ const levelIcon = { INFO: 'ℹ️', WARN: '⚠️', ERROR: '❌' }[level] || 'ℹ️';
+ sendChat('GameAssist', `/w gm ${levelIcon} [${timestamp}] [${mod}] ${_sanitize(msg)}`);
+ },
+
+ handleError(mod, err) {
+ this._metrics.errors++;
+ this._metrics.lastUpdate = new Date().toISOString();
+ this.log(mod, err.message || String(err), 'ERROR');
+ },
+
+ register(name, initFn, { enabled = true, events = [], prefixes = [], teardown = null } = {}) {
+ if (READY) {
+ this.log('Core', `Cannot register after ready: ${name}`, 'WARN');
+ return;
+ }
+ if (MODULES[name]) {
+ this.log('Core', `Duplicate module: ${name}`, 'WARN');
+ return;
+ }
+ MODULES[name] = { initFn, teardown, enabled, initialized: false, events, prefixes };
+ this._plannedEvents.push(...events);
+ this._plannedChatPrefixes.push(...prefixes);
+ },
+
+ onCommand(prefix, fn, mod, { gmOnly = false, acl = [] } = {}) {
+ const wrapped = msg => {
+ if (msg.type !== 'api' || !msg.content.startsWith(prefix)) return;
+ if (gmOnly && !playerIsGM(msg.playerid)) return;
+ if (acl.length && !acl.includes(msg.playerid)) return;
+ this._metrics.commands++;
+ this._metrics.lastUpdate = new Date().toISOString();
+ try { fn(msg); }
+ catch(e) { this.handleError(mod, e); }
+ };
+ on('chat:message', wrapped);
+ this._commandHandlers[mod] = (this._commandHandlers[mod] || []).concat({ event:'chat:message', fn:wrapped });
+ },
+
+ offCommands(mod) {
+ (this._commandHandlers[mod] || []).forEach(h => off(h.event, h.fn));
+ this._commandHandlers[mod] = [];
+ },
+
+ onEvent(evt, fn, mod) {
+ const wrapped = (...args) => {
+ if (!READY || !MODULES[mod].initialized) return;
+ this._metrics.messages++;
+ this._metrics.lastUpdate = new Date().toISOString();
+ try { fn(...args); }
+ catch(e) { this.handleError(mod, e); }
+ };
+ on(evt, wrapped);
+ this._listeners[mod] = (this._listeners[mod] || []).concat({ event:evt, fn:wrapped });
+ },
+
+ offEvents(mod) {
+ (this._listeners[mod] || []).forEach(h => off(h.event, h.fn));
+ this._listeners[mod] = [];
+ },
+
+ _clearAllListeners() {
+ Object.keys(this._commandHandlers).forEach(m => this.offCommands(m));
+ Object.keys(this._listeners).forEach(m => this.offEvents(m));
+ },
+
+ _dedupePlanned() {
+ this._plannedEvents = [...new Set(this._plannedEvents)];
+ this._plannedChatPrefixes = [...new Set(this._plannedChatPrefixes)];
+ },
+
+ enableModule(name) {
+ _enqueue(() => {
+ const m = MODULES[name];
+ if (!m) { this.log('Core', `No such module: ${name}`, 'WARN'); return; }
+ this.offEvents(name);
+ this.offCommands(name);
+ clearState(name);
+ getState(name).config.enabled = true;
+ m.initialized = true;
+ try { m.initFn(); this.log(name, 'Enabled'); }
+ catch(e) { this.handleError(name, e); }
+ });
+ },
+
+ disableModule(name) {
+ _enqueue(() => {
+ const m = MODULES[name];
+ if (!m) { this.log('Core', `No such module: ${name}`, 'WARN'); return; }
+ if (typeof m.teardown === 'function') {
+ try { m.teardown(); }
+ catch(e) { this.log(name, `Teardown failed: ${e.message}`, 'WARN'); }
+ }
+ this.offEvents(name);
+ this.offCommands(name);
+ clearState(name);
+ getState(name).config.enabled = false;
+ m.initialized = false;
+ this.log(name, 'Disabled');
+ });
+ }
+ };
+
+ globalThis.GameAssist = GameAssist;
+
+ // ————— CONFIG COMMAND —————
+ GameAssist.onCommand('!ga-config', msg => {
+ const parts = msg.content.trim().split(/\s+/);
+ const sub = parts[1];
+ if (sub === 'list') {
+ const ts = new Date().toLocaleString();
+ const ver = `v${VERSION}`;
+ const cfg = JSON.stringify(state[STATE_KEY].config, null, 2)
+ .replace(/[<>&]/g, c=>({'<':'<','>':'>','&':'&'})[c]);
+ const name = 'GameAssist Config';
+ let handout = findObjs({ type:'handout', name })[0];
+ if (!handout) handout = createObj('handout', { name, archived:false });
+ handout.set('notes', `
Generated: ${ts} (${ver})\n\n${cfg}
`);
+ sendChat('GameAssist', `/w gm Config written to "${name}"`);
+ }
+ else if (sub === 'set' && parts.length >= 4) {
+ const mod = parts[2];
+ const [ key, ...rest ] = parts.slice(3).join(' ').split('=');
+ const val = rest.join('=');
+ const parsed = parseConfigValue(val);
+ if (!MODULES[mod]) {
+ GameAssist.log('Config', `Unknown module: ${mod}`, 'WARN');
+ return;
+ }
+ getState(mod).config[key.trim()] = parsed;
+ GameAssist.log('Config', `Set ${mod}.${key.trim()} = ${JSON.stringify(parsed)}`);
+ }
+ else if (sub === 'get' && parts.length >= 4) {
+ const mod = parts[2];
+ const key = parts[3];
+ if (!MODULES[mod]) {
+ GameAssist.log('Config', `Unknown module: ${mod}`, 'WARN');
+ return;
+ }
+ const val = getState(mod).config[key];
+ GameAssist.log('Config', `${mod}.${key} = ${JSON.stringify(val)}`);
+ }
+ else if (sub === 'modules') {
+ const moduleList = Object.entries(MODULES).map(([name, mod]) => {
+ const cfg = getState(name).config;
+ const status = cfg.enabled ? '✅' : '❌';
+ const init = mod.initialized ? '🔄' : '⏸️';
+ return `${status}${init} ${name}`;
+ }).join('\n');
+ GameAssist.log('Config', `Modules:\n${moduleList}`);
+ }
+ else {
+ GameAssist.log('Config', 'Usage: !ga-config list|set|get|modules [args]');
+ }
+ }, 'Core', { gmOnly: true });
+
+ // ————— CONTROL COMMANDS —————
+ GameAssist.onCommand('!ga-enable', msg => {
+ const mod = msg.content.split(/\s+/)[1];
+ if (!mod) {
+ GameAssist.log('Core', 'Usage: !ga-enable ', 'WARN');
+ return;
+ }
+ GameAssist.enableModule(mod);
+ }, 'Core', { gmOnly: true });
+
+ GameAssist.onCommand('!ga-disable', msg => {
+ const mod = msg.content.split(/\s+/)[1];
+ if (!mod) {
+ GameAssist.log('Core', 'Usage: !ga-disable ', 'WARN');
+ return;
+ }
+ GameAssist.disableModule(mod);
+ }, 'Core', { gmOnly: true });
+
+ GameAssist.onCommand('!ga-status', msg => {
+ const metrics = GameAssist._metrics;
+ const avgDuration = metrics.taskDurations.length > 0
+ ? (metrics.taskDurations.reduce((a,b) => a+b, 0) / metrics.taskDurations.length).toFixed(2)
+ : 'N/A';
+
+ const status = [
+ `**GameAssist ${VERSION} Status**`,
+ `Commands: ${metrics.commands}`,
+ `Messages: ${metrics.messages}`,
+ `Errors: ${metrics.errors}`,
+ `Avg Task Duration: ${avgDuration}ms`,
+ `Queue Length: ${_queue.length}`,
+ `Last Update: ${metrics.lastUpdate || 'Never'}`,
+ `Modules: ${Object.keys(MODULES).length}`,
+ `Active Listeners: ${Object.values(GameAssist._listeners).flat().length}`
+ ].join('\n');
+
+ GameAssist.log('Status', status);
+ }, 'Core', { gmOnly: true });
+
+ // ————— CRITFUMBLE MODULE v0.2.4.7 —————
+ GameAssist.register('CritFumble', function() {
+ const modState = getState('CritFumble');
+
+ Object.assign(modState.config, {
+ enabled: true,
+ debug: true,
+ useEmojis: true,
+ rollDelayMs: 200,
+ ...modState.config
+ });
+
+ modState.runtime.activePlayers = modState.runtime.activePlayers || {};
+
+ const VALID_TEMPLATES = ['atk', 'atkdmg', 'npcatk', 'spell'];
+ const FUMBLE_TABLES = {
+ melee: 'CF-Melee',
+ ranged: 'CF-Ranged',
+ spell: 'CF-Spell',
+ natural: 'CF-Natural',
+ thrown: 'CF-Thrown'
+ };
+
+ function debugLog(message) {
+ if (modState.config.debug) {
+ GameAssist.log('CritFumble', message);
+ }
+ }
+
+ function emoji(symbol) {
+ return modState.config.useEmojis ? symbol : '';
+ }
+
+ function sendTemplateMessage(who, title, fields) {
+ const content = fields.map(f => `{{${f.label}=${f.value}}}`).join(' ');
+ sendChat('CritFumble', `/w "${who}" &{template:default} {{name=${title}}} ${content}`);
+ }
+
+ function getFumbleTableName(fumbleType) {
+ return FUMBLE_TABLES[fumbleType] || null;
+ }
+
+ function sendConfirmMenu(who) {
+ const confirmButtons = [
+ `[Confirm-Crit-Martial](!Confirm-Crit-Martial)`,
+ `[Confirm-Crit-Magic](!Confirm-Crit-Magic)`
+ ].join(' ');
+
+ sendTemplateMessage(who, `${emoji('❓')} Confirm Critical Miss`, [
+ { label: "Choose Confirmation Type", value: confirmButtons }
+ ]);
+ }
+
+ function sendFumbleMenu(who) {
+ sendConfirmMenu(who);
+
+ const fumbleButtons = [
+ `[⚔ Melee](!critfumble-melee)`,
+ `[🏹 Ranged](!critfumble-ranged)`,
+ `[🎯 Thrown](!critfumble-thrown)`,
+ `[🔥 Spell](!critfumble-spell)`,
+ `[👊 Natural/Unarmed](!critfumble-natural)`
+ ].join(' ');
+
+ sendTemplateMessage(who, `${emoji('💥')} Critical Miss!`, [
+ { label: "What kind of attack was this?", value: fumbleButtons }
+ ]);
+ sendTemplateMessage('gm', `${emoji('💥')} Critical Miss for ${who}!`, [
+ { label: "What kind of attack was this?", value: fumbleButtons }
+ ]);
+ }
+
+ function announceTableRoll(tableName) {
+ sendTemplateMessage('gm', `${emoji('🎲')} Rolling Table`, [
+ { label: "Table", value: `**${tableName}**` }
+ ]);
+ }
+
+ function executeTableRoll(tableName) {
+ const rollCommand = `/roll 1t[${tableName}]`;
+ setTimeout(() => {
+ sendChat('', rollCommand);
+ debugLog(`Roll command executed: ${rollCommand}`);
+ }, modState.config.rollDelayMs);
+ }
+
+ function rollFumbleTable(who, fumbleType) {
+ const tableName = getFumbleTableName(fumbleType);
+ if (!tableName) {
+ const validKeys = Object.keys(FUMBLE_TABLES).join(', ');
+ sendTemplateMessage(who, "⚠️ Invalid Fumble Type", [
+ { label: "Requested", value: `"${fumbleType}"` },
+ { label: "Valid Types", value: validKeys }
+ ]);
+ debugLog(`Invalid fumble type "${fumbleType}"`);
+ return;
+ }
+ announceTableRoll(tableName);
+ executeTableRoll(tableName);
+ }
+
+ function rollConfirmTable(who, confirmType) {
+ const validConfirmTables = ['Confirm-Crit-Martial', 'Confirm-Crit-Magic'];
+ if (!validConfirmTables.includes(confirmType)) {
+ sendTemplateMessage(who, "⚠️ Invalid Confirm Type", [
+ { label: "Requested", value: `"${confirmType}"` },
+ { label: "Valid Options", value: validConfirmTables.join(', ') }
+ ]);
+ debugLog(`Invalid confirm type "${confirmType}"`);
+ return;
+ }
+ announceTableRoll(confirmType);
+ executeTableRoll(confirmType);
+ }
+
+ function hasNaturalOne(inlinerolls) {
+ return inlinerolls.some(group => {
+ if (!group.results || !Array.isArray(group.results.rolls)) return false;
+ return group.results.rolls.some(roll => {
+ if (roll.type !== 'R' || roll.sides !== 20) return false;
+
+ if (Array.isArray(roll.results)) {
+ return roll.results.some(result =>
+ (result.r === true || result.r === undefined) && result.v === 1
+ );
+ } else {
+ return (roll.v === 1);
+ }
+ });
+ });
+ }
+
+ function showManualTriggerMenu() {
+ const playerNames = Object.values(modState.runtime.activePlayers);
+ if (!playerNames.length) {
+ sendTemplateMessage('gm', "⚠️ No Players Detected", [
+ { label: "Note", value: "No players have been active yet this session." }
+ ]);
+ return;
+ }
+
+ const buttons = playerNames.map(name =>
+ `[${name}](!critfumblemenu-${encodeURIComponent(name)})`
+ ).join(' ');
+
+ sendTemplateMessage('gm', "Manually Trigger Fumble Menu", [
+ { label: "Select Player", value: buttons }
+ ]);
+ }
+
+ function handleManualTrigger(targetPlayer) {
+ sendFumbleMenu(decodeURIComponent(targetPlayer));
+ debugLog(`Manually triggered fumble menu for: ${targetPlayer}`);
+ }
+
+ function showHelpMessage(who) {
+ sendTemplateMessage(who, "📘 CritFumble Help", [
+ { label: "Version", value: "v0.2.4.7" },
+ { label: "Commands", value: "`!critfail`, `!critfumble help`" },
+ { label: "Description", value: "Auto-detects crit fails and prompts the attacker with a fumble menu. GM can trigger menus manually." },
+ { label: "Valid Types", value: Object.keys(FUMBLE_TABLES).join(', ') }
+ ]);
+ }
+
+ function handleRoll(msg) {
+ if (!msg) return;
+
+ if (msg.playerid && !modState.runtime.activePlayers[msg.playerid]) {
+ const player = getObj('player', msg.playerid);
+ if (player) {
+ modState.runtime.activePlayers[msg.playerid] = player.get('displayname');
+ debugLog(`Registered player: ${player.get('displayname')}`);
+ }
+ }
+
+ if (msg.type === 'api') {
+ const cmd = (msg.content || '').trim().toLowerCase();
+
+ if (cmd === '!critfail') {
+ debugLog('Manual trigger command received: !critfail');
+ return showManualTriggerMenu();
+ }
+
+ if (cmd === '!critfumble help') return showHelpMessage(msg.who);
+
+ if (cmd.startsWith('!critfumblemenu-')) {
+ const playerName = msg.content.replace('!critfumblemenu-', '');
+ return handleManualTrigger(playerName);
+ }
+
+ if (cmd.startsWith('!critfumble-')) {
+ const who = (msg.who || 'Unknown').replace(' (GM)', '');
+ const fumbleType = msg.content.replace('!critfumble-', '').toLowerCase();
+ debugLog(`${who} selected fumble type: ${fumbleType}`);
+ return rollFumbleTable(who, fumbleType);
+ }
+
+ if (cmd.startsWith('!confirm-crit-')) {
+ const who = (msg.who || 'Unknown').replace(' (GM)', '');
+ const confirmType = msg.content.slice(1);
+ debugLog(`${who} selected confirm type: ${confirmType}`);
+ return rollConfirmTable(who, confirmType);
+ }
+
+ return;
+ }
+
+ if (!msg.rolltemplate || !VALID_TEMPLATES.includes(msg.rolltemplate)) return;
+
+ const rolls = msg.inlinerolls || [];
+ if (!rolls.length || !hasNaturalOne(rolls)) return;
+
+ const who = (msg.who || 'Unknown').replace(' (GM)', '');
+ debugLog(`Fumble detected from: ${who}`);
+ if (who === 'Unknown') {
+ sendTemplateMessage('gm', "⚠️ Unknown Player", [
+ { label: "Note", value: "Could not identify who rolled the fumble." }
+ ]);
+ }
+
+ sendFumbleMenu(who);
+ }
+
+ GameAssist.onEvent('chat:message', handleRoll, 'CritFumble');
+ GameAssist.log('CritFumble', 'v0.2.4.7 Ready: Auto fumble detection + !critfail');
+ }, {
+ enabled: true,
+ events: ['chat:message'],
+ prefixes: ['!critfail', '!critfumble']
+ });
+
+ // ————— NPC MANAGER MODULE v0.1.1.0 —————
+ GameAssist.register('NPCManager', function() {
+ const modState = getState('NPCManager');
+
+ Object.assign(modState.config, {
+ enabled: true,
+ autoTrackDeath: true,
+ deadMarker: 'dead',
+ ...modState.config
+ });
+
+ function isNPC(token) {
+ if (!token || token.get('layer') !== 'objects') return false;
+ const charId = token.get('represents');
+ if (!charId) return false;
+
+ const npcAttr = findObjs({
+ _type: 'attribute',
+ _characterid: charId,
+ name: 'npc'
+ })[0];
+
+ return npcAttr && npcAttr.get('current') === '1';
+ }
+
+ function checkForDeath(token) {
+ if (!modState.config.autoTrackDeath || !isNPC(token)) return;
+
+ const hp = parseInt(token.get('bar1_value'), 10) || 0;
+ const markers = (token.get('statusmarkers') || '').split(',');
+ const isDead = markers.includes(modState.config.deadMarker);
+
+ if (hp < 1 && !isDead) {
+ sendChat('api', `!token-mod --ids ${token.id} --set statusmarkers|+${modState.config.deadMarker}`);
+ GameAssist.log('NPCManager', `${token.get('name')} marked as dead (HP: ${hp})`);
+ } else if (hp >= 1 && isDead) {
+ sendChat('api', `!token-mod --ids ${token.id} --set statusmarkers|-${modState.config.deadMarker}`);
+ GameAssist.log('NPCManager', `${token.get('name')} revived (HP: ${hp})`);
+ }
+ }
+
+ function handleTokenChange(obj, prev) {
+ if (obj.get('bar1_value') !== prev.bar1_value) {
+ checkForDeath(obj);
+ }
+ }
+
+ GameAssist.onCommand('!npc-death-report', msg => {
+ const pageId = Campaign().get('playerpageid');
+ const tokens = findObjs({
+ _pageid: pageId,
+ _type: 'graphic',
+ layer: 'objects'
+ });
+
+ const flagged = [];
+ for (let token of tokens) {
+ if (!isNPC(token)) continue;
+
+ const hp = parseInt(token.get('bar1_value'), 10) || 0;
+ const markers = (token.get('statusmarkers') || '').split(',');
+ const isDead = markers.includes(modState.config.deadMarker);
+
+ if ((hp < 1 && !isDead) || (hp >= 1 && isDead)) {
+ flagged.push({
+ name: token.get('name') || '(Unnamed)',
+ id: token.id,
+ hp,
+ markers: token.get('statusmarkers') || '(none)'
+ });
+ }
+ }
+
+ if (flagged.length === 0) {
+ GameAssist.log('NPCManager', '✅ Living NPCs have correct death marker states.');
+ } else {
+ GameAssist.log('NPCManager', `⚠️ ${flagged.length} NPC(s) with mismatched death markers:`);
+ flagged.forEach(({ name, id, hp, markers }) => {
+ GameAssist.log('NPCManager', `- ${name} [${id}] | HP: ${hp} | Markers: ${markers}`);
+ });
+ }
+ }, 'NPCManager', { gmOnly: true });
+
+ GameAssist.onEvent('change:graphic:bar1_value', handleTokenChange, 'NPCManager');
+ GameAssist.log('NPCManager', 'v0.1.1.0 Ready: Auto death tracking + !npc-death-report');
+ }, {
+ enabled: true,
+ events: ['change:graphic:bar1_value'],
+ prefixes: ['!npc-death-report']
+ });
+
+// ————— CONCENTRATION TRACKER MODULE v0.1.0.4k —————
+GameAssist.register('ConcentrationTracker', function() {
+ const modState = getState('ConcentrationTracker');
+
+ Object.assign(modState.config, {
+ enabled: true,
+ marker: 'Concentrating',
+ randomize: true,
+ ...modState.config
+ });
+
+ modState.runtime.lastDamage = modState.runtime.lastDamage || {};
+
+ const CMDS = ['!concentration', '!cc'];
+ const TOKEN_MARKER = 'Concentrating';
+
+ const DEFAULT_LINES = {
+ success: [
+ "steadies their breath, holding their focus.",
+ "'s grip tightens as they maintain their spell.",
+ "staggers slightly but does not lose concentration.",
+ "clenches their jaw, magic still flickering with intent.",
+ "narrows their eyes, spell still intact."
+ ],
+ failure: [
+ "gasps, their focus shattered as the spell falters.",
+ "'s concentration breaks and the magic fades.",
+ "cries out, unable to maintain the spell.",
+ "'s spell fizzles as they lose control.",
+ "winces, focus lost in the heat of battle."
+ ]
+ };
+
+ function getConfig() {
+ return Object.assign({ randomize: true }, modState.config);
+ }
+
+ function getOutcomeLines(name) {
+ const fill = line => line.replace("{{name}}", name);
+ return {
+ success: DEFAULT_LINES.success.map(fill),
+ failure: DEFAULT_LINES.failure.map(fill)
+ };
+ }
+
+ function getConBonus(character) {
+ const attr = findObjs({
+ type: 'attribute',
+ characterid: character.id,
+ name: 'constitution_save_bonus'
+ })[0];
+ return attr ? parseInt(attr.get('current'), 10) : 0;
+ }
+
+ function toggleMarker(token, on) {
+ sendChat('api',
+ `!token-mod --ids ${token.id} --set statusmarkers|${on ? '+' : '-'}${TOKEN_MARKER}`
+ );
+ }
+
+ function postButtons(recipient) {
+ const dmg = '?{Damage taken?|0}';
+ const buttons = [
+ `[🎯 Maintain Control](!concentration --damage ${dmg} --mode normal)`,
+ `[🧠 Brace for the Distraction](!concentration --damage ${dmg} --mode adv)`,
+ `[😣 Struggling to Focus](!concentration --damage ${dmg} --mode dis)`
+ ].join(' ');
+ sendChat('ConcentrationTracker',
+ `/w "${recipient}" ${buttons}
⚠️ Select your token before clicking.`
+ );
+ }
+
+ function sendResult(player, dc, total, rolls, formula) {
+ const tpl =
+ `&{template:default} {{name=🧠 Concentration Check}}` +
+ ` {{DC=${dc}}} {{Result=Roll(s) ${rolls} → ${total} (from ${formula})}}`;
+ sendChat('ConcentrationTracker', `/w "${player}" ${tpl}`);
+ sendChat('ConcentrationTracker', `/w gm ${tpl}`);
+ }
+
+ function handleRoll(msg, damage, mode) {
+ const player = msg.who.replace(/ \(GM\)$/, '');
+ if (!msg.selected?.length) {
+ return sendChat('ConcentrationTracker',
+ `/w "${player}" ⚠️ No token selected.`
+ );
+ }
+ const token = getObj('graphic', msg.selected[0]._id);
+ if (!token) {
+ return sendChat('ConcentrationTracker',
+ `/w "${player}" ⚠️ Token not found.`
+ );
+ }
+ const charId = token.get('represents');
+ if (!charId) {
+ return sendChat('ConcentrationTracker',
+ `/w "${player}" ⚠️ Token not linked.`
+ );
+ }
+ const character = getObj('character', charId);
+ if (!character) {
+ return sendChat('ConcentrationTracker',
+ `/w "${player}" ⚠️ Character not found.`
+ );
+ }
+
+ const bonus = getConBonus(character);
+ const dc = Math.max(10, Math.floor(damage / 2));
+ const name = token.get('name') || character.get('name');
+ const { success: S, failure: F } = getOutcomeLines(name);
+ const { randomize } = getConfig();
+
+ let expr = `1d20 + ${bonus}`;
+ if (mode === 'adv') expr = `2d20kh1 + ${bonus}`;
+ if (mode === 'dis') expr = `2d20kl1 + ${bonus}`;
+
+ modState.runtime.lastDamage[msg.playerid] = damage;
+
+ sendChat('', `[[${expr}]]`, ops => {
+ const roll = ops[0].inlinerolls?.[0];
+ if (!roll) {
+ return sendChat('ConcentrationTracker',
+ `/w "${player}" ⚠️ Roll failed.`
+ );
+ }
+ const total = roll.results.total;
+ const formula = roll.expression;
+ const vals = roll.results.rolls[0].results.map(r => r.v);
+ const rollsText = (mode === 'normal' ? vals[0] : vals.join(','));
+ const ok = total >= dc;
+
+ sendResult(player, dc, total, rollsText, formula);
+
+ const pool = ok ? S : F;
+ const tail = randomize
+ ? pool[Math.floor(Math.random() * pool.length)]
+ : pool[0];
+ const emote = tail;
+ sendChat(`character|${character.id}`, `/em ${emote}`);
+ toggleMarker(token, ok);
+ });
+ }
+
+ function showStatus(player) {
+ const page = Campaign().get('playerpageid');
+ const tokens = findObjs({
+ _type: 'graphic', _pageid: page, layer: 'objects'
+ }).filter(t =>
+ (t.get('statusmarkers') || '').toLowerCase().includes(TOKEN_MARKER.toLowerCase())
+ );
+ if (!tokens.length) {
+ return sendChat('ConcentrationTracker',
+ `/w "${player}" No tokens concentrating.`
+ );
+ }
+ let out = `&{template:default} {{name=🧠 Concentration Status}}`;
+ tokens.forEach(t => {
+ out += `{{${t.get('name') || 'Unnamed'}=Concentrating}}`;
+ });
+ sendChat('ConcentrationTracker', `/w "${player}" ${out}`);
+ }
+
+ function handleClear(msg) {
+ const player = msg.who.replace(/ \(GM\)$/, '');
+ msg.selected?.forEach(sel => {
+ const t = getObj('graphic', sel._id);
+ if (t) toggleMarker(t, false);
+ });
+ sendChat('ConcentrationTracker', `/w "${player}" ✅ Cleared markers.`);
+ }
+
+ function handleLast(msg) {
+ const player = msg.who.replace(/ \(GM\)$/, '');
+ const dmg = modState.runtime.lastDamage[msg.playerid];
+ if (!dmg) {
+ return sendChat('ConcentrationTracker',
+ `/w "${player}" ⚠️ No previous damage.`
+ );
+ }
+ handleRoll(msg, dmg, 'normal');
+ }
+
+ function showHelp(player) {
+ const helpText = [
+ "🧠 Concentration Help:",
+ "• !concentration / !cc → Show buttons",
+ "• --damage X → Roll vs X",
+ "• --mode normal|adv|dis",
+ "• --off → Remove marker",
+ "• --last → Repeat last",
+ "• --status → Who is concentrating",
+ "• --config randomize on|off"
+ ].join('
');
+ sendChat('ConcentrationTracker', `/w "${player}" ${helpText}`);
+ }
+
+ function handler(msg) {
+ if (msg.type !== 'api') return;
+ const parts = msg.content.trim().split(/\s+--/);
+ const cmd = parts.shift();
+ if (!CMDS.includes(cmd)) return;
+
+ const player = msg.who.replace(/ \(GM\)$/, '');
+
+ if (parts[0]?.startsWith('config ')) {
+ const [, key, val] = parts[0].split(/\s+/);
+ if (key === 'randomize') {
+ modState.config.randomize = (val === 'on' || val === 'true');
+ return sendChat('ConcentrationTracker',
+ `/w "${player}" ✅ Randomize = ${modState.config.randomize}`
+ );
+ }
+ return sendChat('ConcentrationTracker',
+ `/w "${player}" ❌ Unknown config ${key}`
+ );
+ }
+
+ let damage = 0, mode = 'normal';
+ for (let p of parts) {
+ if (p === 'help') return showHelp(player);
+ if (p === 'status') return showStatus(player);
+ if (p === 'last') return handleLast(msg);
+ if (p === 'off') return handleClear(msg);
+ if (p.startsWith('damage ')) damage = parseInt(p.split(' ')[1], 10);
+ if (p.startsWith('mode ')) mode = p.split(' ')[1];
+ }
+
+ if (damage > 0) {
+ handleRoll(msg, damage, mode);
+ } else {
+ postButtons(player);
+ }
+ }
+
+ CMDS.forEach(pref =>
+ GameAssist.onCommand(pref, handler, 'ConcentrationTracker', { gmOnly: false })
+ );
+ GameAssist.log('ConcentrationTracker', `Ready: ${CMDS.join(' & ')}`);
+}, {
+ enabled: true,
+ prefixes: ['!concentration', '!cc'],
+ teardown: () => {
+ const page = Campaign().get('playerpageid');
+ findObjs({ _type: 'graphic', _pageid: page, layer: 'objects' })
+ .filter(t =>
+ (t.get('statusmarkers') || '').toLowerCase().includes('concentrating')
+ )
+ .forEach(t =>
+ sendChat('api',
+ `!token-mod --ids ${t.id} --set statusmarkers|-Concentrating`
+ )
+ );
+ }
+});
+
+ // ————— NPC HP ROLLER MODULE v0.1.1.0 —————
+ GameAssist.register('NPCHPRoller', function() {
+ const modState = getState('NPCHPRoller');
+
+ Object.assign(modState.config, {
+ enabled: true,
+ autoRollOnAdd: false,
+ ...modState.config
+ });
+
+ function parseDiceString(diceStr) {
+ // Match “NdM”, “NdM+K”, “NdM + K”, “NdM-K”, case-insensitive on “d”
+ const match = diceStr.match(
+ /^\s*(\d+)\s*[dD]\s*(\d+)(?:\s*([+-])\s*(\d+))?\s*$/
+ );
+ if (!match) return null;
+
+ const count = parseInt(match[1], 10);
+ const sides = parseInt(match[2], 10);
+ const sign = match[3] === '-' ? -1 : 1;
+ const bonus = match[4] ? sign * parseInt(match[4], 10) : 0;
+
+ return { count, sides, bonus };
+ }
+
+ function rollDice(count, sides) {
+ let total = 0;
+ for (let i = 0; i < count; i++) {
+ total += Math.floor(Math.random() * sides) + 1;
+ }
+ return total;
+ }
+
+ function rollHP(diceData) {
+ const { count, sides, bonus } = diceData;
+ return rollDice(count, sides) + bonus;
+ }
+
+ function rollTokenHP(token) {
+ const charId = token.get('represents');
+ if (!charId) {
+ GameAssist.log('NPCHPRoller', 'Token not linked to character', 'WARN');
+ return;
+ }
+
+ const npcAttr = findObjs({
+ _type: 'attribute',
+ _characterid: charId,
+ name: 'npc'
+ })[0];
+
+ if (!npcAttr || npcAttr.get('current') !== '1') {
+ return;
+ }
+
+ const hpFormulaAttr = findObjs({
+ _type: 'attribute',
+ _characterid: charId,
+ name: 'npc_hpformula'
+ })[0];
+
+ if (!hpFormulaAttr) {
+ GameAssist.log('NPCHPRoller', `No HP formula found for ${token.get('name')}`, 'WARN');
+ return;
+ }
+
+ const formula = hpFormulaAttr.get('current');
+ const diceData = parseDiceString(formula);
+
+ if (!diceData) {
+ GameAssist.log('NPCHPRoller', `Invalid HP formula: ${formula}`, 'WARN');
+ return;
+ }
+
+ const hp = rollHP(diceData);
+
+ token.set('bar1_value', hp);
+ token.set('bar1_max', hp);
+
+ GameAssist.log('NPCHPRoller', `${token.get('name')} HP set to ${hp} using [${formula}]`);
+ }
+
+ GameAssist.onCommand('!npc-hp-all', async msg => {
+ const pageId = Campaign().get('playerpageid');
+ const tokens = findObjs({
+ _pageid: pageId,
+ _type: 'graphic',
+ layer: 'objects'
+ });
+
+ const npcTokens = [];
+
+ for (const token of tokens) {
+ const characterId = token.get('represents');
+ if (!characterId) continue;
+
+ const npcAttr = findObjs({
+ _type: 'attribute',
+ _characterid: characterId,
+ name: 'npc'
+ })[0];
+
+ if (npcAttr && npcAttr.get('current') === '1') {
+ npcTokens.push(token);
+ }
+ }
+
+ GameAssist.log('NPCHPRoller', `Rolling HP for ${npcTokens.length} NPCs on current map...`);
+
+ for (const token of npcTokens) {
+ try {
+ rollTokenHP(token);
+ } catch (err) {
+ GameAssist.log('NPCHPRoller', `Error processing ${token.get('name')}: ${err.message}`, 'ERROR');
+ }
+ }
+ }, 'NPCHPRoller', { gmOnly: true });
+
+ GameAssist.onCommand('!npc-hp-selected', msg => {
+ if (!msg.selected || msg.selected.length === 0) {
+ GameAssist.log('NPCHPRoller', 'No tokens selected', 'WARN');
+ return;
+ }
+
+ msg.selected.forEach(sel => {
+ const token = getObj('graphic', sel._id);
+ if (token) {
+ try {
+ rollTokenHP(token);
+ } catch (err) {
+ GameAssist.log('NPCHPRoller', `Error processing ${token.get('name')}: ${err.message}`, 'ERROR');
+ }
+ }
+ });
+ }, 'NPCHPRoller', { gmOnly: true });
+
+ GameAssist.log('NPCHPRoller', 'v0.1.1.0 Ready: !npc-hp-all, !npc-hp-selected');
+ }, {
+ enabled: true,
+ events: [],
+ prefixes: ['!npc-hp-all', '!npc-hp-selected']
+ });
+
+ // ————— BOOTSTRAP —————
+ on('ready', () => {
+ if (READY) return;
+ READY = true;
+
+ state[STATE_KEY] = state[STATE_KEY] || { config: {} };
+ GameAssist.config = state[STATE_KEY].config;
+
+ GameAssist._clearAllListeners();
+ seedDefaults();
+ auditState();
+ GameAssist._dedupePlanned();
+ auditCompatibility();
+
+ GameAssist.log('Core', `GameAssist v${VERSION} ready; modules: ${Object.keys(MODULES).join(', ')}`);
+
+ Object.entries(MODULES).forEach(([name, m]) => {
+ if (getState(name).config.enabled) {
+ m.initialized = true;
+ try { m.initFn(); }
+ catch(e) { GameAssist.handleError(name, e); }
+ }
+ });
+ });
+
+})();
diff --git a/GameAssist/0.1.1.1/GameAssist.js b/GameAssist/0.1.1.1/GameAssist.js
new file mode 100644
index 000000000..e954e2fe0
--- /dev/null
+++ b/GameAssist/0.1.1.1/GameAssist.js
@@ -0,0 +1,1231 @@
+// =============================
+// === GameAssist v0.1.1.1 ===
+// === Author: Mord Eagle ===
+// =============================
+// Released under the MIT License (see https://opensource.org/licenses/MIT)
+//
+// Copyright (c) 2025 Mord Eagle
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+// THE SOFTWARE.
+
+(() => {
+ 'use strict';
+
+ const VERSION = '0.1.1.1';
+ const STATE_KEY = 'GameAssist';
+ const MODULES = {};
+ let READY = false;
+
+ // ————— QUEUE + WATCHDOG —————
+ let _busy = false;
+ let _lastStart = 0;
+ const _queue = [];
+ const DEFAULT_TIMEOUT = 30000;
+ const WATCHDOG_INTERVAL = 15000;
+
+ function _enqueue(task, priority = 0, timeout = DEFAULT_TIMEOUT) {
+ _queue.push({ task, priority, enqueuedAt: Date.now(), timeout });
+ _queue.sort((a,b) => b.priority - a.priority || a.enqueuedAt - b.enqueuedAt);
+ _runNext();
+ }
+
+ function _runNext() {
+ if (_busy || !_queue.length) return;
+ const { task, timeout } = _queue.shift();
+ _busy = true;
+ _lastStart = Date.now();
+
+ const timer = setTimeout(() => {
+ GameAssist.log('Core', `Task timed out after ${timeout}ms`, 'WARN');
+ _busy = false;
+ _runNext();
+ }, timeout);
+
+ Promise.resolve()
+ .then(task)
+ .catch(err => GameAssist.log('Core', `Error in task: ${err.message}`, 'ERROR'))
+ .finally(() => {
+ clearTimeout(timer);
+ _busy = false;
+ const duration = Date.now() - _lastStart;
+ GameAssist._metrics.taskDurations.push(duration);
+ GameAssist._metrics.lastUpdate = new Date().toISOString();
+ _runNext();
+ });
+ }
+
+ setInterval(() => {
+ if (_busy && Date.now() - _lastStart > DEFAULT_TIMEOUT * 2) {
+ GameAssist.log('Core', 'Watchdog forced queue reset', 'WARN');
+ _busy = false;
+ _runNext();
+ }
+ }, WATCHDOG_INTERVAL);
+
+ // ————— HANDLER TRACKING —————
+ globalThis._handlers = globalThis._handlers || {};
+ const originalOn = typeof globalThis.on === 'function' ? globalThis.on : null;
+ const originalOff = typeof globalThis.off === 'function' ? globalThis.off : null;
+
+ globalThis.on = (event, handler) => {
+ globalThis._handlers[event] = globalThis._handlers[event] || [];
+ globalThis._handlers[event].push(handler);
+ if (typeof originalOn === 'function') {
+ return originalOn(event, handler);
+ }
+ };
+
+ globalThis.off = (event, handler) => {
+ if (!globalThis._handlers[event]) return;
+ globalThis._handlers[event] = globalThis._handlers[event].filter(h => h !== handler);
+ if (typeof originalOff === 'function') {
+ return originalOff(event, handler);
+ }
+ };
+
+ // ————— UTILITIES —————
+ function _parseArgs(content) {
+ const args = {}, pattern = /--(\w+)(?:\s+("[^"]*"|[^\s]+))?/g;
+ let m;
+ while ((m = pattern.exec(content))) {
+ let v = m[2] || true;
+ if (typeof v === 'string') {
+ if (/^".*"$/.test(v)) v = v.slice(1, -1);
+ else if (/^\d+$/.test(v)) v = parseInt(v, 10);
+ else if (/,/.test(v)) v = v.split(',');
+ }
+ args[m[1]] = v;
+ }
+ return { cmd: content.split(/\s+/)[0], args };
+ }
+
+ function getState(mod) {
+ state[STATE_KEY] = state[STATE_KEY] || { config: {} };
+ state[STATE_KEY][mod] = state[STATE_KEY][mod] || { config: {}, runtime: {} };
+ return state[STATE_KEY][mod];
+ }
+ function saveState(mod, data) {
+ state[STATE_KEY] = state[STATE_KEY] || { config: {} };
+ state[STATE_KEY][mod] = Object.assign(getState(mod), data);
+ }
+ function clearState(mod) {
+ if (state[STATE_KEY]?.[mod]) delete state[STATE_KEY][mod];
+ }
+
+ function auditState() {
+ const root = state[STATE_KEY] || {};
+ Object.keys(root).forEach(k => {
+ if (k === 'config') return;
+ if (!MODULES[k]) {
+ GameAssist.log('Core', `Unexpected state branch: ${k}`, 'WARN');
+ delete root[k];
+ } else {
+ const branch = root[k];
+ if (!branch.config || !branch.runtime) {
+ GameAssist.log('Core', `Malformed state for ${k}`, 'WARN');
+ delete root[k];
+ }
+ }
+ });
+ GameAssist._metrics.stateAudits++;
+ GameAssist._metrics.lastUpdate = new Date().toISOString();
+ }
+
+ function seedDefaults() {
+ Object.entries(MODULES).forEach(([name, mod]) => {
+ const cfg = getState(name).config;
+ if (cfg.enabled === undefined) cfg.enabled = mod.enabled;
+ });
+ }
+
+ function _sanitize(str = '') {
+ return str.toString()
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/'/g, ''');
+ }
+
+ // ————— COMPATIBILITY —————
+ const KNOWN_SCRIPTS = [
+ 'tokenmod.js','universaltvttimporter.js','npc-hp.js','wolfpack.js',
+ 'critfumble.js','rana-curse.js','statusinfo.js','npc death tracker.js',
+ 'customizable roll listener.js','5th edition ogl by roll20 companion.js'
+ ];
+ function normalizeScriptName(n) {
+ return (n||'')
+ .toLowerCase()
+ .replace(/\.js$/, '')
+ .replace(/[\s_]+/g, '-')
+ .replace(/[^\w-]/g, '');
+ }
+ function auditCompatibility() {
+ if (!GameAssist.flags.DEBUG_COMPAT) return;
+ const known = KNOWN_SCRIPTS.map(normalizeScriptName);
+ const active = Object.keys(state.api?.scripts || {}).map(normalizeScriptName);
+ const good = active.filter(n => known.includes(n));
+ const bad = active.filter(n => !known.includes(n));
+ GameAssist.log('Compat', '✅ Known: ' + (good.join(', ') || 'none'));
+ GameAssist.log('Compat', '❓ Unknown: ' + (bad.join(', ') || 'none'));
+ GameAssist.log('Compat', '🔌 Events: ' + GameAssist._plannedEvents.join(', '));
+ GameAssist.log('Compat', '💬 Commands: ' + GameAssist._plannedChatPrefixes.join(', '));
+ }
+
+ // ————— CONFIG PARSER —————
+ function parseConfigValue(raw) {
+ raw = raw.trim();
+ if (raw === 'true') return true;
+ if (raw === 'false') return false;
+ if (!isNaN(raw)) return Number(raw);
+ if ((raw.startsWith('{') && raw.endsWith('}')) || (raw.startsWith('[') && raw.endsWith(']'))) {
+ try { return JSON.parse(raw); }
+ catch { GameAssist.log('Config', 'Invalid JSON: ' + _sanitize(raw)); }
+ }
+ return raw;
+ }
+
+ // ————— GameAssist CORE —————
+ const GameAssist = {
+ _metrics: {
+ commands: 0,
+ messages: 0,
+ errors: 0,
+ stateAudits: 0,
+ taskDurations: [],
+ lastUpdate: null
+ },
+ _plannedEvents: [],
+ _plannedChatPrefixes: [],
+ _listeners: {},
+ _commandHandlers: {},
+ _eventHandlers: {},
+ config: {},
+ flags: { DEBUG_COMPAT: false, QUIET_STARTUP: true },
+
+ log(mod, msg, level = 'INFO', { startup = false } = {}) {
+ if (startup && GameAssist.flags.QUIET_STARTUP) return;
+
+ const timestamp = new Date().toLocaleTimeString();
+ const levelIcon = { INFO: 'ℹ️', WARN: '⚠️', ERROR: '❌' }[level] || 'ℹ️';
+
+ // escape user-supplied text, then split on newlines
+ const safe = _sanitize(msg).split('\n');
+
+ // prepend /w gm to every continuation line so Roll20 treats
+ // the whole block as one whisper
+ const stitched = safe.map((l, i) => (i ? '/w gm ' + l : l)).join('\n');
+
+ sendChat(
+ 'GameAssist',
+ `/w gm ${levelIcon} [${timestamp}] [${mod}] ${stitched}`
+ );
+ },
+
+ handleError(mod, err) {
+ this._metrics.errors++;
+ this._metrics.lastUpdate = new Date().toISOString();
+ this.log(mod, err.message || String(err), 'ERROR');
+ },
+
+ register(name, initFn, { enabled = true, events = [], prefixes = [], teardown = null } = {}) {
+ if (READY) {
+ this.log('Core', `Cannot register after ready: ${name}`, 'WARN');
+ return;
+ }
+ if (MODULES[name]) {
+ this.log('Core', `Duplicate module: ${name}`, 'WARN');
+ return;
+ }
+ MODULES[name] = { initFn, teardown, enabled, initialized: false, events, prefixes };
+ this._plannedEvents.push(...events);
+ this._plannedChatPrefixes.push(...prefixes);
+ },
+
+ onCommand(prefix, fn, mod, { gmOnly = false, acl = [] } = {}) {
+ const wrapped = msg => {
+ if (msg.type !== 'api' || !msg.content.startsWith(prefix)) return;
+ if (gmOnly && !playerIsGM(msg.playerid)) return;
+ if (acl.length && !acl.includes(msg.playerid)) return;
+ this._metrics.commands++;
+ this._metrics.lastUpdate = new Date().toISOString();
+ try { fn(msg); }
+ catch(e) { this.handleError(mod, e); }
+ };
+ on('chat:message', wrapped);
+ this._commandHandlers[mod] = (this._commandHandlers[mod] || []).concat({ event:'chat:message', fn:wrapped });
+ },
+
+ offCommands(mod) {
+ (this._commandHandlers[mod] || []).forEach(h => off(h.event, h.fn));
+ this._commandHandlers[mod] = [];
+ },
+
+ onEvent(evt, fn, mod) {
+ const wrapped = (...args) => {
+ if (!READY || !MODULES[mod].initialized) return;
+ this._metrics.messages++;
+ this._metrics.lastUpdate = new Date().toISOString();
+ try { fn(...args); }
+ catch(e) { this.handleError(mod, e); }
+ };
+ on(evt, wrapped);
+ this._listeners[mod] = (this._listeners[mod] || []).concat({ event:evt, fn:wrapped });
+ },
+
+ offEvents(mod) {
+ (this._listeners[mod] || []).forEach(h => off(h.event, h.fn));
+ this._listeners[mod] = [];
+ },
+
+ _clearAllListeners() {
+ Object.keys(this._commandHandlers).forEach(m => this.offCommands(m));
+ Object.keys(this._listeners).forEach(m => this.offEvents(m));
+ },
+
+ _dedupePlanned() {
+ this._plannedEvents = [...new Set(this._plannedEvents)];
+ this._plannedChatPrefixes = [...new Set(this._plannedChatPrefixes)];
+ },
+
+ enableModule(name) {
+ _enqueue(() => {
+ const m = MODULES[name];
+ if (!m) { this.log('Core', `No such module: ${name}`, 'WARN'); return; }
+ this.offEvents(name);
+ this.offCommands(name);
+ clearState(name);
+ getState(name).config.enabled = true;
+ m.initialized = true;
+ try { m.initFn(); this.log(name, 'Enabled'); }
+ catch(e) { this.handleError(name, e); }
+ });
+ },
+
+ disableModule(name) {
+ _enqueue(() => {
+ const m = MODULES[name];
+ if (!m) { this.log('Core', `No such module: ${name}`, 'WARN'); return; }
+ if (typeof m.teardown === 'function') {
+ try { m.teardown(); }
+ catch(e) { this.log(name, `Teardown failed: ${e.message}`, 'WARN'); }
+ }
+ this.offEvents(name);
+ this.offCommands(name);
+ clearState(name);
+ getState(name).config.enabled = false;
+ m.initialized = false;
+ this.log(name, 'Disabled');
+ });
+ }
+ };
+
+ globalThis.GameAssist = GameAssist;
+
+ // ————— CONFIG COMMAND —————
+ GameAssist.onCommand('!ga-config', msg => {
+ const parts = msg.content.trim().split(/\s+/);
+ const sub = parts[1];
+ if (sub === 'list') {
+ const ts = new Date().toLocaleString();
+ const ver = `v${VERSION}`;
+ const cfg = JSON.stringify(state[STATE_KEY].config, null, 2)
+ .replace(/[<>&]/g, c=>({'<':'<','>':'>','&':'&'})[c]);
+ const name = 'GameAssist Config';
+ let handout = findObjs({ type:'handout', name })[0];
+ if (!handout) handout = createObj('handout', { name, archived:false });
+ handout.set('notes', `Generated: ${ts} (${ver})\n\n${cfg}
`);
+ sendChat('GameAssist', `/w gm Config written to "${name}"`);
+ }
+ else if (sub === 'set' && parts.length >= 4) {
+ const mod = parts[2];
+ const [ key, ...rest ] = parts.slice(3).join(' ').split('=');
+ const val = rest.join('=');
+ const parsed = parseConfigValue(val);
+ if (!MODULES[mod]) {
+ GameAssist.log('Config', `Unknown module: ${mod}`, 'WARN');
+ return;
+ }
+ getState(mod).config[key.trim()] = parsed;
+ GameAssist.log('Config', `Set ${mod}.${key.trim()} = ${JSON.stringify(parsed)}`);
+ }
+ else if (sub === 'get' && parts.length >= 4) {
+ const mod = parts[2];
+ const key = parts[3];
+ if (!MODULES[mod]) {
+ GameAssist.log('Config', `Unknown module: ${mod}`, 'WARN');
+ return;
+ }
+ const val = getState(mod).config[key];
+ GameAssist.log('Config', `${mod}.${key} = ${JSON.stringify(val)}`);
+ }
+ else if (sub === 'modules') {
+ const moduleList = Object.entries(MODULES).map(([name, mod]) => {
+ const cfg = getState(name).config;
+ const status = cfg.enabled ? '✅' : '❌';
+ const init = mod.initialized ? '🔄' : '⏸️';
+ return `${status}${init} ${name}`;
+ }).join('\n');
+ GameAssist.log('Config', `Modules:\n${moduleList}`);
+ }
+ else {
+ GameAssist.log('Config', 'Usage: !ga-config list|set|get|modules [args]');
+ }
+ }, 'Core', { gmOnly: true });
+
+ // ————— CONTROL COMMANDS —————
+ GameAssist.onCommand('!ga-enable', msg => {
+ const mod = msg.content.split(/\s+/)[1];
+ if (!mod) {
+ GameAssist.log('Core', 'Usage: !ga-enable ', 'WARN');
+ return;
+ }
+ GameAssist.enableModule(mod);
+ }, 'Core', { gmOnly: true });
+
+ GameAssist.onCommand('!ga-disable', msg => {
+ const mod = msg.content.split(/\s+/)[1];
+ if (!mod) {
+ GameAssist.log('Core', 'Usage: !ga-disable ', 'WARN');
+ return;
+ }
+ GameAssist.disableModule(mod);
+ }, 'Core', { gmOnly: true });
+
+ GameAssist.onCommand('!ga-status', msg => {
+ const metrics = GameAssist._metrics;
+ const avgDuration = metrics.taskDurations.length > 0
+ ? (metrics.taskDurations.reduce((a,b) => a+b, 0) / metrics.taskDurations.length).toFixed(2)
+ : 'N/A';
+
+ const status = [
+ `**GameAssist ${VERSION} Status**`,
+ `Commands: ${metrics.commands}`,
+ `Messages: ${metrics.messages}`,
+ `Errors: ${metrics.errors}`,
+ `Avg Task Duration: ${avgDuration}ms`,
+ `Queue Length: ${_queue.length}`,
+ `Last Update: ${metrics.lastUpdate || 'Never'}`,
+ `Modules: ${Object.keys(MODULES).length}`,
+ `Active Listeners: ${Object.values(GameAssist._listeners).flat().length}`
+ ].join('\n');
+
+ GameAssist.log('Status', status);
+ }, 'Core', { gmOnly: true });
+
+ // ————— CRITFUMBLE MODULE v0.2.4.8 —————
+ GameAssist.register('CritFumble', function() {
+ // ─── Module Setup ──────────────────────────────────────────────────────────────
+ const modState = getState('CritFumble');
+ Object.assign(modState.config, {
+ enabled: true,
+ debug: true,
+ useEmojis: true,
+ rollDelayMs: 200,
+ // Preserve any values previously saved in state
+ ...modState.config
+ });
+ modState.runtime.activePlayers = modState.runtime.activePlayers || {};
+
+ // ─── Constants ─────────────────────────────────────────────────────────────────
+ /** Which Roll20 rolltemplates we watch for natural-1s */
+ const VALID_TEMPLATES = ['atk','atkdmg','npcatk','spell'];
+ const FUMBLE_TABLES = {
+ melee: 'CF-Melee',
+ ranged: 'CF-Ranged',
+ spell: 'CF-Spell',
+ natural: 'CF-Natural',
+ thrown: 'CF-Thrown'
+ };
+ // Lookup for confirm tables
+ const CONFIRM_TABLES = {
+ 'confirm-crit-martial': 'Confirm-Crit-Martial',
+ 'confirm-crit-magic': 'Confirm-Crit-Magic'
+ };
+
+ // ─── Helper Functions ──────────────────────────────────────────────────────────
+ /**
+ * debugLog(msg)
+ * Logs to the GM only when debug mode is on.
+ * Uses GameAssist.log under the hood.
+ */
+ function debugLog(msg) {
+ if (modState.config.debug) {
+ GameAssist.log('CritFumble', msg);
+ }
+ }
+ function emoji(sym) {
+ return modState.config.useEmojis ? sym : '';
+ }
+
+ // Strip off any " (GM)" suffix so /w target resolves
+ function sanitizeWho(who) {
+ return who.replace(/ \(GM\)$/, '');
+ }
+
+ function sendTemplateMessage(who,title,fields) {
+ who = sanitizeWho(who);
+ const content = fields.map(f=>`{{${f.label}=${f.value}}}`).join(' ');
+ sendChat('CritFumble', `/w "${who}" &{template:default} {{name=${title}}} ${content}`);
+ }
+
+ function getFumbleTableName(type) {
+ return FUMBLE_TABLES[type]||null;
+ }
+
+ function sendConfirmMenu(who) {
+ const buttons = [
+ `[Confirm-Crit-Martial](!Confirm-Crit-Martial)`,
+ `[Confirm-Crit-Magic](!Confirm-Crit-Magic)`
+ ].join(' ');
+ sendTemplateMessage(who, `${emoji('❓')} Confirm Critical Miss`, [
+ { label: "Choose Confirmation Type", value: buttons }
+ ]);
+ }
+
+ function sendFumbleMenu(who) {
+ sendConfirmMenu(who);
+ const buttons = [
+ `[⚔ Melee](!critfumble-melee)`,
+ `[🏹 Ranged](!critfumble-ranged)`,
+ `[🎯 Thrown](!critfumble-thrown)`,
+ `[🔥 Spell](!critfumble-spell)`,
+ `[👊 Natural/Unarmed](!critfumble-natural)`
+ ].join(' ');
+ sendTemplateMessage(who, `${emoji('💥')} Critical Miss!`, [
+ { label: "What kind of attack was this?", value: buttons }
+ ]);
+ // also whisper to GM for awareness
+ sendTemplateMessage('gm', `${emoji('💥')} Critical Miss for ${who}!`, [
+ { label: "What kind of attack was this?", value: buttons }
+ ]);
+ }
+
+ function announceTableRoll(tableName) {
+ sendTemplateMessage('gm', `${emoji('🎲')} Rolling Table`, [
+ { label: "Table", value: `**${tableName}**` }
+ ]);
+ }
+ function executeTableRoll(tableName) {
+ setTimeout(()=>{
+ sendChat('', `/roll 1t[${tableName}]`);
+ debugLog(`Roll command executed: /roll 1t[${tableName}]`);
+ }, modState.config.rollDelayMs);
+ }
+
+ function rollFumbleTable(who,type) {
+ const table = getFumbleTableName(type);
+ if (!table) {
+ sendTemplateMessage(who, "⚠️ Invalid Fumble Type", [
+ { label: "Requested", value: `"${type}"` },
+ { label: "Valid Types", value: Object.keys(FUMBLE_TABLES).join(', ') }
+ ]);
+ debugLog(`Invalid fumble type "${type}"`);
+ return;
+ }
+ announceTableRoll(table);
+ executeTableRoll(table);
+ }
+
+ function rollConfirmTable(who,rawCommand) {
+ const table = CONFIRM_TABLES[rawCommand.toLowerCase()];
+ if (!table) {
+ sendTemplateMessage(who, "⚠️ Invalid Confirm Type", [
+ { label: "Requested", value: `"${rawCommand}"` },
+ { label: "Valid Options", value: Object.values(CONFIRM_TABLES).join(', ') }
+ ]);
+ debugLog(`Invalid confirm type "${rawCommand}"`);
+ return;
+ }
+ announceTableRoll(table);
+ executeTableRoll(table);
+ }
+
+ function hasNaturalOne(inlinerolls=[]) {
+ return inlinerolls.some(group=>{
+ if (!group.results||!Array.isArray(group.results.rolls)) return false;
+ return group.results.rolls.some(roll=>{
+ if (roll.type!=='R'||roll.sides!==20) return false;
+ const results = Array.isArray(roll.results)? roll.results : [roll.results];
+ return results.some(r=> (r.r===true||r.r===undefined) && r.v===1 );
+ });
+ });
+ }
+
+ function showManualTriggerMenu() {
+ const players = Object.values(modState.runtime.activePlayers);
+ if (!players.length) {
+ sendTemplateMessage('gm', "⚠️ No Players Detected", [
+ { label:"Note", value:"No players have been active yet this session." }
+ ]);
+ return;
+ }
+ const buttons = players.map(name=>
+ `[${name}](!critfumblemenu-${encodeURIComponent(name)})`
+ ).join(' ');
+ sendTemplateMessage('gm',"Manually Trigger Fumble Menu",[
+ { label:"Select Player", value:buttons }
+ ]);
+ }
+
+ function handleManualTrigger(encodedName) {
+ sendFumbleMenu(decodeURIComponent(encodedName));
+ debugLog(`Manually triggered fumble menu for: ${encodedName}`);
+ }
+
+ function showHelpMessage(who) {
+ sendTemplateMessage(who, "📘 CritFumble Help", [
+ { label: "Version", value: "v0.2.4.8" },
+ { label: "Commands", value: "`!critfail`, `!critfumble help`, `!critfumble-`, `!confirm-crit-martial`, `!confirm-crit-magic`" },
+ { label: "Description", value: "Auto-detects critical misses and prompts attacker with a fumble menu; GM can also manually trigger via `!critfail`." },
+ { label: "Valid Types", value: Object.keys(FUMBLE_TABLES).join(', ') }
+ ]);
+ }
+
+ function handleRoll(msg) {
+ if (!msg) return;
+ // register active players
+ if (msg.playerid && !modState.runtime.activePlayers[msg.playerid]) {
+ const p = getObj('player', msg.playerid);
+ if (p) modState.runtime.activePlayers[msg.playerid] = p.get('displayname');
+ }
+
+ // API‐style commands
+ if (msg.type==='api') {
+ const cmd = (msg.content||'').trim().toLowerCase();
+
+ if (cmd==='!critfail') {
+ debugLog('Manual trigger: !critfail');
+ return showManualTriggerMenu();
+ }
+ if (cmd==='!critfumble help') {
+ return showHelpMessage(msg.who);
+ }
+ if (cmd.startsWith('!critfumblemenu-')) {
+ return handleManualTrigger(msg.content.replace('!critfumblemenu-',''));
+ }
+ if (cmd.startsWith('!critfumble-')) {
+ const who = sanitizeWho(msg.who);
+ const fumbleType = msg.content.replace('!critfumble-','').toLowerCase();
+ debugLog(`${who} selected fumble type: ${fumbleType}`);
+ return rollFumbleTable(who, fumbleType);
+ }
+ if (cmd.startsWith('!confirm-crit-')) {
+ const who = sanitizeWho(msg.who);
+ const rawCommand = msg.content.slice(1); // e.g. "confirm-crit-martial"
+ debugLog(`${who} selected confirm type: ${rawCommand}`);
+ return rollConfirmTable(who, rawCommand);
+ }
+ return;
+ }
+
+ // auto-detect natural 1 on a valid rolltemplate
+ if (!msg.rolltemplate || !VALID_TEMPLATES.includes(msg.rolltemplate)) return;
+ const rolls = msg.inlinerolls||[];
+ if (!hasNaturalOne(rolls)) return;
+
+ const who = sanitizeWho(msg.who);
+ debugLog(`Fumble detected from: ${who}`);
+ sendFumbleMenu(who);
+ }
+
+ GameAssist.onEvent('chat:message', handleRoll, 'CritFumble');
+ GameAssist.log('CritFumble','v0.2.4.8 Ready: Auto fumble detection + !critfail','INFO',{startup:true});
+ }, {
+ enabled: true,
+ events: ['chat:message'],
+ prefixes: ['!critfail','!critfumble']
+ });
+
+ // ————— NPC MANAGER MODULE v0.1.1.0 —————
+ GameAssist.register('NPCManager', function() {
+ const modState = getState('NPCManager');
+
+ Object.assign(modState.config, {
+ enabled: true,
+ autoTrackDeath: true,
+ deadMarker: 'dead',
+ ...modState.config
+ });
+
+ function isNPC(token) {
+ if (!token || token.get('layer') !== 'objects') return false;
+ const charId = token.get('represents');
+ if (!charId) return false;
+
+ const npcAttr = findObjs({
+ _type: 'attribute',
+ _characterid: charId,
+ name: 'npc'
+ })[0];
+
+ return npcAttr && npcAttr.get('current') === '1';
+ }
+
+ function checkForDeath(token) {
+ if (!modState.config.autoTrackDeath || !isNPC(token)) return;
+
+ const hp = parseInt(token.get('bar1_value'), 10) || 0;
+ const markers = (token.get('statusmarkers') || '').split(',');
+ const isDead = markers.includes(modState.config.deadMarker);
+
+ if (hp < 1 && !isDead) {
+ sendChat('api', `!token-mod --ids ${token.id} --set statusmarkers|+${modState.config.deadMarker}`);
+ GameAssist.log('NPCManager', `${token.get('name')} marked as dead (HP: ${hp})`);
+ } else if (hp >= 1 && isDead) {
+ sendChat('api', `!token-mod --ids ${token.id} --set statusmarkers|-${modState.config.deadMarker}`);
+ GameAssist.log('NPCManager', `${token.get('name')} revived (HP: ${hp})`);
+ }
+ }
+
+ function handleTokenChange(obj, prev) {
+ if (obj.get('bar1_value') !== prev.bar1_value) {
+ checkForDeath(obj);
+ }
+ }
+
+ GameAssist.onCommand('!npc-death-report', msg => {
+ const pageId = Campaign().get('playerpageid');
+ const tokens = findObjs({
+ _pageid: pageId,
+ _type: 'graphic',
+ layer: 'objects'
+ });
+
+ const flagged = [];
+ for (let token of tokens) {
+ if (!isNPC(token)) continue;
+
+ const hp = parseInt(token.get('bar1_value'), 10) || 0;
+ const markers = (token.get('statusmarkers') || '').split(',');
+ const isDead = markers.includes(modState.config.deadMarker);
+
+ if ((hp < 1 && !isDead) || (hp >= 1 && isDead)) {
+ flagged.push({
+ name: token.get('name') || '(Unnamed)',
+ id: token.id,
+ hp,
+ markers: token.get('statusmarkers') || '(none)'
+ });
+ }
+ }
+
+ if (flagged.length === 0) {
+ GameAssist.log('NPCManager', '✅ Living NPCs have correct death marker states.');
+ } else {
+ GameAssist.log('NPCManager', `⚠️ ${flagged.length} NPC(s) with mismatched death markers:`);
+ flagged.forEach(({ name, id, hp, markers }) => {
+ GameAssist.log('NPCManager', `- ${name} [${id}] | HP: ${hp} | Markers: ${markers}`);
+ });
+ }
+ }, 'NPCManager', { gmOnly: true });
+
+ GameAssist.onEvent('change:graphic:bar1_value', handleTokenChange, 'NPCManager');
+ GameAssist.log('NPCManager', 'v0.1.1.0 Ready: Auto death tracking + !npc-death-report', 'INFO', { startup: true });
+ }, {
+ enabled: true,
+ events: ['change:graphic:bar1_value'],
+ prefixes: ['!npc-death-report']
+ });
+
+// ————— CONCENTRATION TRACKER MODULE v0.1.0.5 —————
+GameAssist.register('ConcentrationTracker', function() {
+ // ─── Module Setup ──────────────────────────────────────────────────────────────
+ const modState = getState('ConcentrationTracker');
+ Object.assign(modState.config, {
+ enabled: true,
+ marker: 'Concentrating',
+ randomize: true,
+ ...modState.config
+ });
+ modState.runtime.lastDamage = modState.runtime.lastDamage || {};
+
+ // ─── Public Command Prefixes ───────────────────────────────────────────────────
+ const CMDS = ['!concentration', '!cc'];
+ const TOKEN_MARKER = 'Concentrating';
+
+ // ─── Default Emote Lines ────────────────────────────────────────────────────────
+ const DEFAULT_LINES = {
+ success: [
+ "steadies their breath, holding their focus.",
+ "'s grip tightens as they maintain their spell.",
+ "staggers slightly but does not lose concentration.",
+ "clenches their jaw, magic still flickering with intent.",
+ "narrows their eyes, spell still intact."
+ ],
+ failure: [
+ "gasps, their focus shattered as the spell falters.",
+ "'s concentration breaks and the magic fades.",
+ "cries out, unable to maintain the spell.",
+ "'s spell fizzles as they lose control.",
+ "winces, focus lost in the heat of battle."
+ ]
+ };
+
+ // ─── Helper Functions ──────────────────────────────────────────────────────────
+
+ /**
+ * getConfig()
+ * Merge default settings with stored config.
+ */
+ function getConfig() {
+ return Object.assign({ randomize: true }, modState.config);
+ }
+
+ /**
+ * getOutcomeLines(name)
+ * Returns the success/failure emote arrays with {{name}} replaced.
+ */
+ function getOutcomeLines(name) {
+ const fill = line => line.replace("{{name}}", name);
+ return {
+ success: DEFAULT_LINES.success.map(fill),
+ failure: DEFAULT_LINES.failure.map(fill)
+ };
+ }
+
+ /**
+ * getConBonus(character)
+ * Reads the character's Constitution saving throw bonus.
+ */
+ function getConBonus(character) {
+ const attr = findObjs({
+ _type: 'attribute',
+ _characterid: character.id,
+ name: 'constitution_save_bonus'
+ })[0];
+ return attr ? parseInt(attr.get('current'), 10) : 0;
+ }
+
+ /**
+ * toggleMarker(token, on)
+ * Adds or removes the Concentrating status marker.
+ */
+ function toggleMarker(token, on) {
+ sendChat('api',
+ `!token-mod --ids ${token.id} --set statusmarkers|${on ? '+' : '-'}${TOKEN_MARKER}`
+ );
+ }
+
+ /**
+ * postButtons(recipient)
+ * Sends the three-button UI for a new concentration check.
+ */
+ function postButtons(recipient) {
+ const dmg = '?{Damage taken?|0}';
+ const buttons = [
+ `[🎯 Maintain Control](!concentration --damage ${dmg} --mode normal)`,
+ `[🧠 Brace for the Distraction](!concentration --damage ${dmg} --mode adv)`,
+ `[😣 Struggling to Focus](!concentration --damage ${dmg} --mode dis)`
+ ].join(' ');
+ sendChat('ConcentrationTracker',
+ `/w "${recipient}" ${buttons}
⚠️ Select your token before clicking.`
+ );
+ }
+
+ /**
+ * sendResult(player, dc, total, rolls, formula)
+ * Whispers the concentration-check result to player & GM.
+ */
+ function sendResult(player, dc, total, rolls, formula) {
+ const tpl =
+ `&{template:default} {{name=🧠 Concentration Check}}` +
+ ` {{DC=${dc}}} {{Result=Roll(s) ${rolls} → ${total} (from ${formula})}}`;
+ sendChat('ConcentrationTracker', `/w "${player}" ${tpl}`);
+ sendChat('ConcentrationTracker', `/w gm ${tpl}`);
+ }
+
+ /**
+ * showStatus(player)
+ * Lists all tokens currently marked Concentrating.
+ */
+ function showStatus(player) {
+ const page = Campaign().get('playerpageid');
+ const tokens = findObjs({
+ _type: 'graphic',
+ _pageid: page,
+ layer: 'objects'
+ }).filter(t =>
+ (t.get('statusmarkers') || '')
+ .toLowerCase()
+ .includes(TOKEN_MARKER.toLowerCase())
+ );
+ if (!tokens.length) {
+ return sendChat('ConcentrationTracker',
+ `/w "${player}" No tokens concentrating.`
+ );
+ }
+ let out = `&{template:default} {{name=🧠 Concentration Status}}`;
+ tokens.forEach(t => {
+ out += `{{${t.get('name') || 'Unnamed'}=Concentrating}}`;
+ });
+ sendChat('ConcentrationTracker', `/w "${player}" ${out}`);
+ }
+
+ /**
+ * showHelp(player)
+ * Whisper the full list of commands and usage.
+ */
+ function showHelp(player) {
+ const helpText = [
+ "🧠 Concentration Help:",
+ "• !concentration / !cc → Show buttons",
+ "• --damage X → Roll vs DC = max(10,⌊X/2⌋)",
+ "• --mode normal|adv|dis→ Set roll mode",
+ "• --last → Repeat last check",
+ "• --off → Remove marker from selected tokens",
+ "• --status → Who is concentrating",
+ "• --config randomize on|off → Toggle emote randomization"
+ ].join('
');
+ sendChat('ConcentrationTracker', `/w "${player}" ${helpText}`);
+ }
+
+ /**
+ * handleRoll(msg, damage, mode)
+ * Executes the concentration roll workflow.
+ */
+ function handleRoll(msg, damage, mode) {
+ const player = msg.who.replace(/ \(GM\)$/, '');
+ if (!msg.selected?.length) {
+ return sendChat('ConcentrationTracker',
+ `/w "${player}" ⚠️ No token selected.`
+ );
+ }
+ const token = getObj('graphic', msg.selected[0]._id);
+ if (!token) {
+ return sendChat('ConcentrationTracker',
+ `/w "${player}" ⚠️ Token not found.`
+ );
+ }
+ const charId = token.get('represents');
+ if (!charId) {
+ return sendChat('ConcentrationTracker',
+ `/w "${player}" ⚠️ Token not linked.`
+ );
+ }
+ const character = getObj('character', charId);
+ if (!character) {
+ return sendChat('ConcentrationTracker',
+ `/w "${player}" ⚠️ Character not found.`
+ );
+ }
+
+ const bonus = getConBonus(character);
+ const dc = Math.max(10, Math.floor(damage / 2));
+ const name = token.get('name') || character.get('name');
+ const { success: S, failure: F } = getOutcomeLines(name);
+ const { randomize } = getConfig();
+
+ let expr = `1d20 + ${bonus}`;
+ if (mode === 'adv') expr = `2d20kh1 + ${bonus}`;
+ if (mode === 'dis') expr = `2d20kl1 + ${bonus}`;
+
+ modState.runtime.lastDamage[msg.playerid] = damage;
+
+ sendChat('', `[[${expr}]]`, ops => {
+ const roll = ops[0].inlinerolls?.[0];
+ if (!roll) {
+ return sendChat('ConcentrationTracker',
+ `/w "${player}" ⚠️ Roll failed.`
+ );
+ }
+ const total = roll.results.total;
+ const formula = roll.expression;
+ const vals = roll.results.rolls[0].results.map(r => r.v);
+ const rollsText = (mode === 'normal' ? vals[0] : vals.join(','));
+ const ok = total >= dc;
+
+ sendResult(player, dc, total, rollsText, formula);
+
+ const pool = ok ? S : F;
+ const tail = randomize
+ ? pool[Math.floor(Math.random() * pool.length)]
+ : pool[0];
+ sendChat(`character|${character.id}`, `/em ${tail}`);
+ toggleMarker(token, ok);
+ });
+ }
+
+ /**
+ * handleClear(msg)
+ * Clears the marker from selected tokens.
+ */
+ function handleClear(msg) {
+ const player = msg.who.replace(/ \(GM\)$/, '');
+ msg.selected?.forEach(sel => {
+ const t = getObj('graphic', sel._id);
+ if (t) toggleMarker(t, false);
+ });
+ sendChat('ConcentrationTracker', `/w "${player}" ✅ Cleared markers.`);
+ }
+
+ /**
+ * handleLast(msg)
+ * Repeats the last concentration check.
+ */
+ function handleLast(msg) {
+ const player = msg.who.replace(/ \(GM\)$/, '');
+ const dmg = modState.runtime.lastDamage[msg.playerid];
+ if (!dmg) {
+ return sendChat('ConcentrationTracker',
+ `/w "${player}" ⚠️ No previous damage.`
+ );
+ }
+ handleRoll(msg, dmg, 'normal');
+ }
+
+ // ─── Core Handler (Case-Insensitive) ──────────────────────────────────────────
+ function handler(msg) {
+ if (msg.type !== 'api') return;
+
+ // 1) Normalize prefix: trim + lowercase
+ const raw = msg.content.trim();
+ const parts = raw.toLowerCase().split(/\s+--/);
+ const cmd = parts.shift(); // "!concentration" or "!cc"
+ if (!CMDS.includes(cmd)) return;
+
+ // 2) Identify player (strip " (GM)")
+ const player = msg.who.replace(/ \(GM\)$/, '');
+
+ // 3) Config branch
+ if (parts[0]?.startsWith('config ')) {
+ const [, key, val] = parts[0].split(/\s+/);
+ if (key === 'randomize') {
+ modState.config.randomize = (val === 'on' || val === 'true');
+ return sendChat('ConcentrationTracker',
+ `/w "${player}" ✅ Randomize = ${modState.config.randomize}`
+ );
+ }
+ return sendChat('ConcentrationTracker',
+ `/w "${player}" ❌ Unknown config ${key}`
+ );
+ }
+
+ // 4) Parse flags
+ let damage = 0, mode = 'normal';
+ for (let p of parts) {
+ if (p === 'help') return showHelp(player);
+ if (p === 'status') return showStatus(player);
+ if (p === 'last') return handleLast(msg);
+ if (p === 'off') return handleClear(msg);
+ if (p.startsWith('damage ')) damage = parseInt(p.split(' ')[1], 10);
+ if (p.startsWith('mode ')) mode = p.split(' ')[1];
+ }
+
+ // 5) Execute
+ if (damage > 0) {
+ handleRoll(msg, damage, mode);
+ } else {
+ postButtons(player);
+ }
+ }
+
+ // ─── Wire It Up ────────────────────────────────────────────────────────────────
+ GameAssist.onEvent('chat:message', handler, 'ConcentrationTracker');
+ GameAssist.log(
+ 'ConcentrationTracker',
+ `Ready: ${CMDS.join(' & ')}`,
+ 'INFO',
+ { startup: true }
+ );
+}, {
+ enabled: true,
+ prefixes: ['!concentration','!cc'],
+ teardown: () => {
+ const page = Campaign().get('playerpageid');
+ findObjs({ _type: 'graphic', _pageid: page, layer: 'objects' })
+ .filter(t =>
+ (t.get('statusmarkers') || '')
+ .toLowerCase()
+ .includes('concentrating')
+ )
+ .forEach(t =>
+ sendChat('api',
+ `!token-mod --ids ${t.id} --set statusmarkers|-Concentrating`
+ )
+ );
+ }
+});
+
+ // ————— NPC HP ROLLER MODULE v0.1.1.0 —————
+ GameAssist.register('NPCHPRoller', function() {
+ const modState = getState('NPCHPRoller');
+
+ Object.assign(modState.config, {
+ enabled: true,
+ autoRollOnAdd: false,
+ ...modState.config
+ });
+
+ function parseDiceString(diceStr) {
+ // Match “NdM”, “NdM+K”, “NdM + K”, “NdM-K”, case-insensitive on “d”
+ const match = diceStr.match(
+ /^\s*(\d+)\s*[dD]\s*(\d+)(?:\s*([+-])\s*(\d+))?\s*$/
+ );
+ if (!match) return null;
+
+ const count = parseInt(match[1], 10);
+ const sides = parseInt(match[2], 10);
+ const sign = match[3] === '-' ? -1 : 1;
+ const bonus = match[4] ? sign * parseInt(match[4], 10) : 0;
+
+ return { count, sides, bonus };
+ }
+
+ function rollDice(count, sides) {
+ let total = 0;
+ for (let i = 0; i < count; i++) {
+ total += Math.floor(Math.random() * sides) + 1;
+ }
+ return total;
+ }
+
+ function rollHP(diceData) {
+ const { count, sides, bonus } = diceData;
+ return rollDice(count, sides) + bonus;
+ }
+
+ function rollTokenHP(token) {
+ const charId = token.get('represents');
+ if (!charId) {
+ GameAssist.log('NPCHPRoller', 'Token not linked to character', 'WARN');
+ return;
+ }
+
+ const npcAttr = findObjs({
+ _type: 'attribute',
+ _characterid: charId,
+ name: 'npc'
+ })[0];
+
+ if (!npcAttr || npcAttr.get('current') !== '1') {
+ return;
+ }
+
+ const hpFormulaAttr = findObjs({
+ _type: 'attribute',
+ _characterid: charId,
+ name: 'npc_hpformula'
+ })[0];
+
+ if (!hpFormulaAttr) {
+ GameAssist.log('NPCHPRoller', `No HP formula found for ${token.get('name')}`, 'WARN');
+ return;
+ }
+
+ const formula = hpFormulaAttr.get('current');
+ const diceData = parseDiceString(formula);
+
+ if (!diceData) {
+ GameAssist.log('NPCHPRoller', `Invalid HP formula: ${formula}`, 'WARN');
+ return;
+ }
+
+ const hp = rollHP(diceData);
+
+ token.set('bar1_value', hp);
+ token.set('bar1_max', hp);
+
+ GameAssist.log('NPCHPRoller', `${token.get('name')} HP set to ${hp} using [${formula}]`);
+ }
+
+ GameAssist.onCommand('!npc-hp-all', async msg => {
+ const pageId = Campaign().get('playerpageid');
+ const tokens = findObjs({
+ _pageid: pageId,
+ _type: 'graphic',
+ layer: 'objects'
+ });
+
+ const npcTokens = [];
+
+ for (const token of tokens) {
+ const characterId = token.get('represents');
+ if (!characterId) continue;
+
+ const npcAttr = findObjs({
+ _type: 'attribute',
+ _characterid: characterId,
+ name: 'npc'
+ })[0];
+
+ if (npcAttr && npcAttr.get('current') === '1') {
+ npcTokens.push(token);
+ }
+ }
+
+ GameAssist.log('NPCHPRoller', `Rolling HP for ${npcTokens.length} NPCs on current map...`);
+
+ for (const token of npcTokens) {
+ try {
+ rollTokenHP(token);
+ } catch (err) {
+ GameAssist.log('NPCHPRoller', `Error processing ${token.get('name')}: ${err.message}`, 'ERROR');
+ }
+ }
+ }, 'NPCHPRoller', { gmOnly: true });
+
+ GameAssist.onCommand('!npc-hp-selected', msg => {
+ if (!msg.selected || msg.selected.length === 0) {
+ GameAssist.log('NPCHPRoller', 'No tokens selected', 'WARN');
+ return;
+ }
+
+ msg.selected.forEach(sel => {
+ const token = getObj('graphic', sel._id);
+ if (token) {
+ try {
+ rollTokenHP(token);
+ } catch (err) {
+ GameAssist.log('NPCHPRoller', `Error processing ${token.get('name')}: ${err.message}`, 'ERROR');
+ }
+ }
+ });
+ }, 'NPCHPRoller', { gmOnly: true });
+
+ GameAssist.log('NPCHPRoller', 'v0.1.1.0 Ready: !npc-hp-all, !npc-hp-selected', 'INFO', { startup: true });
+ }, {
+ enabled: true,
+ events: [],
+ prefixes: ['!npc-hp-all', '!npc-hp-selected']
+ });
+
+ // ————— BOOTSTRAP —————
+ on('ready', () => {
+ if (READY) return;
+ READY = true;
+
+ state[STATE_KEY] = state[STATE_KEY] || { config: {} };
+ GameAssist.config = state[STATE_KEY].config;
+
+ GameAssist._clearAllListeners();
+ seedDefaults();
+ auditState();
+ GameAssist._dedupePlanned();
+ auditCompatibility();
+
+ GameAssist.log('Core', `GameAssist v${VERSION} ready; modules: ${Object.keys(MODULES).join(', ')}`);
+
+ Object.entries(MODULES).forEach(([name, m]) => {
+ if (getState(name).config.enabled) {
+ m.initialized = true;
+ try { m.initFn(); }
+ catch(e) { GameAssist.handleError(name, e); }
+ }
+ });
+ });
+
+})();
+
diff --git a/GameAssist/CHANGELOG.md b/GameAssist/CHANGELOG.md
new file mode 100644
index 000000000..5a5b66a22
--- /dev/null
+++ b/GameAssist/CHANGELOG.md
@@ -0,0 +1,79 @@
+# Changelog
+
+All notable changes to this project will be documented in this file.
+
+---
+
+## [0.1.1.1] – 2025-05-30
+
+### Core Framework
+
+- **Quiet Startup Option:**
+ Added `flags.QUIET_STARTUP` (default: `true`). GMs can now silence per-module “Ready” chat lines during sandbox boot, except for the single Core summary line.
+- **Logging Improvements:**
+ - Re-implemented `GameAssist.log` for better output and log hygiene.
+ - Logs now automatically escape user text, split multiline output into properly prefixed `/w gm` blocks, and preserve message order and formatting.
+ - `log()` accepts `{ startup: true }` metadata, so modules can control which messages are suppressed by QUIET_STARTUP.
+- **Core-Ready Announcement:**
+ - The core “ready” message is never suppressed, even in QUIET_STARTUP mode.
+- **Status Command Update:**
+ - `!ga-status` now uses real newline characters for clearer output.
+ - Output remains grouped in a single whisper for better readability.
+- **Module Announcements:**
+ - All bundled modules (CritFumble, NPCManager, ConcentrationTracker, NPCHPRoller) now mark their “Ready” lines with `{ startup:true }` so they respect QUIET_STARTUP.
+ - NPCHPRoller conforms to this output pattern.
+- **Summary:**
+ No functional changes to gameplay. All updates focus on GM chat output quality-of-life, reducing log clutter, and clarifying startup diagnostics.
+
+---
+
+## [0.1.1.0] – 2025-05-29
+
+- Initial public release of GameAssist.
+- Bundled the core loader with four modules: CritFumble, NPCManager, ConcentrationTracker, and NPCHPRoller.
+- Laid foundation for future modular expansion and customization.
+
+---
+
+*This changelog will track all future updates, enhancements, and bug fixes.*
+
+---
+
+### Module-Specific Changelogs
+
+#### CritFumble Module
+
+**v0.2.4.8** (2025-06-01)
+- Added `sanitizeWho()` helper to strip “ (GM)” from whisper targets.
+- Confirm-table lookup is now case-insensitive via `CONFIRM_TABLES`.
+- Refactored `rollConfirmTable()` logic, preserving debug logging.
+- `showHelpMessage()` fully lists API commands and documents new behaviors.
+- Maintained support for both manual and automatic fumble paths.
+
+**v0.2.4.7** (2025-05-30)
+- Initial v0.2.4.7 release.
+- Detects natural 1 on supported templates and pops the fumble menu.
+- Manual GM trigger via `!critfail`.
+- Confirmation table support via `!confirm-crit-`.
+- Debug logging and emoji options in module config.
+
+---
+
+#### ConcentrationTracker Module
+
+**v0.1.0.5** (2025-06-01)
+- Refactored handler to normalize and lowercase incoming commands.
+- Switched to `onEvent('chat:message')` for improved command matching.
+- Added section headers and doc comments for easier customization.
+- `showHelp()` lists all flags and usage patterns.
+- Retained all manual and automatic options, plus tracking of last damage.
+
+**v0.1.0.4k** (2025-05-02)
+- Initial release.
+- Three-button prompt for concentration checks, supports advantage/disadvantage.
+- Rolls, toggles “Concentrating” marker, clears on module disable.
+- Whispered output to both player and GM.
+
+---
+
+*This changelog will track all future updates, enhancements, and bug fixes.*
diff --git a/GameAssist/GameAssist.js b/GameAssist/GameAssist.js
index 6c395853b..061069340 100644
--- a/GameAssist/GameAssist.js
+++ b/GameAssist/GameAssist.js
@@ -1,5 +1,5 @@
// =============================
-// === GameAssist v0.1.1.0 ===
+// === GameAssist v0.1.1.1 ===
// === Author: Mord Eagle ===
// =============================
// Released under the MIT License (see https://opensource.org/licenses/MIT)
@@ -20,10 +20,11 @@
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
+
(() => {
'use strict';
- const VERSION = '0.1.1.0';
+ const VERSION = '0.1.1.1';
const STATE_KEY = 'GameAssist';
const MODULES = {};
let READY = false;
@@ -212,12 +213,25 @@
_commandHandlers: {},
_eventHandlers: {},
config: {},
- flags: { DEBUG_COMPAT: false },
+ flags: { DEBUG_COMPAT: false, QUIET_STARTUP: true },
+
+ log(mod, msg, level = 'INFO', { startup = false } = {}) {
+ if (startup && GameAssist.flags.QUIET_STARTUP) return;
- log(mod, msg, level = 'INFO') {
const timestamp = new Date().toLocaleTimeString();
const levelIcon = { INFO: 'ℹ️', WARN: '⚠️', ERROR: '❌' }[level] || 'ℹ️';
- sendChat('GameAssist', `/w gm ${levelIcon} [${timestamp}] [${mod}] ${_sanitize(msg)}`);
+
+ // escape user-supplied text, then split on newlines
+ const safe = _sanitize(msg).split('\n');
+
+ // prepend /w gm to every continuation line so Roll20 treats
+ // the whole block as one whisper
+ const stitched = safe.map((l, i) => (i ? '/w gm ' + l : l)).join('\n');
+
+ sendChat(
+ 'GameAssist',
+ `/w gm ${levelIcon} [${timestamp}] [${mod}] ${stitched}`
+ );
},
handleError(mod, err) {
@@ -395,7 +409,7 @@
const avgDuration = metrics.taskDurations.length > 0
? (metrics.taskDurations.reduce((a,b) => a+b, 0) / metrics.taskDurations.length).toFixed(2)
: 'N/A';
-
+
const status = [
`**GameAssist ${VERSION} Status**`,
`Commands: ${metrics.commands}`,
@@ -407,79 +421,95 @@
`Modules: ${Object.keys(MODULES).length}`,
`Active Listeners: ${Object.values(GameAssist._listeners).flat().length}`
].join('\n');
-
+
GameAssist.log('Status', status);
}, 'Core', { gmOnly: true });
- // ————— CRITFUMBLE MODULE v0.2.4.7 —————
+ // ————— CRITFUMBLE MODULE v0.2.4.8 —————
GameAssist.register('CritFumble', function() {
+ // ─── Module Setup ──────────────────────────────────────────────────────────────
const modState = getState('CritFumble');
-
Object.assign(modState.config, {
- enabled: true,
- debug: true,
+ enabled: true,
+ debug: true,
useEmojis: true,
rollDelayMs: 200,
+ // Preserve any values previously saved in state
...modState.config
});
-
modState.runtime.activePlayers = modState.runtime.activePlayers || {};
- const VALID_TEMPLATES = ['atk', 'atkdmg', 'npcatk', 'spell'];
+ // ─── Constants ─────────────────────────────────────────────────────────────────
+ /** Which Roll20 rolltemplates we watch for natural-1s */
+ const VALID_TEMPLATES = ['atk','atkdmg','npcatk','spell'];
const FUMBLE_TABLES = {
- melee: 'CF-Melee',
- ranged: 'CF-Ranged',
- spell: 'CF-Spell',
+ melee: 'CF-Melee',
+ ranged: 'CF-Ranged',
+ spell: 'CF-Spell',
natural: 'CF-Natural',
- thrown: 'CF-Thrown'
+ thrown: 'CF-Thrown'
+ };
+ // Lookup for confirm tables
+ const CONFIRM_TABLES = {
+ 'confirm-crit-martial': 'Confirm-Crit-Martial',
+ 'confirm-crit-magic': 'Confirm-Crit-Magic'
};
- function debugLog(message) {
+ // ─── Helper Functions ──────────────────────────────────────────────────────────
+ /**
+ * debugLog(msg)
+ * Logs to the GM only when debug mode is on.
+ * Uses GameAssist.log under the hood.
+ */
+ function debugLog(msg) {
if (modState.config.debug) {
- GameAssist.log('CritFumble', message);
+ GameAssist.log('CritFumble', msg);
}
}
+ function emoji(sym) {
+ return modState.config.useEmojis ? sym : '';
+ }
- function emoji(symbol) {
- return modState.config.useEmojis ? symbol : '';
+ // Strip off any " (GM)" suffix so /w target resolves
+ function sanitizeWho(who) {
+ return who.replace(/ \(GM\)$/, '');
}
- function sendTemplateMessage(who, title, fields) {
- const content = fields.map(f => `{{${f.label}=${f.value}}}`).join(' ');
+ function sendTemplateMessage(who,title,fields) {
+ who = sanitizeWho(who);
+ const content = fields.map(f=>`{{${f.label}=${f.value}}}`).join(' ');
sendChat('CritFumble', `/w "${who}" &{template:default} {{name=${title}}} ${content}`);
}
- function getFumbleTableName(fumbleType) {
- return FUMBLE_TABLES[fumbleType] || null;
+ function getFumbleTableName(type) {
+ return FUMBLE_TABLES[type]||null;
}
function sendConfirmMenu(who) {
- const confirmButtons = [
+ const buttons = [
`[Confirm-Crit-Martial](!Confirm-Crit-Martial)`,
`[Confirm-Crit-Magic](!Confirm-Crit-Magic)`
].join(' ');
-
sendTemplateMessage(who, `${emoji('❓')} Confirm Critical Miss`, [
- { label: "Choose Confirmation Type", value: confirmButtons }
+ { label: "Choose Confirmation Type", value: buttons }
]);
}
function sendFumbleMenu(who) {
sendConfirmMenu(who);
-
- const fumbleButtons = [
+ const buttons = [
`[⚔ Melee](!critfumble-melee)`,
`[🏹 Ranged](!critfumble-ranged)`,
`[🎯 Thrown](!critfumble-thrown)`,
`[🔥 Spell](!critfumble-spell)`,
`[👊 Natural/Unarmed](!critfumble-natural)`
].join(' ');
-
sendTemplateMessage(who, `${emoji('💥')} Critical Miss!`, [
- { label: "What kind of attack was this?", value: fumbleButtons }
+ { label: "What kind of attack was this?", value: buttons }
]);
+ // also whisper to GM for awareness
sendTemplateMessage('gm', `${emoji('💥')} Critical Miss for ${who}!`, [
- { label: "What kind of attack was this?", value: fumbleButtons }
+ { label: "What kind of attack was this?", value: buttons }
]);
}
@@ -488,164 +518,141 @@
{ label: "Table", value: `**${tableName}**` }
]);
}
-
function executeTableRoll(tableName) {
- const rollCommand = `/roll 1t[${tableName}]`;
- setTimeout(() => {
- sendChat('', rollCommand);
- debugLog(`Roll command executed: ${rollCommand}`);
+ setTimeout(()=>{
+ sendChat('', `/roll 1t[${tableName}]`);
+ debugLog(`Roll command executed: /roll 1t[${tableName}]`);
}, modState.config.rollDelayMs);
}
- function rollFumbleTable(who, fumbleType) {
- const tableName = getFumbleTableName(fumbleType);
- if (!tableName) {
- const validKeys = Object.keys(FUMBLE_TABLES).join(', ');
+ function rollFumbleTable(who,type) {
+ const table = getFumbleTableName(type);
+ if (!table) {
sendTemplateMessage(who, "⚠️ Invalid Fumble Type", [
- { label: "Requested", value: `"${fumbleType}"` },
- { label: "Valid Types", value: validKeys }
+ { label: "Requested", value: `"${type}"` },
+ { label: "Valid Types", value: Object.keys(FUMBLE_TABLES).join(', ') }
]);
- debugLog(`Invalid fumble type "${fumbleType}"`);
+ debugLog(`Invalid fumble type "${type}"`);
return;
}
- announceTableRoll(tableName);
- executeTableRoll(tableName);
+ announceTableRoll(table);
+ executeTableRoll(table);
}
- function rollConfirmTable(who, confirmType) {
- const validConfirmTables = ['Confirm-Crit-Martial', 'Confirm-Crit-Magic'];
- if (!validConfirmTables.includes(confirmType)) {
+ function rollConfirmTable(who,rawCommand) {
+ const table = CONFIRM_TABLES[rawCommand.toLowerCase()];
+ if (!table) {
sendTemplateMessage(who, "⚠️ Invalid Confirm Type", [
- { label: "Requested", value: `"${confirmType}"` },
- { label: "Valid Options", value: validConfirmTables.join(', ') }
+ { label: "Requested", value: `"${rawCommand}"` },
+ { label: "Valid Options", value: Object.values(CONFIRM_TABLES).join(', ') }
]);
- debugLog(`Invalid confirm type "${confirmType}"`);
+ debugLog(`Invalid confirm type "${rawCommand}"`);
return;
}
- announceTableRoll(confirmType);
- executeTableRoll(confirmType);
+ announceTableRoll(table);
+ executeTableRoll(table);
}
- function hasNaturalOne(inlinerolls) {
- return inlinerolls.some(group => {
- if (!group.results || !Array.isArray(group.results.rolls)) return false;
- return group.results.rolls.some(roll => {
- if (roll.type !== 'R' || roll.sides !== 20) return false;
-
- if (Array.isArray(roll.results)) {
- return roll.results.some(result =>
- (result.r === true || result.r === undefined) && result.v === 1
- );
- } else {
- return (roll.v === 1);
- }
+ function hasNaturalOne(inlinerolls=[]) {
+ return inlinerolls.some(group=>{
+ if (!group.results||!Array.isArray(group.results.rolls)) return false;
+ return group.results.rolls.some(roll=>{
+ if (roll.type!=='R'||roll.sides!==20) return false;
+ const results = Array.isArray(roll.results)? roll.results : [roll.results];
+ return results.some(r=> (r.r===true||r.r===undefined) && r.v===1 );
});
});
}
function showManualTriggerMenu() {
- const playerNames = Object.values(modState.runtime.activePlayers);
- if (!playerNames.length) {
+ const players = Object.values(modState.runtime.activePlayers);
+ if (!players.length) {
sendTemplateMessage('gm', "⚠️ No Players Detected", [
- { label: "Note", value: "No players have been active yet this session." }
+ { label:"Note", value:"No players have been active yet this session." }
]);
return;
}
-
- const buttons = playerNames.map(name =>
+ const buttons = players.map(name=>
`[${name}](!critfumblemenu-${encodeURIComponent(name)})`
).join(' ');
-
- sendTemplateMessage('gm', "Manually Trigger Fumble Menu", [
- { label: "Select Player", value: buttons }
+ sendTemplateMessage('gm',"Manually Trigger Fumble Menu",[
+ { label:"Select Player", value:buttons }
]);
}
- function handleManualTrigger(targetPlayer) {
- sendFumbleMenu(decodeURIComponent(targetPlayer));
- debugLog(`Manually triggered fumble menu for: ${targetPlayer}`);
+ function handleManualTrigger(encodedName) {
+ sendFumbleMenu(decodeURIComponent(encodedName));
+ debugLog(`Manually triggered fumble menu for: ${encodedName}`);
}
function showHelpMessage(who) {
sendTemplateMessage(who, "📘 CritFumble Help", [
- { label: "Version", value: "v0.2.4.7" },
- { label: "Commands", value: "`!critfail`, `!critfumble help`" },
- { label: "Description", value: "Auto-detects crit fails and prompts the attacker with a fumble menu. GM can trigger menus manually." },
+ { label: "Version", value: "v0.2.4.8" },
+ { label: "Commands", value: "`!critfail`, `!critfumble help`, `!critfumble-`, `!confirm-crit-martial`, `!confirm-crit-magic`" },
+ { label: "Description", value: "Auto-detects critical misses and prompts attacker with a fumble menu; GM can also manually trigger via `!critfail`." },
{ label: "Valid Types", value: Object.keys(FUMBLE_TABLES).join(', ') }
]);
}
function handleRoll(msg) {
if (!msg) return;
-
+ // register active players
if (msg.playerid && !modState.runtime.activePlayers[msg.playerid]) {
- const player = getObj('player', msg.playerid);
- if (player) {
- modState.runtime.activePlayers[msg.playerid] = player.get('displayname');
- debugLog(`Registered player: ${player.get('displayname')}`);
- }
+ const p = getObj('player', msg.playerid);
+ if (p) modState.runtime.activePlayers[msg.playerid] = p.get('displayname');
}
- if (msg.type === 'api') {
- const cmd = (msg.content || '').trim().toLowerCase();
+ // API‐style commands
+ if (msg.type==='api') {
+ const cmd = (msg.content||'').trim().toLowerCase();
- if (cmd === '!critfail') {
- debugLog('Manual trigger command received: !critfail');
+ if (cmd==='!critfail') {
+ debugLog('Manual trigger: !critfail');
return showManualTriggerMenu();
}
-
- if (cmd === '!critfumble help') return showHelpMessage(msg.who);
-
+ if (cmd==='!critfumble help') {
+ return showHelpMessage(msg.who);
+ }
if (cmd.startsWith('!critfumblemenu-')) {
- const playerName = msg.content.replace('!critfumblemenu-', '');
- return handleManualTrigger(playerName);
+ return handleManualTrigger(msg.content.replace('!critfumblemenu-',''));
}
-
if (cmd.startsWith('!critfumble-')) {
- const who = (msg.who || 'Unknown').replace(' (GM)', '');
- const fumbleType = msg.content.replace('!critfumble-', '').toLowerCase();
+ const who = sanitizeWho(msg.who);
+ const fumbleType = msg.content.replace('!critfumble-','').toLowerCase();
debugLog(`${who} selected fumble type: ${fumbleType}`);
return rollFumbleTable(who, fumbleType);
}
-
if (cmd.startsWith('!confirm-crit-')) {
- const who = (msg.who || 'Unknown').replace(' (GM)', '');
- const confirmType = msg.content.slice(1);
- debugLog(`${who} selected confirm type: ${confirmType}`);
- return rollConfirmTable(who, confirmType);
+ const who = sanitizeWho(msg.who);
+ const rawCommand = msg.content.slice(1); // e.g. "confirm-crit-martial"
+ debugLog(`${who} selected confirm type: ${rawCommand}`);
+ return rollConfirmTable(who, rawCommand);
}
-
return;
}
+ // auto-detect natural 1 on a valid rolltemplate
if (!msg.rolltemplate || !VALID_TEMPLATES.includes(msg.rolltemplate)) return;
+ const rolls = msg.inlinerolls||[];
+ if (!hasNaturalOne(rolls)) return;
- const rolls = msg.inlinerolls || [];
- if (!rolls.length || !hasNaturalOne(rolls)) return;
-
- const who = (msg.who || 'Unknown').replace(' (GM)', '');
+ const who = sanitizeWho(msg.who);
debugLog(`Fumble detected from: ${who}`);
- if (who === 'Unknown') {
- sendTemplateMessage('gm', "⚠️ Unknown Player", [
- { label: "Note", value: "Could not identify who rolled the fumble." }
- ]);
- }
-
sendFumbleMenu(who);
}
GameAssist.onEvent('chat:message', handleRoll, 'CritFumble');
- GameAssist.log('CritFumble', 'v0.2.4.7 Ready: Auto fumble detection + !critfail');
- }, {
- enabled: true,
- events: ['chat:message'],
- prefixes: ['!critfail', '!critfumble']
+ GameAssist.log('CritFumble','v0.2.4.8 Ready: Auto fumble detection + !critfail','INFO',{startup:true});
+ }, {
+ enabled: true,
+ events: ['chat:message'],
+ prefixes: ['!critfail','!critfumble']
});
// ————— NPC MANAGER MODULE v0.1.1.0 —————
GameAssist.register('NPCManager', function() {
const modState = getState('NPCManager');
-
+
Object.assign(modState.config, {
enabled: true,
autoTrackDeath: true,
@@ -657,13 +664,13 @@
if (!token || token.get('layer') !== 'objects') return false;
const charId = token.get('represents');
if (!charId) return false;
-
+
const npcAttr = findObjs({
_type: 'attribute',
_characterid: charId,
name: 'npc'
})[0];
-
+
return npcAttr && npcAttr.get('current') === '1';
}
@@ -726,29 +733,30 @@
}, 'NPCManager', { gmOnly: true });
GameAssist.onEvent('change:graphic:bar1_value', handleTokenChange, 'NPCManager');
- GameAssist.log('NPCManager', 'v0.1.1.0 Ready: Auto death tracking + !npc-death-report');
+ GameAssist.log('NPCManager', 'v0.1.1.0 Ready: Auto death tracking + !npc-death-report', 'INFO', { startup: true });
}, {
enabled: true,
events: ['change:graphic:bar1_value'],
prefixes: ['!npc-death-report']
});
-// ————— CONCENTRATION TRACKER MODULE v0.1.0.4k —————
+// ————— CONCENTRATION TRACKER MODULE v0.1.0.5 —————
GameAssist.register('ConcentrationTracker', function() {
+ // ─── Module Setup ──────────────────────────────────────────────────────────────
const modState = getState('ConcentrationTracker');
-
Object.assign(modState.config, {
- enabled: true,
- marker: 'Concentrating',
+ enabled: true,
+ marker: 'Concentrating',
randomize: true,
...modState.config
});
-
modState.runtime.lastDamage = modState.runtime.lastDamage || {};
+ // ─── Public Command Prefixes ───────────────────────────────────────────────────
const CMDS = ['!concentration', '!cc'];
const TOKEN_MARKER = 'Concentrating';
+ // ─── Default Emote Lines ────────────────────────────────────────────────────────
const DEFAULT_LINES = {
success: [
"steadies their breath, holding their focus.",
@@ -766,10 +774,20 @@ GameAssist.register('ConcentrationTracker', function() {
]
};
+ // ─── Helper Functions ──────────────────────────────────────────────────────────
+
+ /**
+ * getConfig()
+ * Merge default settings with stored config.
+ */
function getConfig() {
return Object.assign({ randomize: true }, modState.config);
}
+ /**
+ * getOutcomeLines(name)
+ * Returns the success/failure emote arrays with {{name}} replaced.
+ */
function getOutcomeLines(name) {
const fill = line => line.replace("{{name}}", name);
return {
@@ -778,21 +796,33 @@ GameAssist.register('ConcentrationTracker', function() {
};
}
+ /**
+ * getConBonus(character)
+ * Reads the character's Constitution saving throw bonus.
+ */
function getConBonus(character) {
const attr = findObjs({
- type: 'attribute',
- characterid: character.id,
- name: 'constitution_save_bonus'
+ _type: 'attribute',
+ _characterid: character.id,
+ name: 'constitution_save_bonus'
})[0];
return attr ? parseInt(attr.get('current'), 10) : 0;
}
+ /**
+ * toggleMarker(token, on)
+ * Adds or removes the Concentrating status marker.
+ */
function toggleMarker(token, on) {
sendChat('api',
`!token-mod --ids ${token.id} --set statusmarkers|${on ? '+' : '-'}${TOKEN_MARKER}`
);
}
+ /**
+ * postButtons(recipient)
+ * Sends the three-button UI for a new concentration check.
+ */
function postButtons(recipient) {
const dmg = '?{Damage taken?|0}';
const buttons = [
@@ -805,6 +835,10 @@ GameAssist.register('ConcentrationTracker', function() {
);
}
+ /**
+ * sendResult(player, dc, total, rolls, formula)
+ * Whispers the concentration-check result to player & GM.
+ */
function sendResult(player, dc, total, rolls, formula) {
const tpl =
`&{template:default} {{name=🧠 Concentration Check}}` +
@@ -813,6 +847,55 @@ GameAssist.register('ConcentrationTracker', function() {
sendChat('ConcentrationTracker', `/w gm ${tpl}`);
}
+ /**
+ * showStatus(player)
+ * Lists all tokens currently marked Concentrating.
+ */
+ function showStatus(player) {
+ const page = Campaign().get('playerpageid');
+ const tokens = findObjs({
+ _type: 'graphic',
+ _pageid: page,
+ layer: 'objects'
+ }).filter(t =>
+ (t.get('statusmarkers') || '')
+ .toLowerCase()
+ .includes(TOKEN_MARKER.toLowerCase())
+ );
+ if (!tokens.length) {
+ return sendChat('ConcentrationTracker',
+ `/w "${player}" No tokens concentrating.`
+ );
+ }
+ let out = `&{template:default} {{name=🧠 Concentration Status}}`;
+ tokens.forEach(t => {
+ out += `{{${t.get('name') || 'Unnamed'}=Concentrating}}`;
+ });
+ sendChat('ConcentrationTracker', `/w "${player}" ${out}`);
+ }
+
+ /**
+ * showHelp(player)
+ * Whisper the full list of commands and usage.
+ */
+ function showHelp(player) {
+ const helpText = [
+ "🧠 Concentration Help:",
+ "• !concentration / !cc → Show buttons",
+ "• --damage X → Roll vs DC = max(10,⌊X/2⌋)",
+ "• --mode normal|adv|dis→ Set roll mode",
+ "• --last → Repeat last check",
+ "• --off → Remove marker from selected tokens",
+ "• --status → Who is concentrating",
+ "• --config randomize on|off → Toggle emote randomization"
+ ].join('
');
+ sendChat('ConcentrationTracker', `/w "${player}" ${helpText}`);
+ }
+
+ /**
+ * handleRoll(msg, damage, mode)
+ * Executes the concentration roll workflow.
+ */
function handleRoll(msg, damage, mode) {
const player = msg.who.replace(/ \(GM\)$/, '');
if (!msg.selected?.length) {
@@ -840,8 +923,8 @@ GameAssist.register('ConcentrationTracker', function() {
}
const bonus = getConBonus(character);
- const dc = Math.max(10, Math.floor(damage / 2));
- const name = token.get('name') || character.get('name');
+ const dc = Math.max(10, Math.floor(damage / 2));
+ const name = token.get('name') || character.get('name');
const { success: S, failure: F } = getOutcomeLines(name);
const { randomize } = getConfig();
@@ -858,11 +941,11 @@ GameAssist.register('ConcentrationTracker', function() {
`/w "${player}" ⚠️ Roll failed.`
);
}
- const total = roll.results.total;
+ const total = roll.results.total;
const formula = roll.expression;
- const vals = roll.results.rolls[0].results.map(r => r.v);
+ const vals = roll.results.rolls[0].results.map(r => r.v);
const rollsText = (mode === 'normal' ? vals[0] : vals.join(','));
- const ok = total >= dc;
+ const ok = total >= dc;
sendResult(player, dc, total, rollsText, formula);
@@ -870,31 +953,15 @@ GameAssist.register('ConcentrationTracker', function() {
const tail = randomize
? pool[Math.floor(Math.random() * pool.length)]
: pool[0];
- const emote = tail;
- sendChat(`character|${character.id}`, `/em ${emote}`);
+ sendChat(`character|${character.id}`, `/em ${tail}`);
toggleMarker(token, ok);
});
}
- function showStatus(player) {
- const page = Campaign().get('playerpageid');
- const tokens = findObjs({
- _type: 'graphic', _pageid: page, layer: 'objects'
- }).filter(t =>
- (t.get('statusmarkers') || '').toLowerCase().includes(TOKEN_MARKER.toLowerCase())
- );
- if (!tokens.length) {
- return sendChat('ConcentrationTracker',
- `/w "${player}" No tokens concentrating.`
- );
- }
- let out = `&{template:default} {{name=🧠 Concentration Status}}`;
- tokens.forEach(t => {
- out += `{{${t.get('name') || 'Unnamed'}=Concentrating}}`;
- });
- sendChat('ConcentrationTracker', `/w "${player}" ${out}`);
- }
-
+ /**
+ * handleClear(msg)
+ * Clears the marker from selected tokens.
+ */
function handleClear(msg) {
const player = msg.who.replace(/ \(GM\)$/, '');
msg.selected?.forEach(sel => {
@@ -904,9 +971,13 @@ GameAssist.register('ConcentrationTracker', function() {
sendChat('ConcentrationTracker', `/w "${player}" ✅ Cleared markers.`);
}
+ /**
+ * handleLast(msg)
+ * Repeats the last concentration check.
+ */
function handleLast(msg) {
const player = msg.who.replace(/ \(GM\)$/, '');
- const dmg = modState.runtime.lastDamage[msg.playerid];
+ const dmg = modState.runtime.lastDamage[msg.playerid];
if (!dmg) {
return sendChat('ConcentrationTracker',
`/w "${player}" ⚠️ No previous damage.`
@@ -915,28 +986,20 @@ GameAssist.register('ConcentrationTracker', function() {
handleRoll(msg, dmg, 'normal');
}
- function showHelp(player) {
- const helpText = [
- "🧠 Concentration Help:",
- "• !concentration / !cc → Show buttons",
- "• --damage X → Roll vs X",
- "• --mode normal|adv|dis",
- "• --off → Remove marker",
- "• --last → Repeat last",
- "• --status → Who is concentrating",
- "• --config randomize on|off"
- ].join('
');
- sendChat('ConcentrationTracker', `/w "${player}" ${helpText}`);
- }
-
+ // ─── Core Handler (Case-Insensitive) ──────────────────────────────────────────
function handler(msg) {
if (msg.type !== 'api') return;
- const parts = msg.content.trim().split(/\s+--/);
- const cmd = parts.shift();
+
+ // 1) Normalize prefix: trim + lowercase
+ const raw = msg.content.trim();
+ const parts = raw.toLowerCase().split(/\s+--/);
+ const cmd = parts.shift(); // "!concentration" or "!cc"
if (!CMDS.includes(cmd)) return;
+ // 2) Identify player (strip " (GM)")
const player = msg.who.replace(/ \(GM\)$/, '');
+ // 3) Config branch
if (parts[0]?.startsWith('config ')) {
const [, key, val] = parts[0].split(/\s+/);
if (key === 'randomize') {
@@ -950,16 +1013,18 @@ GameAssist.register('ConcentrationTracker', function() {
);
}
+ // 4) Parse flags
let damage = 0, mode = 'normal';
for (let p of parts) {
- if (p === 'help') return showHelp(player);
+ if (p === 'help') return showHelp(player);
if (p === 'status') return showStatus(player);
- if (p === 'last') return handleLast(msg);
- if (p === 'off') return handleClear(msg);
+ if (p === 'last') return handleLast(msg);
+ if (p === 'off') return handleClear(msg);
if (p.startsWith('damage ')) damage = parseInt(p.split(' ')[1], 10);
- if (p.startsWith('mode ')) mode = p.split(' ')[1];
+ if (p.startsWith('mode ')) mode = p.split(' ')[1];
}
+ // 5) Execute
if (damage > 0) {
handleRoll(msg, damage, mode);
} else {
@@ -967,18 +1032,24 @@ GameAssist.register('ConcentrationTracker', function() {
}
}
- CMDS.forEach(pref =>
- GameAssist.onCommand(pref, handler, 'ConcentrationTracker', { gmOnly: false })
+ // ─── Wire It Up ────────────────────────────────────────────────────────────────
+ GameAssist.onEvent('chat:message', handler, 'ConcentrationTracker');
+ GameAssist.log(
+ 'ConcentrationTracker',
+ `Ready: ${CMDS.join(' & ')}`,
+ 'INFO',
+ { startup: true }
);
- GameAssist.log('ConcentrationTracker', `Ready: ${CMDS.join(' & ')}`);
}, {
- enabled: true,
- prefixes: ['!concentration', '!cc'],
+ enabled: true,
+ prefixes: ['!concentration','!cc'],
teardown: () => {
const page = Campaign().get('playerpageid');
findObjs({ _type: 'graphic', _pageid: page, layer: 'objects' })
.filter(t =>
- (t.get('statusmarkers') || '').toLowerCase().includes('concentrating')
+ (t.get('statusmarkers') || '')
+ .toLowerCase()
+ .includes('concentrating')
)
.forEach(t =>
sendChat('api',
@@ -991,7 +1062,7 @@ GameAssist.register('ConcentrationTracker', function() {
// ————— NPC HP ROLLER MODULE v0.1.1.0 —————
GameAssist.register('NPCHPRoller', function() {
const modState = getState('NPCHPRoller');
-
+
Object.assign(modState.config, {
enabled: true,
autoRollOnAdd: false,
@@ -1004,12 +1075,12 @@ GameAssist.register('ConcentrationTracker', function() {
/^\s*(\d+)\s*[dD]\s*(\d+)(?:\s*([+-])\s*(\d+))?\s*$/
);
if (!match) return null;
-
+
const count = parseInt(match[1], 10);
const sides = parseInt(match[2], 10);
const sign = match[3] === '-' ? -1 : 1;
const bonus = match[4] ? sign * parseInt(match[4], 10) : 0;
-
+
return { count, sides, bonus };
}
@@ -1038,7 +1109,7 @@ GameAssist.register('ConcentrationTracker', function() {
_characterid: charId,
name: 'npc'
})[0];
-
+
if (!npcAttr || npcAttr.get('current') !== '1') {
return;
}
@@ -1056,17 +1127,17 @@ GameAssist.register('ConcentrationTracker', function() {
const formula = hpFormulaAttr.get('current');
const diceData = parseDiceString(formula);
-
+
if (!diceData) {
GameAssist.log('NPCHPRoller', `Invalid HP formula: ${formula}`, 'WARN');
return;
}
const hp = rollHP(diceData);
-
+
token.set('bar1_value', hp);
token.set('bar1_max', hp);
-
+
GameAssist.log('NPCHPRoller', `${token.get('name')} HP set to ${hp} using [${formula}]`);
}
@@ -1089,7 +1160,7 @@ GameAssist.register('ConcentrationTracker', function() {
_characterid: characterId,
name: 'npc'
})[0];
-
+
if (npcAttr && npcAttr.get('current') === '1') {
npcTokens.push(token);
}
@@ -1124,7 +1195,7 @@ GameAssist.register('ConcentrationTracker', function() {
});
}, 'NPCHPRoller', { gmOnly: true });
- GameAssist.log('NPCHPRoller', 'v0.1.1.0 Ready: !npc-hp-all, !npc-hp-selected');
+ GameAssist.log('NPCHPRoller', 'v0.1.1.0 Ready: !npc-hp-all, !npc-hp-selected', 'INFO', { startup: true });
}, {
enabled: true,
events: [],
@@ -1158,3 +1229,4 @@ GameAssist.register('ConcentrationTracker', function() {
})();
+
diff --git a/GameAssist/README.md b/GameAssist/README.md
index 829712fc6..9ac479030 100644
--- a/GameAssist/README.md
+++ b/GameAssist/README.md
@@ -1,140 +1,561 @@
+# GameAssist – Modular API Framework for Roll20
+**Version 0.1.1.1** | © 2025 Mord Eagle · MIT License
+**Lead Dev:** [@Mord-Eagle](https://github.com/Mord-Eagle)
-# GameAssist
+---
+
+## 0 · What is GameAssist (in one paragraph)?
+
+GameAssist is a **Roll20 API modular Framework**: one script that drops into your API sandbox and spins up a guarded event-queue, metrics board and watchdog. Currently it has four bundled modules—CritFumble, Concentration Tracker, NPC Manager and NPC HP Roller—that hook into that queue, giving you automated fumble tables, concentration checks, death-marker hygiene and one-click HP randomization. Hot-reload, per-task time-outs and state audits let you run marathon sessions without reloading the sandbox.
+
+---
-*Modular Roll20 API Framework*
+## 1 · TL;DR Cheat Sheet
-## Overview
+| Category | Highlights |
+| -------- | ---------- |
+| Core Lift | Serialised queue, per-task timeout, watchdog auto-recovery, state auditor, live metrics. |
+| 30-Second Install | ① Paste **GameAssist.js** ② One-Click **TokenMod** ③ Add **seven** roll-tables (list below) ④ `!ga-status` = green. |
+| Flagship Player Commands | `!concentration`, `!cc`, `!critfail` |
+| Flagship GM Commands | `!npc-hp-all`, `!npc-hp-selected`, `!npc-death-report` |
+| Admin Controls | `!ga-config list\|get\|set\|modules`, `!ga-enable`, `!ga-disable`, `!ga-status` |
+| Safety Nets | FIFO queue + watchdog + auditor → zero silent failures. |
+| Extensibility | `GameAssist.register('MyModule', initFn, { events:['chat:message'], prefixes:['!mymod'] });` |
+| Backup Utility | `!ga-config list` produces a hand-out containing the full JSON config. |
-**GameAssist** is a modular, extensible API framework for Roll20, designed to automate and simplify game management for Game Masters and players alike. Built for the [D\&D 5E 2014 Character Sheet](https://wiki.roll20.net/5e_OGL_Character_Sheet), GameAssist provides robust tools for session automation, error reduction, and campaign consistency.
+> **Required Roll-Tables:** CF-Melee, CF-Ranged, CF-Thrown, CF-Spell, CF-Natural, Confirm-Crit-Martial, Confirm-Crit-Magic
-## Features
-### Core System
+---
+
+## 2 · Table of Contents
+> 3. [Overview](#3-overview) 4. [Quick Start](#4-quick-start) 5. [Deep-Dive Architecture](#5-deep-dive-architecture) 6. [Module Guides](#6-module-guides)
+
+> 7. [Installation](#7-installation) 8. [Command Matrix](#8-command-matrix) 9. [Configuration Keys](#9-configuration-keys) 10. [Developer API](#10-developer-api)
-* **Task Queue & Watchdog:** Ensures reliable, serialized execution of all automation and recovers gracefully from errors.
-* **Handler Tracking:** Centralizes event and command bindings to prevent duplication and facilitate debugging.
-* **State Management:** Maintains isolated, audited state for each module to safeguard your data.
-* **RBAC (Role-Based Access Control):** Restricts sensitive commands to GMs, with support for future player-based permissions.
+> 11. [Roll-Table Cookbook](#11-roll-table-cookbook) 12. [Macro Recipes](#12-macro-recipes) 13. [Performance Benchmarks](#13-performance-benchmarks)
-### Included Modules
+> 14. [Troubleshooting](#14-troubleshooting) 15. [Upgrade Paths](#15-upgrade-paths) 16. [Contributing](#16-contributing)
-#### CritFumble
+> 17. [Roadmap](#17-roadmap) 18. [Changelog](#18-changelog) 19. [Glossary](#19-glossary)
+
+---
-* Detects critical misses and prompts players to specify attack type.
-* Rolls from configurable fumble tables; supports Roll20’s rollable tables.
-* Interactive player menu on crit fails.
+## 3 · Overview
-#### NPC Manager
+GameAssist’s micro-kernel wraps the Roll20 event bus and exposes:
-* Tracks and manages “dead” status for NPC tokens based on HP.
-* Reports marker mismatches for easy correction (requires [TokenMod](https://wiki.roll20.net/TokenMod)).
+* **Task Queue** – serialises async work (`sendChat`, `findObjs`, …) and times out stalled jobs.
+* **Watchdog** – detects a hung task > `DEFAULT_TIMEOUT × 2` and restarts the queue.
+* **State Manager** – namespaced storage (`state.GameAssist.`) with auto-seed and orphan purge.
+* **Metrics Board** – live counters (`commands`, `errors`, `avgTaskMs`) surfaced through `!ga-status`.
+* **Hot Reload** – `!ga-enable|disable` detaches listeners, resets state and re-inits without a sandbox restart.
+* **Compatibility Audit** – toggle `GameAssist.flags.DEBUG_COMPAT` to see a list of known and unknown scripts.
-#### Concentration Tracker
+Design goal: **zero GM downtime**.
-* Prompts for concentration saves when damage is detected.
-* Supports advantage/disadvantage and automatic marker handling (requires TokenMod).
-* Whispered results to player and GM.
+---
-#### NPC HP Roller
+## 4 · Quick Start
-* Assigns randomized hit points to NPC tokens based on sheet formulas.
-* Supports both selected tokens and entire pages (requires TokenMod).
+```text
+📥 1 Copy GameAssist.js → API editor → Save
+🛠 2 Install TokenMod (no config needed)
+📜 3 Create 7 roll-tables (see § 9: Roll-Table Cookbook)
+🔄 4 Type !ga-status → each module should report “Ready”
+🎲 5 Test • !critfail • !concentration --damage 14
+````
---
-## Installation
+## 5 · Deep-Dive Architecture
+
+### 5.1 Event Pipeline
+
+Every inbound Roll20 event is wrapped so the kernel can tally metrics, enforce ACLs and guarantee FIFO execution with a timeout watchdog.
-1. **Download or clone this repository.**
-2. **Copy `GameAssist.js`** into your Roll20 game’s API Scripts panel.
-3. **(Strongly recommended)**: Install [TokenMod](https://wiki.roll20.net/TokenMod) for marker automation and full feature support.
-4. **Reload the API sandbox** in your game.
+### 5.2 Fail-Safe Scenarios
-**Note:** GameAssist requires a Roll20 Pro subscription (API access).
+| Scenario | Kernel Response |
+| ---------------------------- | ---------------------------------------------- |
+| Uncaught exception in module | Error logged, queue continues. |
+| Infinite `sendChat` loop | Watchdog kills task after 60 s (configurable). |
+| State manually corrupted | Auditor deletes branch and re-seeds defaults. |
---
-## Usage
+## 6 · Module Guides
-### Core Commands
+### 6.1 CritFumble
-* `!ga-config`
- View or set configuration options for all modules.
-* `!ga-enable ` / `!ga-disable `
- Enable or disable modules live, without reloading the sandbox.
-* Module-specific commands:
+Natural-1 detection on the standard `atk`, `atkdmg`, `npcatk`, `spell` templates. Auto-prompts attacker with a chat-button menu; GM can trigger manually with `!critfail`. Internals:
- * **CritFumble:** `!critfail`, `!critfumble help`
- * **NPC Manager:** `!npc-death-report`
- * **Concentration Tracker:** `!concentration`, `!cc`
- * **NPC HP Roller:** `!npc-hp-all`, `!npc-hp-selected`
+* Helper commands: `!critfumble help`, `!critfumble-`, `!critfumblemenu-`, `!Confirm-Crit-Martial`, `!Confirm-Crit-Magic`.
+* Config: `debug`, `useEmojis`, `rollDelayMs`.
-### Configuration Example
+### 6.2 Concentration Tracker
+*(Requires TokenMod API for automated marker/status integration.)*
+
+`!concentration` or the alias `!cc` opens buttons for normal/adv/dis rolls or takes flags:
```
-!ga-config set CritFumble debug=true
+
+ "• --help → Whispers Concentration 'Help' message",
+ "• --damage X → Roll vs DC = max(10,⌊X/2⌋),
+ "• --mode normal|adv|dis→ Set roll mode",
+ "• --last → Repeat last check",
+ "• --off → Remove marker from selected tokens",
+ "• --status → Who is concentrating",
+ "• --config randomize on|off → Toggle emote randomization"
```
-For a full list of options, use:
+Config keys: `marker`, `randomize`.
-```
-!ga-config list
-```
+### 6.3 NPC Manager
+*(Requires TokenMod API for automated marker/status integration.)*
+
+Watches `change:graphic:bar1_value`. When an NPC’s HP drops below 1 the `deadMarker` is applied via TokenMod, and removed when HP rises. `!npc-death-report` audits for mismatches. Config keys: `autoTrackDeath`, `deadMarker`.
+
+### 6.4 NPC HP Roller
+*(Requires TokenMod API for automated marker/status integration.)*
+
+`!npc-hp-all` rolls HP for every NPC on the player page; `!npc-hp-selected` works on a token-selection. Parses any `NdM±K` formula stored in `npc_hpformula`. Optional future flag `autoRollOnAdd` (present but currently false by default as it is a work in progress).
+
+---
+
+## 7 · Installation
+
+I. In Roll20, open **Game Settings → API Scripts**.
+II. Create a new script, paste in your `GameAssist.js` file and click **Save Script**.
+III. From the Mod Library, install **TokenMod** (required by several modules).
+IV. Using the Rollable Table tool, create these seven tables by name:
+ - CF-Melee
+ - CF-Ranged
+ - CF-Spell
+ - CF-Natural
+ - CF-Thrown
+ - Confirm-Crit-Martial
+ - Confirm-Crit-Magic
+V. Click **Save Script** again to reload the API. As GM, open your chat whisper window and confirm you see one “ready” message for GameAssist itself and one for each module. It will look roughly like this:
+
+> (From GameAssist): ℹ️ [10:53:56 PM] [Core] GameAssist v0.1.1.1 ready; modules: CritFumble, NPCManager, ConcentrationTracker, NPCHPRoller
+
+VI. To verify end-to-end, type `!ga-status` as GM. You’ll receive a whispered summary of GameAssist’s internal metrics (commands processed, active listeners, queue length, etc.), which confirms the system is up and running.
-or refer to the in-game help via module commands.
+---
+
+## 8 · Command Matrix
+
+| Scope | Command | Parameters / Flags | Purpose |
+|------------|-------------------------------------------------------------|-----------------------------------------------------------------------------------------|----------------------------------------------------------|
+| **Admin** | `!ga-config list` | — | Write full JSON config to a “GameAssist Config” handout |
+| | `!ga-config get ` | — | Whisper the current value of one config key |
+| | `!ga-config set =` | — | Persistently set one config key |
+| | `!ga-config modules` | — | List all modules with enabled/initialized status icons |
+| | `!ga-enable ` / `!ga-disable ` | — | Enable or disable a module |
+| | `!ga-status` | — | Whisper live metrics (commands, messages, errors, etc.) |
+| **GM** | `!npc-hp-all` | — | Roll & set HP for all NPC tokens on the current page |
+| | `!npc-hp-selected` | — | Roll & set HP for the currently selected NPC tokens |
+| | `!npc-death-report` | — | Report NPC tokens whose HP/“dead” marker states mismatch |
+| | `!critfail` | — | Manual fumble prompt menu for active players |
+| | `!critfumble help` | — | Whisper CritFumble help panel |
+| **Player** | `!critfumble-` | `` ∈ {melee, ranged, thrown, spell, natural} | Trigger the fumble‐type menu for your character |
+| | `!confirm-crit-martial` / `!confirm-crit-magic` | — | Roll the corresponding confirmation table |
+| | `!concentration` / `!cc` | `--damage X`, `--mode normal\|adv\|dis`, `--last`, `--off`, `--status`, `--config randomize on\|off`, `--help` | Open UI buttons or perform a concentration save |
---
-## Compatibility
+## 9 · Configuration Keys
-* **Tested and optimized for:** D\&D 5E 2014 Character Sheet by Roll20.
-* **Dependencies:** TokenMod (required for full automation).
+| Module | Key | Type | Default |
+|--------------------------|-----------------|---------|---------------------|
+| **CritFumble** | `debug` | bool | `true` |
+| | `useEmojis` | bool | `true` |
+| | `rollDelayMs` | number | `200` |
+| **ConcentrationTracker** | `marker` | string | `"Concentrating"` |
+| | `randomize` | bool | `true` |
+| **NPCManager** | `autoTrackDeath`| bool | `true` |
+| | `deadMarker` | string | `"dead"` |
+| **NPCHPRoller** | `autoRollOnAdd` | bool | `false` (future) |
-Other character sheets and API scripts may be compatible but are not officially supported.
---
-## Troubleshooting & Support
+## 10 · Developer API
+
+| Category | Method | Description |
+|------------------------|--------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------|
+| **Module Registration** | `GameAssist.register(name, initFn, options)` | Register a new module. `name`: unique ID; `initFn`: init callback; `options`: `{ enabled: bool, events: [], prefixes: [], teardown: fn }` |
+| **Command Handling** | `GameAssist.onCommand(prefix, handler, moduleName, opts)` | Listen for API chat commands; `opts`: `{ gmOnly: bool, acl: [playerIDs] }` |
+| **Event Handling** | `GameAssist.onEvent(eventName, handler, moduleName)` | Listen for Roll20 events (e.g. `chat:message`, `change:graphic:bar1_value`) |
+| **Listener Cleanup** | `GameAssist.offCommands(moduleName)` / `GameAssist.offEvents(moduleName)` | Remove all commands or events registered by the given module |
+| **Module Control** | `GameAssist.enableModule(name)` / `GameAssist.disableModule(name)` | Enable or disable a module at runtime, running its `initFn` or `teardown` |
+| **Logging & Errors** | `GameAssist.log(moduleName, message, level?, opts?)` | Whisper a log to GM; `level` defaults to `'INFO'`; `opts`: `{ startup: bool }` |
+| | `GameAssist.handleError(moduleName, error)` | Increment error metric and log an `'ERROR'`-level message |
+| **State Management** | `getState(moduleName)` | Retrieve (and auto-create) persistent state branch: returns `{ config, runtime }` |
+| | `saveState(moduleName, data)` | Merge and persist additional data into a module’s state branch |
+| | `clearState(moduleName)` | Delete a module’s persistent state branch |
+| **Metrics Inspection** | `GameAssist._metrics` | Live metrics: counts of commands, messages, errors, state audits, task durations, plus `lastUpdate` |
+
+> **Note:** `DEFAULT_TIMEOUT` and `WATCHDOG_INTERVAL` are internal constants and not part of the public API.
-* **Issues & Feature Requests:**
- Use the [GitHub Issues page](https://github.com/Mord-Eagle/GameAssist/issues) to report bugs or suggest improvements.
-* **Documentation:**
- See this README, the in-game `!ga-config` help, or the [GameAssist wiki](https://github.com/Mord-Eagle/GameAssist/wiki) (if available).
---
-## Development & Contributions
+## 11 · Roll-Table Cookbook
-Contributions are welcome!
-If you wish to contribute:
+Sample **CF-Melee** roll table:
-* **Fork this repository** and create a feature branch.
-* **Document your changes** and include tests where feasible.
-* **Open a pull request** with a detailed description.
+| Die Roll | Weight | Effect |
+| -------- | ------ | ------------------------------------------------------------------------------------------------------------------------------ |
+| 1 | 1 | **Sweaty Grip** – Disadvantage on next attack |
+| 2–4 | 3 | **Weapon Twists** – Attack deals half damage |
+| 5–6 | 2 | **Off-Balance** – You fall prone |
+| 7 | 1 | **Lost Grip** – Weapon drops at the foot of your opponent; picking it up requires an action or provokes an opportunity attack |
+| 8 | 1 | **Double Trouble** – Roll twice; both effects apply (rerolls of 8 count as new rolls) |
-Testing is strongly encouraged within the Roll20 API sandbox.
---
-## Changelog
+## 12 · Macro Recipes
+
+### 12.1 GM Panic – disable every module
-See `CHANGELOG.md` for detailed version history.
+```roll20chat
+!ga-disable CritFumble
+!ga-disable ConcentrationTracker
+!ga-disable NPCManager
+!ga-disable NPCHPRoller
+```
---
-## License
+## 13 · Performance Benchmarks
+
+These measurements reflect real-world performance on the specified hardware and chart the end-to-end runtime for `!npc-hp-all` across a moderate token load.
+
+| CPU / RAM | Ryzen 7 7735HS @ 3.2 GHz · 16 GB DDR5-4800 |
+| OS / Browser | Windows 11 Home 24H2 (build 26100.4061) · Chrome 137.0.7151.55 |
+| Roll20 sandbox | “Experimental” channel – 2025-04-09 build |
+| Dataset | 25 NPC tokens on one page |
+
+**Timing results (`!npc-hp-all`, 25 tokens)**
+
+| Run group | Samples | Mean | Median | σ (stdev) | Min – Max |
+| --------- | :-----: | ---- | ------ | --------- | ----------------- |
+| Warm sandbox (runs 1–24) | 24 | **280 ms** | 268 ms | 24 ms | 253 – 337 ms |
+| Fresh sandbox (runs 25–34)
(immediately after Restart Sandbox) | 10 | **355 ms** | 350 ms | 18 ms | 330 – 387 ms |
+| **Combined** | **34** | **298 ms** | 300 ms | 39 ms | 253 – 387 ms |
-GameAssist is released under the [MIT License](LICENSE), as required by Roll20’s API repository.
-By contributing, you agree your changes will be licensed under MIT.
+---
+
+## 14 · Troubleshooting
+
+- **GameAssist appears unresponsive**
+ Run `!ga-status` and look at **Queue Length** and **Last Update**.
+ - If **Queue Length** keeps climbing while **Last Update** does not change, a module is stuck.
+ - To resolve: either increase `DEFAULT_TIMEOUT` in the code or disable modules one by one (`!ga-disable `) until you identify the problematic one.
+
+- **Module not enabled**
+ Use `!ga-config modules` to view each module’s enabled/initialized status.
+ - If a module shows ❌, enable it with `!ga-enable `.
+ - If you never see a “Ready: …” message for a module during startup, confirm that its `enabled` key in state is `true` (run `!ga-config get enabled`).
+
+- **API command not working**
+ Many common pitfalls:
+ - Ensure you type commands in lowercase (e.g. `!concentration`, `!critfail`).
+ - For ConcentrationTracker, verify the code is using `GameAssist.onEvent('chat:message', handler)` and converting `msg.content.toLowerCase()` before matching against `['!concentration','!cc']`.
+ - If you forked or edited the module, compare against the official GameAssist (or related module) code to confirm you didn’t accidentally remove key lines.
+
+- **Rollable tables missing or typo**
+ CritFumble relies on exactly these table names (case-sensitive):
+ - `CF-Melee`
+ - `CF-Ranged`
+ - `CF-Spell`
+ - `CF-Natural`
+ - `CF-Thrown`
+ - `Confirm-Crit-Martial`
+ - `Confirm-Crit-Magic`
+ If any of these do not exist (or are spelled differently), fumble menus and confirm commands will fail. Use the Roll20 Rollable Table tool to create or correct them.
+
+- **TokenMod errors or missing**
+ NPCManager and ConcentrationTracker both call `!token-mod`. Make sure you have TokenMod installed (from the Mod Library) and that it appears **above** GameAssist in your API Scripts list. If TokenMod is missing, you will see errors in the API log when running NPCManager or ConcentrationTracker commands.
+
+- **No debug output for a module**
+ To enable compatibility logs for conflicting scripts, open the API Console (press F12 in the API Editor) and enter:
+ ```js
+ GameAssist.flags.DEBUG_COMPAT = true;
+
+ Then click Save Script to reload.
+
+- For module-specific debugging, whisper to GM:
+ ```js
+ !ga-config set debug=true
+
+ That will emit detailed debugLog whispers whenever that module runs.
+
+- Markers not toggling correctly
+ - For ConcentrationTracker, run !concentration --off on a selected token to clear its marker.
+ - If markers persist, check which token is selected and whether its status name matches the configured marker key. You can verify via:
+ ```js
+ !ga-config get ConcentrationTracker marker
+
+ - For NPCManager, HP <1 should apply the deadMarker. If tokens aren’t getting the “dead” marker, confirm:
+ ```js
+ !ga-config get NPCManager deadMarker
+
+- Re-enabling after a Panic disable
+ If you used a “GM Panic” macro that ran:
+> !ga-disable CritFumble
+> !ga-disable NPCManager
+> !ga-disable ConcentrationTracker
+> !ga-disable NPCHPRoller
+
+ bring everything back online by executing:
+> !ga-enable CritFumble
+> !ga-enable NPCManager
+> !ga-enable ConcentrationTracker
+> !ga-enable NPCHPRoller
+
+- Still stuck?
+ Check the API Log (visible in the Roll20 API Editor) for red error messages. If you see a “SyntaxError” or “ReferenceError,” copy the exact text and search or post on the Roll20 Community API Forum, including your GameAssist version and which module triggered the error.
---
-# Analytical Breakdown
+## 15 · Upgrade Paths
+
+When a new release appears on GitHub, follow these steps:
+
+I. **Backup Your Current Environment**
+ a. In Roll20, open **Game Settings → API Scripts**.
+ b. Select your existing GameAssist script, copy all its contents, and paste them into a local file (e.g. `GameAssist-backup.js`).
+ c. (Optional) Attempt to back up your current configuration by running `!ga-config list`.
+ - **NOTE:** At the time of writing, `!ga-config list` may output an empty JSON (`{}`) instead of your full settings. If you see `{}`, open the API Console (F12 → “API” tab), then copy the entire `state.GameAssist` JSON branch to a local file (e.g. `GameAssist-state-backup.json`).
+ d. Confirm you have the script backup (and optionally a state/handout backup) before proceeding.
+
+II. **Fetch the Latest Release from GitHub**
+ a. Visit the [GameAssist repository](https://github.com/Mord-Eagle/GameAssist) on GitHub and select the latest tagged release (e.g. `v0.1.1.1 → v0.1.1.2`).
+ b. Download or copy the raw contents of the new `GameAssist.js` to your clipboard.
+
+III. **Replace the Script in Roll20**
+ a. In **Game Settings → API Scripts**, select your current GameAssist entry.
+ b. Delete all existing code from that script.
+ c. Paste in the new `GameAssist.js` from GitHub.
+ d. Click **Save Script**.
+
+IV. **Verify Core Loading**
+ a. Watch your GM Whisper window—look for a banner such as:
+ ```
+ GameAssist v0.1.1.2 ready; modules: CritFumble, NPCManager, ConcentrationTracker, NPCHPRoller
+ ```
+ b. Run `!ga-status` to confirm there are no errors and that all modules report as active.
+ c. If you do not see the “ready” banner or encounter errors, immediately revert by replacing the script contents with your `GameAssist-backup.js` and clicking **Save Script**.
+
+V. **Quick Module Smoke Test**
+ a. **CritFumble:** Roll a natural-1 on an attack or type `!critfail`. The fumble menu should appear.
+ b. **NPCManager:** Drag an NPC token below 1 HP or run `!npc-death-report`. Verify correct marker state or mismatches.
+ c. **ConcentrationTracker:** Type `!concentration --status`; you should receive a whisper listing who is concentrating.
+ d. **NPCHPRoller:** Select an NPC token and run `!npc-hp-selected`; the token’s HP bar should update.
+
+VI. **Verify Configuration Keys Persist**
+ a. Your existing settings in `state.GameAssist.config` should carry over automatically.
+ b. To double-check a few common values, run:
+ ```roll20chat
+ !ga-config get CritFumble debug
+ !ga-config get NPCManager deadMarker
+ !ga-config get ConcentrationTracker marker
+ !ga-config get NPCHPRoller autoRollOnAdd
+ ```
+ c. If any values look incorrect or missing (possibly due to the `!ga-config list` bug), restore your saved `state.GameAssist` JSON via the API Console.
+
+VII. **Rollback Plan (if needed)**
+ a. If an upgrade fails—missing “ready” banner, unexpected errors—open **API Scripts** and paste in your `GameAssist-backup.js`.
+ b. Click **Save Script** to revert to the last working version.
+ c. Open the API Console, paste in your saved `state.GameAssist` JSON under the `state` object, and click **Save State**.
+ d. Run `!ga-status` to verify you’re back to the previous stable environment.
+
+> **Summary:** Upgrading is simply:
+> **Copy → Paste → Save → Verify → (optional Rollback).**
-* **Clarity:** The revised README uses clear, structured prose with direct command explanations and explicit dependencies.
-* **Compliance:** Follows Roll20’s requirements on license, accepted file types, command documentation, and usage of module names.
-* **Description:** Module purposes and example commands are described up front, and the installation process is concise but explicit.
-* **Support:** Directs users to GitHub Issues and provides both in-game and README documentation routes.
-* **Extensibility:** Encourages safe contributions and outlines the PR process.
-* **User Accessibility:** Links to TokenMod, Roll20 docs, and the character sheet to ensure new users understand dependencies and context.
+---
+## 16 · Contributing
+
+Thank you for your interest in improving GameAssist. Please follow these guidelines to streamline reviews and maintain consistency across the codebase.
+
+I. **Reporting Issues**
+ a. Before creating a new issue, search existing issues to ensure it hasn’t already been reported or resolved.
+ b. When reporting a bug:
+ - Provide a clear, descriptive title (e.g. “NPCManager does not set dead marker when HP < 1”).
+ - In the description, include:
+ 1. Steps to reproduce the problem in a minimal scenario.
+ 2. The exact GameAssist version and Roll20 environment (e.g. browser, API version).
+ 3. Any error messages from the API Console or chat whispers.
+ - If you have a temporary workaround or suspect a specific module/file, include that detail.
+ c. When suggesting a new feature or enhancement:
+ - Describe the problem you’re trying to solve or the use case you envision.
+ - Outline exactly what new commands, configuration keys, or behaviors you propose.
+ - If possible, sketch example API signatures or sample usage to illustrate your idea.
+
+II. **Development Environment & Coding Style**
+ a. **JavaScript Standards**
+ 1. Use ES6+ syntax (e.g., `const`/`let`, arrow functions, template literals).
+ 2. Maintain the existing indentation (4 spaces per level) and brace style.
+ 3. Keep helper functions, constants, and variables scoped inside the `GameAssist.register(…)` callback whenever possible—avoid top-level declarations.
+ b. **Module Structure**
+ 1. Each new or modified module should use `GameAssist.register(name, initFn, options)`.
+ - `name` must be unique.
+ - `initFn` contains all initialization logic.
+ - `options` should specify `{ enabled: bool, events: [eventNames], prefixes: [chatPrefixes], teardown: fn }`.
+ 2. Follow the established pattern:
+ - **Helper functions** and constants at the top of the callback.
+ - **Core handler functions** in the middle.
+ - `GameAssist.onEvent(…)` or `GameAssist.onCommand(…)` at the end.
+ - A final `GameAssist.log(...)` announcing readiness.
+ 3. If adding a new module, always call `getState(moduleName)` to create your own `{ config, runtime }` branch. Do not overlap or delete another module’s state.
+ c. **Linting & Testing**
+ 1. Although we don’t enforce a linter or automated tests, please manually verify your changes by:
+ - Loading the updated script in Roll20’s API Editor.
+ - Observing the “ready” banner in GM whispers.
+ - Running `!ga-status` to confirm no errors.
+ - Testing any new commands in a sandbox game.
+ 2. If you introduce new Rollable Tables, update the README’s **Roll-Table Cookbook** (§11) with exact table names and sample entries.
+
+III. **Pull Request Workflow**
+ a. **Fork & Branch**
+ 1. Fork the GameAssist repository on GitHub.
+ 2. Clone your fork locally and create a branch named for your change (e.g. `fix-npc-death-marker`, `feature-add-concentration-log`).
+ b. **Commit Messages**
+ 1. Use short, imperative titles (e.g. “Fix: NPCManager missing dead marker”).
+ 2. In the body, explain what changed and why. Reference issue numbers like “Closes #123” or “Fixes #123” when applicable.
+ c. **Submitting a Pull Request**
+ 1. Push your branch to your fork.
+ 2. Open a PR against the `main` branch of the upstream GameAssist repository.
+ 3. In the PR description, include:
+ - A summary of your changes.
+ - Any new commands, configuration keys, or table names introduced.
+ - Steps for reviewers to verify (e.g., “Install TokenMod, create a rollable table named `CF-NewFeature`, and run `!newfeature-test`).
+ 4. Respond promptly to review feedback and adjust your branch as needed.
+
+IV. **Documentation & Examples**
+ a. If you add or change a command, update the **Command Matrix** (§8) to include syntax, parameters, and purpose.
+ b. For any new roll‐table requirements, append them to the **Roll-Table Cookbook** (§11) with sample entries.
+ c. If you modify or add configuration keys, reflect them in the **Configuration Keys** table (§9) with type and default values.
+ d. Wherever possible, include a one‐or‐two‐line example usage in the appropriate README section (e.g. “Macro Recipes” or “Troubleshooting”).
+
+V. **Communication & Etiquette**
+ a. Keep feedback constructive, focusing on solutions rather than blame. We aim to help contributors improve.
+ b. If a proposed change is large or architectural, open a discussion issue first. Community input can guide major decisions.
+ c. Follow the Code of Conduct: be courteous, inclusive, and respectful of everyone’s time and effort.
+
+By adhering to these guidelines, you’ll help keep GameAssist’s codebase clean, consistent, and accessible—whether you’re an experienced developer or new to Roll20’s API. We appreciate your contributions!
+
+---
+
+## 17 · Roadmap
+
+Below is a list of upcoming ideas and planned improvements for GameAssist. This isn’t a strict to-do list—priorities may shift as new needs arise and the community offers feedback.
+
+As you browse this list you will notice that, while some of these ideas are well within reach and could be implemented in the next few updates, others are “pie-in-the-sky” concepts that may prove too complex or simply outside my current bandwidth. I want to be transparent: I’ll do my best to tackle each item, but I can’t guarantee every suggestion will make it into the code. If you see something here that sparks questions or if you have a new idea altogether, please let me know—whether it’s to request an addition, raise a concern, or even contribute code. Your collaboration and feedback help shape where GameAssist goes next.
+
+1. **Auto HP Roll on NPC Token Add**
+ - Automatically roll and assign HP whenever a new NPC token is placed on the map.
+
+2. **Spell-Specific Concentration Integration**
+ - Automatically apply the “Concentrating” marker when a concentration spell is cast.
+ - Include the spell name (and optionally an icon) in concentration check results and in the `--status` report.
+ - Track spell duration or expiration by round, and send optional reminders in chat.
+ - Automatically clear the “Concentrating” marker if the token’s HP drops to 0 or below.
+
+3. **Expanded Module Suite**
+ - **Cooldown Tracker**: Prototype a module that monitors ability recharge timers (e.g., Legendary Actions, spell slots) and whispers reminders when those resources become available again.
+ - **Encounter Assistant**: Tools for initiative tracking, managing enemy waves, and quick loot distribution.
+ - **Resource Tracker**: A unified way to track spell slots, ki points, sorcery points, and other character resources directly from tokens.
+ - **Condition Effect Automator**: Automatically apply and remove condition markers (e.g., stunned, poisoned) based on triggers or API calls.
+ - **Rest & Recovery Module**:
+ - Allow the GM to apply a short or long rest to all player characters with a single command.
+ - Include an “alternate (gritty) rest” option for rules that use longer rest periods.
+ - Provide customizable, homebrew-friendly rest rules for campaigns that use nonstandard recovery mechanics.
+ - **Roll Table Integration**: Enable quick import/export of custom Rollable Tables so GMs can share or backup table data easily.
+ - **Dynamic Location Detection**: Automatically manage Auras and Area-of-Effect visuals based on token position and map geometry.
+
+4. **Modular Component Registry & Discovery**
+ - Implement a lightweight “plugin loader” system so third-party modules can register themselves with minimal boilerplate.
+ - Allow users to enable or disable modules simply by dropping files into a designated folder or directory structure.
+
+5. **Configuration & State Editor**
+ - Provide a structured JSON export/import for `state.GameAssist` so GMs can back up, share, or restore entire configuration snapshots in one step.
+
+6. **Documentation, Examples & Community Resources**
+ - Build a gallery of real-world macro recipes (e.g., auto-casting spells, sequenced multi-attack workflows) that demonstrate best practices with GameAssist modules.
+ - Expand the “Roll-Table Cookbook” with templates for common third-party content (e.g., random NPC generation, trap effects, ambient encounters).
+ - Offer suggestions and recommendations for introducing new features to players—tips for communicating changes so everyone stays on the same page.
+
+7. **System Enhancements**
+ - **Session Metrics & Logging**: Track and display per-session statistics (command usage, error rates, module performance) to help diagnose issues or identify hot spots.
+ - **Verbose Mode**: Add a runtime toggle that captures more detailed diagnostics—useful when debugging or troubleshooting complex scenarios.
+
+> **Note on Feedback:**
+> I’m eager to hear your thoughts, new ideas, bug reports, concerns, or general feedback. While I can’t promise immediate implementation or a fixed timeline, every request will be reviewed and considered. If you have something to add or see an area that needs improvement, please open an issue or join the discussion—your contributions are what make GameAssist better for everyone.
+
+
+---
+
+## 18 · Changelog
+
+See `CHANGELOG.md`.
+
+---
+
+## 19 · Glossary
+
+Below are key terms used throughout GameAssist documentation. Each entry includes a friendly, approachable definition to help you understand how everything fits together.
+
+- **API Command**
+ A chat message beginning with an exclamation mark (`!`) that the GameAssist kernel listens for. When a player or GM types something like `!critfail`, GameAssist intercepts it and calls the appropriate module. Think of it as a “chat shortcut” that triggers a module action.
+
+- **Command Handler**
+ A JavaScript function registered by a module to respond to specific API commands. Under the hood, `GameAssist.onCommand(...)` wraps your handler so that when someone types the matching prefix (e.g. `!concentration`), GameAssist routes the message into your code.
+
+- **Event Handler**
+ A JavaScript function that runs when a certain Roll20 event occurs (for example, a token’s HP changes or someone sends a chat message). Modules register event handlers via `GameAssist.onEvent(...)`, and GameAssist makes sure they only fire after the system has fully initialized.
+
+- **Kernel**
+ The central GameAssist engine that manages the task queue, watchdog timer, metrics collection, and overall coordination between modules. You can think of it as the “operating system” for all GameAssist features.
+
+- **Marker**
+ A Roll20 token status icon (e.g., “dead,” “Concentrating,” or any other colored dot or symbol). Modules like NPCManager or ConcentrationTracker toggle these markers on tokens to visually show conditions without manual clicking.
+
+- **Module**
+ A self-contained feature package that plugs into the GameAssist kernel. Each module is registered with a unique name and an initialization function—the kernel calls that function when it’s time to turn the module on. Examples include CritFumble, NPCManager, ConcentrationTracker, and NPCHPRoller.
+
+- **Persistent State**
+ The JSON object stored in `state.GameAssist` where each module keeps its configuration and runtime data. This state persists between API script reloads so modules can “remember” settings like whether they’re enabled or when a player last took damage.
+
+- **Prefixes**
+ The string or strings (like `!critfumble` or `!cc`) that identify module-specific commands. GameAssist collects all prefixes when you register a module, deduplicates them, and listens for chat messages that start with any of those prefixes.
+
+- **Roll-Table (Rollable Table)**
+ A built-in Roll20 feature where you define a list of outcomes (entries) each tied to die roll ranges (e.g., “2–4 → Weapon Twists”). Modules like CritFumble automatically roll on these tables (e.g., `1t[CF-Melee]`) to generate a random effect or flavor text.
+
+- **Task**
+ An asynchronous job placed on GameAssist’s internal queue (via `_enqueue`). Tasks can be anything from initializing a module to running a delayed table roll. The kernel serializes tasks, enforces timeouts, and uses a watchdog to prevent the queue from stalling.
+
+- **Teardown Function**
+ An optional function a module provides when registering. Called by `GameAssist.disableModule(...)`, teardown typically cleans up event listeners, removes markers, and restores any state to its pre-initialized form.
+
+- **Watchdog**
+ A periodic check (running every 15 seconds by default) that ensures the task queue isn’t stuck. If a task processor takes longer than twice the default timeout, the watchdog forces a reset so subsequent tasks can continue.
+
+- **Watchdog Interval**
+ The frequency (in milliseconds) at which the watchdog timer inspects the queue. By default, it’s set to 15,000 ms (15 seconds). If a task hasn’t finished within `DEFAULT_TIMEOUT * 2`, the watchdog logs a warning and resets the busy flag.
+
+- **Workspace**
+ Your Roll20 game’s API Scripts area where you paste and save `GameAssist.js`. This is where the kernel lives, alongside any TokenMod or third-party modules you install via the Mod Library.
+
+> **Tip:** Whenever you see a new term you’re not sure about, check this glossary first. It’s designed to give you the big-picture context without diving straight into code. If the term is not here, please let me know that it ought to be.
+
+```
+
+---
diff --git a/GameAssist/previousversions/GameAssist v0.1.1.0.js b/GameAssist/previousversions/GameAssist v0.1.1.0.js
new file mode 100644
index 000000000..6c395853b
--- /dev/null
+++ b/GameAssist/previousversions/GameAssist v0.1.1.0.js
@@ -0,0 +1,1160 @@
+// =============================
+// === GameAssist v0.1.1.0 ===
+// === Author: Mord Eagle ===
+// =============================
+// Released under the MIT License (see https://opensource.org/licenses/MIT)
+//
+// Copyright (c) 2025 Mord Eagle
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+// THE SOFTWARE.
+(() => {
+ 'use strict';
+
+ const VERSION = '0.1.1.0';
+ const STATE_KEY = 'GameAssist';
+ const MODULES = {};
+ let READY = false;
+
+ // ————— QUEUE + WATCHDOG —————
+ let _busy = false;
+ let _lastStart = 0;
+ const _queue = [];
+ const DEFAULT_TIMEOUT = 30000;
+ const WATCHDOG_INTERVAL = 15000;
+
+ function _enqueue(task, priority = 0, timeout = DEFAULT_TIMEOUT) {
+ _queue.push({ task, priority, enqueuedAt: Date.now(), timeout });
+ _queue.sort((a,b) => b.priority - a.priority || a.enqueuedAt - b.enqueuedAt);
+ _runNext();
+ }
+
+ function _runNext() {
+ if (_busy || !_queue.length) return;
+ const { task, timeout } = _queue.shift();
+ _busy = true;
+ _lastStart = Date.now();
+
+ const timer = setTimeout(() => {
+ GameAssist.log('Core', `Task timed out after ${timeout}ms`, 'WARN');
+ _busy = false;
+ _runNext();
+ }, timeout);
+
+ Promise.resolve()
+ .then(task)
+ .catch(err => GameAssist.log('Core', `Error in task: ${err.message}`, 'ERROR'))
+ .finally(() => {
+ clearTimeout(timer);
+ _busy = false;
+ const duration = Date.now() - _lastStart;
+ GameAssist._metrics.taskDurations.push(duration);
+ GameAssist._metrics.lastUpdate = new Date().toISOString();
+ _runNext();
+ });
+ }
+
+ setInterval(() => {
+ if (_busy && Date.now() - _lastStart > DEFAULT_TIMEOUT * 2) {
+ GameAssist.log('Core', 'Watchdog forced queue reset', 'WARN');
+ _busy = false;
+ _runNext();
+ }
+ }, WATCHDOG_INTERVAL);
+
+ // ————— HANDLER TRACKING —————
+ globalThis._handlers = globalThis._handlers || {};
+ const originalOn = typeof globalThis.on === 'function' ? globalThis.on : null;
+ const originalOff = typeof globalThis.off === 'function' ? globalThis.off : null;
+
+ globalThis.on = (event, handler) => {
+ globalThis._handlers[event] = globalThis._handlers[event] || [];
+ globalThis._handlers[event].push(handler);
+ if (typeof originalOn === 'function') {
+ return originalOn(event, handler);
+ }
+ };
+
+ globalThis.off = (event, handler) => {
+ if (!globalThis._handlers[event]) return;
+ globalThis._handlers[event] = globalThis._handlers[event].filter(h => h !== handler);
+ if (typeof originalOff === 'function') {
+ return originalOff(event, handler);
+ }
+ };
+
+ // ————— UTILITIES —————
+ function _parseArgs(content) {
+ const args = {}, pattern = /--(\w+)(?:\s+("[^"]*"|[^\s]+))?/g;
+ let m;
+ while ((m = pattern.exec(content))) {
+ let v = m[2] || true;
+ if (typeof v === 'string') {
+ if (/^".*"$/.test(v)) v = v.slice(1, -1);
+ else if (/^\d+$/.test(v)) v = parseInt(v, 10);
+ else if (/,/.test(v)) v = v.split(',');
+ }
+ args[m[1]] = v;
+ }
+ return { cmd: content.split(/\s+/)[0], args };
+ }
+
+ function getState(mod) {
+ state[STATE_KEY] = state[STATE_KEY] || { config: {} };
+ state[STATE_KEY][mod] = state[STATE_KEY][mod] || { config: {}, runtime: {} };
+ return state[STATE_KEY][mod];
+ }
+ function saveState(mod, data) {
+ state[STATE_KEY] = state[STATE_KEY] || { config: {} };
+ state[STATE_KEY][mod] = Object.assign(getState(mod), data);
+ }
+ function clearState(mod) {
+ if (state[STATE_KEY]?.[mod]) delete state[STATE_KEY][mod];
+ }
+
+ function auditState() {
+ const root = state[STATE_KEY] || {};
+ Object.keys(root).forEach(k => {
+ if (k === 'config') return;
+ if (!MODULES[k]) {
+ GameAssist.log('Core', `Unexpected state branch: ${k}`, 'WARN');
+ delete root[k];
+ } else {
+ const branch = root[k];
+ if (!branch.config || !branch.runtime) {
+ GameAssist.log('Core', `Malformed state for ${k}`, 'WARN');
+ delete root[k];
+ }
+ }
+ });
+ GameAssist._metrics.stateAudits++;
+ GameAssist._metrics.lastUpdate = new Date().toISOString();
+ }
+
+ function seedDefaults() {
+ Object.entries(MODULES).forEach(([name, mod]) => {
+ const cfg = getState(name).config;
+ if (cfg.enabled === undefined) cfg.enabled = mod.enabled;
+ });
+ }
+
+ function _sanitize(str = '') {
+ return str.toString()
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/'/g, ''');
+ }
+
+ // ————— COMPATIBILITY —————
+ const KNOWN_SCRIPTS = [
+ 'tokenmod.js','universaltvttimporter.js','npc-hp.js','wolfpack.js',
+ 'critfumble.js','rana-curse.js','statusinfo.js','npc death tracker.js',
+ 'customizable roll listener.js','5th edition ogl by roll20 companion.js'
+ ];
+ function normalizeScriptName(n) {
+ return (n||'')
+ .toLowerCase()
+ .replace(/\.js$/, '')
+ .replace(/[\s_]+/g, '-')
+ .replace(/[^\w-]/g, '');
+ }
+ function auditCompatibility() {
+ if (!GameAssist.flags.DEBUG_COMPAT) return;
+ const known = KNOWN_SCRIPTS.map(normalizeScriptName);
+ const active = Object.keys(state.api?.scripts || {}).map(normalizeScriptName);
+ const good = active.filter(n => known.includes(n));
+ const bad = active.filter(n => !known.includes(n));
+ GameAssist.log('Compat', '✅ Known: ' + (good.join(', ') || 'none'));
+ GameAssist.log('Compat', '❓ Unknown: ' + (bad.join(', ') || 'none'));
+ GameAssist.log('Compat', '🔌 Events: ' + GameAssist._plannedEvents.join(', '));
+ GameAssist.log('Compat', '💬 Commands: ' + GameAssist._plannedChatPrefixes.join(', '));
+ }
+
+ // ————— CONFIG PARSER —————
+ function parseConfigValue(raw) {
+ raw = raw.trim();
+ if (raw === 'true') return true;
+ if (raw === 'false') return false;
+ if (!isNaN(raw)) return Number(raw);
+ if ((raw.startsWith('{') && raw.endsWith('}')) || (raw.startsWith('[') && raw.endsWith(']'))) {
+ try { return JSON.parse(raw); }
+ catch { GameAssist.log('Config', 'Invalid JSON: ' + _sanitize(raw)); }
+ }
+ return raw;
+ }
+
+ // ————— GameAssist CORE —————
+ const GameAssist = {
+ _metrics: {
+ commands: 0,
+ messages: 0,
+ errors: 0,
+ stateAudits: 0,
+ taskDurations: [],
+ lastUpdate: null
+ },
+ _plannedEvents: [],
+ _plannedChatPrefixes: [],
+ _listeners: {},
+ _commandHandlers: {},
+ _eventHandlers: {},
+ config: {},
+ flags: { DEBUG_COMPAT: false },
+
+ log(mod, msg, level = 'INFO') {
+ const timestamp = new Date().toLocaleTimeString();
+ const levelIcon = { INFO: 'ℹ️', WARN: '⚠️', ERROR: '❌' }[level] || 'ℹ️';
+ sendChat('GameAssist', `/w gm ${levelIcon} [${timestamp}] [${mod}] ${_sanitize(msg)}`);
+ },
+
+ handleError(mod, err) {
+ this._metrics.errors++;
+ this._metrics.lastUpdate = new Date().toISOString();
+ this.log(mod, err.message || String(err), 'ERROR');
+ },
+
+ register(name, initFn, { enabled = true, events = [], prefixes = [], teardown = null } = {}) {
+ if (READY) {
+ this.log('Core', `Cannot register after ready: ${name}`, 'WARN');
+ return;
+ }
+ if (MODULES[name]) {
+ this.log('Core', `Duplicate module: ${name}`, 'WARN');
+ return;
+ }
+ MODULES[name] = { initFn, teardown, enabled, initialized: false, events, prefixes };
+ this._plannedEvents.push(...events);
+ this._plannedChatPrefixes.push(...prefixes);
+ },
+
+ onCommand(prefix, fn, mod, { gmOnly = false, acl = [] } = {}) {
+ const wrapped = msg => {
+ if (msg.type !== 'api' || !msg.content.startsWith(prefix)) return;
+ if (gmOnly && !playerIsGM(msg.playerid)) return;
+ if (acl.length && !acl.includes(msg.playerid)) return;
+ this._metrics.commands++;
+ this._metrics.lastUpdate = new Date().toISOString();
+ try { fn(msg); }
+ catch(e) { this.handleError(mod, e); }
+ };
+ on('chat:message', wrapped);
+ this._commandHandlers[mod] = (this._commandHandlers[mod] || []).concat({ event:'chat:message', fn:wrapped });
+ },
+
+ offCommands(mod) {
+ (this._commandHandlers[mod] || []).forEach(h => off(h.event, h.fn));
+ this._commandHandlers[mod] = [];
+ },
+
+ onEvent(evt, fn, mod) {
+ const wrapped = (...args) => {
+ if (!READY || !MODULES[mod].initialized) return;
+ this._metrics.messages++;
+ this._metrics.lastUpdate = new Date().toISOString();
+ try { fn(...args); }
+ catch(e) { this.handleError(mod, e); }
+ };
+ on(evt, wrapped);
+ this._listeners[mod] = (this._listeners[mod] || []).concat({ event:evt, fn:wrapped });
+ },
+
+ offEvents(mod) {
+ (this._listeners[mod] || []).forEach(h => off(h.event, h.fn));
+ this._listeners[mod] = [];
+ },
+
+ _clearAllListeners() {
+ Object.keys(this._commandHandlers).forEach(m => this.offCommands(m));
+ Object.keys(this._listeners).forEach(m => this.offEvents(m));
+ },
+
+ _dedupePlanned() {
+ this._plannedEvents = [...new Set(this._plannedEvents)];
+ this._plannedChatPrefixes = [...new Set(this._plannedChatPrefixes)];
+ },
+
+ enableModule(name) {
+ _enqueue(() => {
+ const m = MODULES[name];
+ if (!m) { this.log('Core', `No such module: ${name}`, 'WARN'); return; }
+ this.offEvents(name);
+ this.offCommands(name);
+ clearState(name);
+ getState(name).config.enabled = true;
+ m.initialized = true;
+ try { m.initFn(); this.log(name, 'Enabled'); }
+ catch(e) { this.handleError(name, e); }
+ });
+ },
+
+ disableModule(name) {
+ _enqueue(() => {
+ const m = MODULES[name];
+ if (!m) { this.log('Core', `No such module: ${name}`, 'WARN'); return; }
+ if (typeof m.teardown === 'function') {
+ try { m.teardown(); }
+ catch(e) { this.log(name, `Teardown failed: ${e.message}`, 'WARN'); }
+ }
+ this.offEvents(name);
+ this.offCommands(name);
+ clearState(name);
+ getState(name).config.enabled = false;
+ m.initialized = false;
+ this.log(name, 'Disabled');
+ });
+ }
+ };
+
+ globalThis.GameAssist = GameAssist;
+
+ // ————— CONFIG COMMAND —————
+ GameAssist.onCommand('!ga-config', msg => {
+ const parts = msg.content.trim().split(/\s+/);
+ const sub = parts[1];
+ if (sub === 'list') {
+ const ts = new Date().toLocaleString();
+ const ver = `v${VERSION}`;
+ const cfg = JSON.stringify(state[STATE_KEY].config, null, 2)
+ .replace(/[<>&]/g, c=>({'<':'<','>':'>','&':'&'})[c]);
+ const name = 'GameAssist Config';
+ let handout = findObjs({ type:'handout', name })[0];
+ if (!handout) handout = createObj('handout', { name, archived:false });
+ handout.set('notes', `Generated: ${ts} (${ver})\n\n${cfg}
`);
+ sendChat('GameAssist', `/w gm Config written to "${name}"`);
+ }
+ else if (sub === 'set' && parts.length >= 4) {
+ const mod = parts[2];
+ const [ key, ...rest ] = parts.slice(3).join(' ').split('=');
+ const val = rest.join('=');
+ const parsed = parseConfigValue(val);
+ if (!MODULES[mod]) {
+ GameAssist.log('Config', `Unknown module: ${mod}`, 'WARN');
+ return;
+ }
+ getState(mod).config[key.trim()] = parsed;
+ GameAssist.log('Config', `Set ${mod}.${key.trim()} = ${JSON.stringify(parsed)}`);
+ }
+ else if (sub === 'get' && parts.length >= 4) {
+ const mod = parts[2];
+ const key = parts[3];
+ if (!MODULES[mod]) {
+ GameAssist.log('Config', `Unknown module: ${mod}`, 'WARN');
+ return;
+ }
+ const val = getState(mod).config[key];
+ GameAssist.log('Config', `${mod}.${key} = ${JSON.stringify(val)}`);
+ }
+ else if (sub === 'modules') {
+ const moduleList = Object.entries(MODULES).map(([name, mod]) => {
+ const cfg = getState(name).config;
+ const status = cfg.enabled ? '✅' : '❌';
+ const init = mod.initialized ? '🔄' : '⏸️';
+ return `${status}${init} ${name}`;
+ }).join('\n');
+ GameAssist.log('Config', `Modules:\n${moduleList}`);
+ }
+ else {
+ GameAssist.log('Config', 'Usage: !ga-config list|set|get|modules [args]');
+ }
+ }, 'Core', { gmOnly: true });
+
+ // ————— CONTROL COMMANDS —————
+ GameAssist.onCommand('!ga-enable', msg => {
+ const mod = msg.content.split(/\s+/)[1];
+ if (!mod) {
+ GameAssist.log('Core', 'Usage: !ga-enable ', 'WARN');
+ return;
+ }
+ GameAssist.enableModule(mod);
+ }, 'Core', { gmOnly: true });
+
+ GameAssist.onCommand('!ga-disable', msg => {
+ const mod = msg.content.split(/\s+/)[1];
+ if (!mod) {
+ GameAssist.log('Core', 'Usage: !ga-disable ', 'WARN');
+ return;
+ }
+ GameAssist.disableModule(mod);
+ }, 'Core', { gmOnly: true });
+
+ GameAssist.onCommand('!ga-status', msg => {
+ const metrics = GameAssist._metrics;
+ const avgDuration = metrics.taskDurations.length > 0
+ ? (metrics.taskDurations.reduce((a,b) => a+b, 0) / metrics.taskDurations.length).toFixed(2)
+ : 'N/A';
+
+ const status = [
+ `**GameAssist ${VERSION} Status**`,
+ `Commands: ${metrics.commands}`,
+ `Messages: ${metrics.messages}`,
+ `Errors: ${metrics.errors}`,
+ `Avg Task Duration: ${avgDuration}ms`,
+ `Queue Length: ${_queue.length}`,
+ `Last Update: ${metrics.lastUpdate || 'Never'}`,
+ `Modules: ${Object.keys(MODULES).length}`,
+ `Active Listeners: ${Object.values(GameAssist._listeners).flat().length}`
+ ].join('\n');
+
+ GameAssist.log('Status', status);
+ }, 'Core', { gmOnly: true });
+
+ // ————— CRITFUMBLE MODULE v0.2.4.7 —————
+ GameAssist.register('CritFumble', function() {
+ const modState = getState('CritFumble');
+
+ Object.assign(modState.config, {
+ enabled: true,
+ debug: true,
+ useEmojis: true,
+ rollDelayMs: 200,
+ ...modState.config
+ });
+
+ modState.runtime.activePlayers = modState.runtime.activePlayers || {};
+
+ const VALID_TEMPLATES = ['atk', 'atkdmg', 'npcatk', 'spell'];
+ const FUMBLE_TABLES = {
+ melee: 'CF-Melee',
+ ranged: 'CF-Ranged',
+ spell: 'CF-Spell',
+ natural: 'CF-Natural',
+ thrown: 'CF-Thrown'
+ };
+
+ function debugLog(message) {
+ if (modState.config.debug) {
+ GameAssist.log('CritFumble', message);
+ }
+ }
+
+ function emoji(symbol) {
+ return modState.config.useEmojis ? symbol : '';
+ }
+
+ function sendTemplateMessage(who, title, fields) {
+ const content = fields.map(f => `{{${f.label}=${f.value}}}`).join(' ');
+ sendChat('CritFumble', `/w "${who}" &{template:default} {{name=${title}}} ${content}`);
+ }
+
+ function getFumbleTableName(fumbleType) {
+ return FUMBLE_TABLES[fumbleType] || null;
+ }
+
+ function sendConfirmMenu(who) {
+ const confirmButtons = [
+ `[Confirm-Crit-Martial](!Confirm-Crit-Martial)`,
+ `[Confirm-Crit-Magic](!Confirm-Crit-Magic)`
+ ].join(' ');
+
+ sendTemplateMessage(who, `${emoji('❓')} Confirm Critical Miss`, [
+ { label: "Choose Confirmation Type", value: confirmButtons }
+ ]);
+ }
+
+ function sendFumbleMenu(who) {
+ sendConfirmMenu(who);
+
+ const fumbleButtons = [
+ `[⚔ Melee](!critfumble-melee)`,
+ `[🏹 Ranged](!critfumble-ranged)`,
+ `[🎯 Thrown](!critfumble-thrown)`,
+ `[🔥 Spell](!critfumble-spell)`,
+ `[👊 Natural/Unarmed](!critfumble-natural)`
+ ].join(' ');
+
+ sendTemplateMessage(who, `${emoji('💥')} Critical Miss!`, [
+ { label: "What kind of attack was this?", value: fumbleButtons }
+ ]);
+ sendTemplateMessage('gm', `${emoji('💥')} Critical Miss for ${who}!`, [
+ { label: "What kind of attack was this?", value: fumbleButtons }
+ ]);
+ }
+
+ function announceTableRoll(tableName) {
+ sendTemplateMessage('gm', `${emoji('🎲')} Rolling Table`, [
+ { label: "Table", value: `**${tableName}**` }
+ ]);
+ }
+
+ function executeTableRoll(tableName) {
+ const rollCommand = `/roll 1t[${tableName}]`;
+ setTimeout(() => {
+ sendChat('', rollCommand);
+ debugLog(`Roll command executed: ${rollCommand}`);
+ }, modState.config.rollDelayMs);
+ }
+
+ function rollFumbleTable(who, fumbleType) {
+ const tableName = getFumbleTableName(fumbleType);
+ if (!tableName) {
+ const validKeys = Object.keys(FUMBLE_TABLES).join(', ');
+ sendTemplateMessage(who, "⚠️ Invalid Fumble Type", [
+ { label: "Requested", value: `"${fumbleType}"` },
+ { label: "Valid Types", value: validKeys }
+ ]);
+ debugLog(`Invalid fumble type "${fumbleType}"`);
+ return;
+ }
+ announceTableRoll(tableName);
+ executeTableRoll(tableName);
+ }
+
+ function rollConfirmTable(who, confirmType) {
+ const validConfirmTables = ['Confirm-Crit-Martial', 'Confirm-Crit-Magic'];
+ if (!validConfirmTables.includes(confirmType)) {
+ sendTemplateMessage(who, "⚠️ Invalid Confirm Type", [
+ { label: "Requested", value: `"${confirmType}"` },
+ { label: "Valid Options", value: validConfirmTables.join(', ') }
+ ]);
+ debugLog(`Invalid confirm type "${confirmType}"`);
+ return;
+ }
+ announceTableRoll(confirmType);
+ executeTableRoll(confirmType);
+ }
+
+ function hasNaturalOne(inlinerolls) {
+ return inlinerolls.some(group => {
+ if (!group.results || !Array.isArray(group.results.rolls)) return false;
+ return group.results.rolls.some(roll => {
+ if (roll.type !== 'R' || roll.sides !== 20) return false;
+
+ if (Array.isArray(roll.results)) {
+ return roll.results.some(result =>
+ (result.r === true || result.r === undefined) && result.v === 1
+ );
+ } else {
+ return (roll.v === 1);
+ }
+ });
+ });
+ }
+
+ function showManualTriggerMenu() {
+ const playerNames = Object.values(modState.runtime.activePlayers);
+ if (!playerNames.length) {
+ sendTemplateMessage('gm', "⚠️ No Players Detected", [
+ { label: "Note", value: "No players have been active yet this session." }
+ ]);
+ return;
+ }
+
+ const buttons = playerNames.map(name =>
+ `[${name}](!critfumblemenu-${encodeURIComponent(name)})`
+ ).join(' ');
+
+ sendTemplateMessage('gm', "Manually Trigger Fumble Menu", [
+ { label: "Select Player", value: buttons }
+ ]);
+ }
+
+ function handleManualTrigger(targetPlayer) {
+ sendFumbleMenu(decodeURIComponent(targetPlayer));
+ debugLog(`Manually triggered fumble menu for: ${targetPlayer}`);
+ }
+
+ function showHelpMessage(who) {
+ sendTemplateMessage(who, "📘 CritFumble Help", [
+ { label: "Version", value: "v0.2.4.7" },
+ { label: "Commands", value: "`!critfail`, `!critfumble help`" },
+ { label: "Description", value: "Auto-detects crit fails and prompts the attacker with a fumble menu. GM can trigger menus manually." },
+ { label: "Valid Types", value: Object.keys(FUMBLE_TABLES).join(', ') }
+ ]);
+ }
+
+ function handleRoll(msg) {
+ if (!msg) return;
+
+ if (msg.playerid && !modState.runtime.activePlayers[msg.playerid]) {
+ const player = getObj('player', msg.playerid);
+ if (player) {
+ modState.runtime.activePlayers[msg.playerid] = player.get('displayname');
+ debugLog(`Registered player: ${player.get('displayname')}`);
+ }
+ }
+
+ if (msg.type === 'api') {
+ const cmd = (msg.content || '').trim().toLowerCase();
+
+ if (cmd === '!critfail') {
+ debugLog('Manual trigger command received: !critfail');
+ return showManualTriggerMenu();
+ }
+
+ if (cmd === '!critfumble help') return showHelpMessage(msg.who);
+
+ if (cmd.startsWith('!critfumblemenu-')) {
+ const playerName = msg.content.replace('!critfumblemenu-', '');
+ return handleManualTrigger(playerName);
+ }
+
+ if (cmd.startsWith('!critfumble-')) {
+ const who = (msg.who || 'Unknown').replace(' (GM)', '');
+ const fumbleType = msg.content.replace('!critfumble-', '').toLowerCase();
+ debugLog(`${who} selected fumble type: ${fumbleType}`);
+ return rollFumbleTable(who, fumbleType);
+ }
+
+ if (cmd.startsWith('!confirm-crit-')) {
+ const who = (msg.who || 'Unknown').replace(' (GM)', '');
+ const confirmType = msg.content.slice(1);
+ debugLog(`${who} selected confirm type: ${confirmType}`);
+ return rollConfirmTable(who, confirmType);
+ }
+
+ return;
+ }
+
+ if (!msg.rolltemplate || !VALID_TEMPLATES.includes(msg.rolltemplate)) return;
+
+ const rolls = msg.inlinerolls || [];
+ if (!rolls.length || !hasNaturalOne(rolls)) return;
+
+ const who = (msg.who || 'Unknown').replace(' (GM)', '');
+ debugLog(`Fumble detected from: ${who}`);
+ if (who === 'Unknown') {
+ sendTemplateMessage('gm', "⚠️ Unknown Player", [
+ { label: "Note", value: "Could not identify who rolled the fumble." }
+ ]);
+ }
+
+ sendFumbleMenu(who);
+ }
+
+ GameAssist.onEvent('chat:message', handleRoll, 'CritFumble');
+ GameAssist.log('CritFumble', 'v0.2.4.7 Ready: Auto fumble detection + !critfail');
+ }, {
+ enabled: true,
+ events: ['chat:message'],
+ prefixes: ['!critfail', '!critfumble']
+ });
+
+ // ————— NPC MANAGER MODULE v0.1.1.0 —————
+ GameAssist.register('NPCManager', function() {
+ const modState = getState('NPCManager');
+
+ Object.assign(modState.config, {
+ enabled: true,
+ autoTrackDeath: true,
+ deadMarker: 'dead',
+ ...modState.config
+ });
+
+ function isNPC(token) {
+ if (!token || token.get('layer') !== 'objects') return false;
+ const charId = token.get('represents');
+ if (!charId) return false;
+
+ const npcAttr = findObjs({
+ _type: 'attribute',
+ _characterid: charId,
+ name: 'npc'
+ })[0];
+
+ return npcAttr && npcAttr.get('current') === '1';
+ }
+
+ function checkForDeath(token) {
+ if (!modState.config.autoTrackDeath || !isNPC(token)) return;
+
+ const hp = parseInt(token.get('bar1_value'), 10) || 0;
+ const markers = (token.get('statusmarkers') || '').split(',');
+ const isDead = markers.includes(modState.config.deadMarker);
+
+ if (hp < 1 && !isDead) {
+ sendChat('api', `!token-mod --ids ${token.id} --set statusmarkers|+${modState.config.deadMarker}`);
+ GameAssist.log('NPCManager', `${token.get('name')} marked as dead (HP: ${hp})`);
+ } else if (hp >= 1 && isDead) {
+ sendChat('api', `!token-mod --ids ${token.id} --set statusmarkers|-${modState.config.deadMarker}`);
+ GameAssist.log('NPCManager', `${token.get('name')} revived (HP: ${hp})`);
+ }
+ }
+
+ function handleTokenChange(obj, prev) {
+ if (obj.get('bar1_value') !== prev.bar1_value) {
+ checkForDeath(obj);
+ }
+ }
+
+ GameAssist.onCommand('!npc-death-report', msg => {
+ const pageId = Campaign().get('playerpageid');
+ const tokens = findObjs({
+ _pageid: pageId,
+ _type: 'graphic',
+ layer: 'objects'
+ });
+
+ const flagged = [];
+ for (let token of tokens) {
+ if (!isNPC(token)) continue;
+
+ const hp = parseInt(token.get('bar1_value'), 10) || 0;
+ const markers = (token.get('statusmarkers') || '').split(',');
+ const isDead = markers.includes(modState.config.deadMarker);
+
+ if ((hp < 1 && !isDead) || (hp >= 1 && isDead)) {
+ flagged.push({
+ name: token.get('name') || '(Unnamed)',
+ id: token.id,
+ hp,
+ markers: token.get('statusmarkers') || '(none)'
+ });
+ }
+ }
+
+ if (flagged.length === 0) {
+ GameAssist.log('NPCManager', '✅ Living NPCs have correct death marker states.');
+ } else {
+ GameAssist.log('NPCManager', `⚠️ ${flagged.length} NPC(s) with mismatched death markers:`);
+ flagged.forEach(({ name, id, hp, markers }) => {
+ GameAssist.log('NPCManager', `- ${name} [${id}] | HP: ${hp} | Markers: ${markers}`);
+ });
+ }
+ }, 'NPCManager', { gmOnly: true });
+
+ GameAssist.onEvent('change:graphic:bar1_value', handleTokenChange, 'NPCManager');
+ GameAssist.log('NPCManager', 'v0.1.1.0 Ready: Auto death tracking + !npc-death-report');
+ }, {
+ enabled: true,
+ events: ['change:graphic:bar1_value'],
+ prefixes: ['!npc-death-report']
+ });
+
+// ————— CONCENTRATION TRACKER MODULE v0.1.0.4k —————
+GameAssist.register('ConcentrationTracker', function() {
+ const modState = getState('ConcentrationTracker');
+
+ Object.assign(modState.config, {
+ enabled: true,
+ marker: 'Concentrating',
+ randomize: true,
+ ...modState.config
+ });
+
+ modState.runtime.lastDamage = modState.runtime.lastDamage || {};
+
+ const CMDS = ['!concentration', '!cc'];
+ const TOKEN_MARKER = 'Concentrating';
+
+ const DEFAULT_LINES = {
+ success: [
+ "steadies their breath, holding their focus.",
+ "'s grip tightens as they maintain their spell.",
+ "staggers slightly but does not lose concentration.",
+ "clenches their jaw, magic still flickering with intent.",
+ "narrows their eyes, spell still intact."
+ ],
+ failure: [
+ "gasps, their focus shattered as the spell falters.",
+ "'s concentration breaks and the magic fades.",
+ "cries out, unable to maintain the spell.",
+ "'s spell fizzles as they lose control.",
+ "winces, focus lost in the heat of battle."
+ ]
+ };
+
+ function getConfig() {
+ return Object.assign({ randomize: true }, modState.config);
+ }
+
+ function getOutcomeLines(name) {
+ const fill = line => line.replace("{{name}}", name);
+ return {
+ success: DEFAULT_LINES.success.map(fill),
+ failure: DEFAULT_LINES.failure.map(fill)
+ };
+ }
+
+ function getConBonus(character) {
+ const attr = findObjs({
+ type: 'attribute',
+ characterid: character.id,
+ name: 'constitution_save_bonus'
+ })[0];
+ return attr ? parseInt(attr.get('current'), 10) : 0;
+ }
+
+ function toggleMarker(token, on) {
+ sendChat('api',
+ `!token-mod --ids ${token.id} --set statusmarkers|${on ? '+' : '-'}${TOKEN_MARKER}`
+ );
+ }
+
+ function postButtons(recipient) {
+ const dmg = '?{Damage taken?|0}';
+ const buttons = [
+ `[🎯 Maintain Control](!concentration --damage ${dmg} --mode normal)`,
+ `[🧠 Brace for the Distraction](!concentration --damage ${dmg} --mode adv)`,
+ `[😣 Struggling to Focus](!concentration --damage ${dmg} --mode dis)`
+ ].join(' ');
+ sendChat('ConcentrationTracker',
+ `/w "${recipient}" ${buttons}
⚠️ Select your token before clicking.`
+ );
+ }
+
+ function sendResult(player, dc, total, rolls, formula) {
+ const tpl =
+ `&{template:default} {{name=🧠 Concentration Check}}` +
+ ` {{DC=${dc}}} {{Result=Roll(s) ${rolls} → ${total} (from ${formula})}}`;
+ sendChat('ConcentrationTracker', `/w "${player}" ${tpl}`);
+ sendChat('ConcentrationTracker', `/w gm ${tpl}`);
+ }
+
+ function handleRoll(msg, damage, mode) {
+ const player = msg.who.replace(/ \(GM\)$/, '');
+ if (!msg.selected?.length) {
+ return sendChat('ConcentrationTracker',
+ `/w "${player}" ⚠️ No token selected.`
+ );
+ }
+ const token = getObj('graphic', msg.selected[0]._id);
+ if (!token) {
+ return sendChat('ConcentrationTracker',
+ `/w "${player}" ⚠️ Token not found.`
+ );
+ }
+ const charId = token.get('represents');
+ if (!charId) {
+ return sendChat('ConcentrationTracker',
+ `/w "${player}" ⚠️ Token not linked.`
+ );
+ }
+ const character = getObj('character', charId);
+ if (!character) {
+ return sendChat('ConcentrationTracker',
+ `/w "${player}" ⚠️ Character not found.`
+ );
+ }
+
+ const bonus = getConBonus(character);
+ const dc = Math.max(10, Math.floor(damage / 2));
+ const name = token.get('name') || character.get('name');
+ const { success: S, failure: F } = getOutcomeLines(name);
+ const { randomize } = getConfig();
+
+ let expr = `1d20 + ${bonus}`;
+ if (mode === 'adv') expr = `2d20kh1 + ${bonus}`;
+ if (mode === 'dis') expr = `2d20kl1 + ${bonus}`;
+
+ modState.runtime.lastDamage[msg.playerid] = damage;
+
+ sendChat('', `[[${expr}]]`, ops => {
+ const roll = ops[0].inlinerolls?.[0];
+ if (!roll) {
+ return sendChat('ConcentrationTracker',
+ `/w "${player}" ⚠️ Roll failed.`
+ );
+ }
+ const total = roll.results.total;
+ const formula = roll.expression;
+ const vals = roll.results.rolls[0].results.map(r => r.v);
+ const rollsText = (mode === 'normal' ? vals[0] : vals.join(','));
+ const ok = total >= dc;
+
+ sendResult(player, dc, total, rollsText, formula);
+
+ const pool = ok ? S : F;
+ const tail = randomize
+ ? pool[Math.floor(Math.random() * pool.length)]
+ : pool[0];
+ const emote = tail;
+ sendChat(`character|${character.id}`, `/em ${emote}`);
+ toggleMarker(token, ok);
+ });
+ }
+
+ function showStatus(player) {
+ const page = Campaign().get('playerpageid');
+ const tokens = findObjs({
+ _type: 'graphic', _pageid: page, layer: 'objects'
+ }).filter(t =>
+ (t.get('statusmarkers') || '').toLowerCase().includes(TOKEN_MARKER.toLowerCase())
+ );
+ if (!tokens.length) {
+ return sendChat('ConcentrationTracker',
+ `/w "${player}" No tokens concentrating.`
+ );
+ }
+ let out = `&{template:default} {{name=🧠 Concentration Status}}`;
+ tokens.forEach(t => {
+ out += `{{${t.get('name') || 'Unnamed'}=Concentrating}}`;
+ });
+ sendChat('ConcentrationTracker', `/w "${player}" ${out}`);
+ }
+
+ function handleClear(msg) {
+ const player = msg.who.replace(/ \(GM\)$/, '');
+ msg.selected?.forEach(sel => {
+ const t = getObj('graphic', sel._id);
+ if (t) toggleMarker(t, false);
+ });
+ sendChat('ConcentrationTracker', `/w "${player}" ✅ Cleared markers.`);
+ }
+
+ function handleLast(msg) {
+ const player = msg.who.replace(/ \(GM\)$/, '');
+ const dmg = modState.runtime.lastDamage[msg.playerid];
+ if (!dmg) {
+ return sendChat('ConcentrationTracker',
+ `/w "${player}" ⚠️ No previous damage.`
+ );
+ }
+ handleRoll(msg, dmg, 'normal');
+ }
+
+ function showHelp(player) {
+ const helpText = [
+ "🧠 Concentration Help:",
+ "• !concentration / !cc → Show buttons",
+ "• --damage X → Roll vs X",
+ "• --mode normal|adv|dis",
+ "• --off → Remove marker",
+ "• --last → Repeat last",
+ "• --status → Who is concentrating",
+ "• --config randomize on|off"
+ ].join('
');
+ sendChat('ConcentrationTracker', `/w "${player}" ${helpText}`);
+ }
+
+ function handler(msg) {
+ if (msg.type !== 'api') return;
+ const parts = msg.content.trim().split(/\s+--/);
+ const cmd = parts.shift();
+ if (!CMDS.includes(cmd)) return;
+
+ const player = msg.who.replace(/ \(GM\)$/, '');
+
+ if (parts[0]?.startsWith('config ')) {
+ const [, key, val] = parts[0].split(/\s+/);
+ if (key === 'randomize') {
+ modState.config.randomize = (val === 'on' || val === 'true');
+ return sendChat('ConcentrationTracker',
+ `/w "${player}" ✅ Randomize = ${modState.config.randomize}`
+ );
+ }
+ return sendChat('ConcentrationTracker',
+ `/w "${player}" ❌ Unknown config ${key}`
+ );
+ }
+
+ let damage = 0, mode = 'normal';
+ for (let p of parts) {
+ if (p === 'help') return showHelp(player);
+ if (p === 'status') return showStatus(player);
+ if (p === 'last') return handleLast(msg);
+ if (p === 'off') return handleClear(msg);
+ if (p.startsWith('damage ')) damage = parseInt(p.split(' ')[1], 10);
+ if (p.startsWith('mode ')) mode = p.split(' ')[1];
+ }
+
+ if (damage > 0) {
+ handleRoll(msg, damage, mode);
+ } else {
+ postButtons(player);
+ }
+ }
+
+ CMDS.forEach(pref =>
+ GameAssist.onCommand(pref, handler, 'ConcentrationTracker', { gmOnly: false })
+ );
+ GameAssist.log('ConcentrationTracker', `Ready: ${CMDS.join(' & ')}`);
+}, {
+ enabled: true,
+ prefixes: ['!concentration', '!cc'],
+ teardown: () => {
+ const page = Campaign().get('playerpageid');
+ findObjs({ _type: 'graphic', _pageid: page, layer: 'objects' })
+ .filter(t =>
+ (t.get('statusmarkers') || '').toLowerCase().includes('concentrating')
+ )
+ .forEach(t =>
+ sendChat('api',
+ `!token-mod --ids ${t.id} --set statusmarkers|-Concentrating`
+ )
+ );
+ }
+});
+
+ // ————— NPC HP ROLLER MODULE v0.1.1.0 —————
+ GameAssist.register('NPCHPRoller', function() {
+ const modState = getState('NPCHPRoller');
+
+ Object.assign(modState.config, {
+ enabled: true,
+ autoRollOnAdd: false,
+ ...modState.config
+ });
+
+ function parseDiceString(diceStr) {
+ // Match “NdM”, “NdM+K”, “NdM + K”, “NdM-K”, case-insensitive on “d”
+ const match = diceStr.match(
+ /^\s*(\d+)\s*[dD]\s*(\d+)(?:\s*([+-])\s*(\d+))?\s*$/
+ );
+ if (!match) return null;
+
+ const count = parseInt(match[1], 10);
+ const sides = parseInt(match[2], 10);
+ const sign = match[3] === '-' ? -1 : 1;
+ const bonus = match[4] ? sign * parseInt(match[4], 10) : 0;
+
+ return { count, sides, bonus };
+ }
+
+ function rollDice(count, sides) {
+ let total = 0;
+ for (let i = 0; i < count; i++) {
+ total += Math.floor(Math.random() * sides) + 1;
+ }
+ return total;
+ }
+
+ function rollHP(diceData) {
+ const { count, sides, bonus } = diceData;
+ return rollDice(count, sides) + bonus;
+ }
+
+ function rollTokenHP(token) {
+ const charId = token.get('represents');
+ if (!charId) {
+ GameAssist.log('NPCHPRoller', 'Token not linked to character', 'WARN');
+ return;
+ }
+
+ const npcAttr = findObjs({
+ _type: 'attribute',
+ _characterid: charId,
+ name: 'npc'
+ })[0];
+
+ if (!npcAttr || npcAttr.get('current') !== '1') {
+ return;
+ }
+
+ const hpFormulaAttr = findObjs({
+ _type: 'attribute',
+ _characterid: charId,
+ name: 'npc_hpformula'
+ })[0];
+
+ if (!hpFormulaAttr) {
+ GameAssist.log('NPCHPRoller', `No HP formula found for ${token.get('name')}`, 'WARN');
+ return;
+ }
+
+ const formula = hpFormulaAttr.get('current');
+ const diceData = parseDiceString(formula);
+
+ if (!diceData) {
+ GameAssist.log('NPCHPRoller', `Invalid HP formula: ${formula}`, 'WARN');
+ return;
+ }
+
+ const hp = rollHP(diceData);
+
+ token.set('bar1_value', hp);
+ token.set('bar1_max', hp);
+
+ GameAssist.log('NPCHPRoller', `${token.get('name')} HP set to ${hp} using [${formula}]`);
+ }
+
+ GameAssist.onCommand('!npc-hp-all', async msg => {
+ const pageId = Campaign().get('playerpageid');
+ const tokens = findObjs({
+ _pageid: pageId,
+ _type: 'graphic',
+ layer: 'objects'
+ });
+
+ const npcTokens = [];
+
+ for (const token of tokens) {
+ const characterId = token.get('represents');
+ if (!characterId) continue;
+
+ const npcAttr = findObjs({
+ _type: 'attribute',
+ _characterid: characterId,
+ name: 'npc'
+ })[0];
+
+ if (npcAttr && npcAttr.get('current') === '1') {
+ npcTokens.push(token);
+ }
+ }
+
+ GameAssist.log('NPCHPRoller', `Rolling HP for ${npcTokens.length} NPCs on current map...`);
+
+ for (const token of npcTokens) {
+ try {
+ rollTokenHP(token);
+ } catch (err) {
+ GameAssist.log('NPCHPRoller', `Error processing ${token.get('name')}: ${err.message}`, 'ERROR');
+ }
+ }
+ }, 'NPCHPRoller', { gmOnly: true });
+
+ GameAssist.onCommand('!npc-hp-selected', msg => {
+ if (!msg.selected || msg.selected.length === 0) {
+ GameAssist.log('NPCHPRoller', 'No tokens selected', 'WARN');
+ return;
+ }
+
+ msg.selected.forEach(sel => {
+ const token = getObj('graphic', sel._id);
+ if (token) {
+ try {
+ rollTokenHP(token);
+ } catch (err) {
+ GameAssist.log('NPCHPRoller', `Error processing ${token.get('name')}: ${err.message}`, 'ERROR');
+ }
+ }
+ });
+ }, 'NPCHPRoller', { gmOnly: true });
+
+ GameAssist.log('NPCHPRoller', 'v0.1.1.0 Ready: !npc-hp-all, !npc-hp-selected');
+ }, {
+ enabled: true,
+ events: [],
+ prefixes: ['!npc-hp-all', '!npc-hp-selected']
+ });
+
+ // ————— BOOTSTRAP —————
+ on('ready', () => {
+ if (READY) return;
+ READY = true;
+
+ state[STATE_KEY] = state[STATE_KEY] || { config: {} };
+ GameAssist.config = state[STATE_KEY].config;
+
+ GameAssist._clearAllListeners();
+ seedDefaults();
+ auditState();
+ GameAssist._dedupePlanned();
+ auditCompatibility();
+
+ GameAssist.log('Core', `GameAssist v${VERSION} ready; modules: ${Object.keys(MODULES).join(', ')}`);
+
+ Object.entries(MODULES).forEach(([name, m]) => {
+ if (getState(name).config.enabled) {
+ m.initialized = true;
+ try { m.initFn(); }
+ catch(e) { GameAssist.handleError(name, e); }
+ }
+ });
+ });
+
+})();
+
diff --git a/GameAssist/script.json b/GameAssist/script.json
index 71dac2155..727503ed5 100644
--- a/GameAssist/script.json
+++ b/GameAssist/script.json
@@ -1,9 +1,9 @@
{
"name": "GameAssist",
"script": "GameAssist.js",
- "version": "0.1.1.0",
- "previousversions": [],
- "description": "GameAssist is a modular Roll20 API automation suite designed for D&D 5E (2014 Character Sheet).\n\nCommands:\n\t!ga-enable \n\t!ga-disable \n\t!ga-config list|set|get|modules\n\nCore modules include CritFumble, NPCManager, ConcentrationTracker, NPCHPRoller (TokenMod required for most features).\n\nFeatures:\n\t- Modular loader: enable/disable modules in real time\n\t- Priority task queue and watchdog reset\n\t- In-chat config and live toggling\n\t- Compatibility audit for common scripts\n\nInstallation:\n\t1. Requires Roll20 Pro subscription (API access).\n\t2. (Strongly recommended) Install TokenMod API.\n\t3. Add GameAssist.js via One-Click or copy into API scripts panel.\n\nFor full documentation, see the GitHub repo: https://github.com/Mord-Eagle/GameAssist\n\nPlease report bugs or request features via GitHub Issues.",
+ "version": "0.1.1.1",
+ "previousversions": ["0.1.1.0"],
+ "description": "GameAssist is a modular Roll20 API framework for the 5E 2014 sheet.\n\nCore commands (GM only):\n • !ga-status – live metrics\n • !ga-enable \n • !ga-disable \n • !ga-config …\n\nBundled modules: CritFumble, NPCManager, ConcentrationTracker, NPCHPRoller.\nTokenMod is strongly recommended.\n\n## Changelog\n• 0.1.1.1 – Quiet-startup flag, newline-safe logging\n• 0.1.1.0 – Initial public release.",
"authors": "Mord Eagle",
"roll20userid": "10646976",
"dependencies": ["TokenMod"],