diff --git a/GameAssist/0.1.1.2/GameAssist.js b/GameAssist/0.1.1.2/GameAssist.js
new file mode 100644
index 000000000..8c32bc712
--- /dev/null
+++ b/GameAssist/0.1.1.2/GameAssist.js
@@ -0,0 +1,1241 @@
+// =============================
+// === GameAssist v0.1.1.2 ===
+// === 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.2';
+ 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.9 —————
+ 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 confirmButtons = [
+ `[Confirm-Crit-Martial](!Confirm-Crit-Martial)`,
+ `[Confirm-Crit-Magic](!Confirm-Crit-Magic)`
+ ].join(' ');
+
+ // Send to player
+ sendTemplateMessage(who, `${emoji('❓')} Confirm Critical Miss`, [
+ { label: "Choose Confirmation Type", value: confirmButtons }
+ ]);
+ // Also send to GM
+ sendTemplateMessage('gm', `${emoji('❓')} Confirm Critical Miss for ${who}!`, [
+ { label: "Choose Confirmation Type", value: confirmButtons }
+ ]);
+}
+
+ 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) {
+ for (const group of inlinerolls) {
+ if (!group || !group.results || !Array.isArray(group.results.rolls)) continue;
+ for (const roll of group.results.rolls) {
+ // Only look at d20 dice rolls
+ if (roll.type !== 'R' || roll.sides !== 20 || !Array.isArray(roll.results)) continue;
+ for (const result of roll.results) {
+ // Defensive: result must have .v (value); .r is not always present
+ if (typeof result.v !== 'number') continue;
+ if (result.v === 1) return true;
+ }
+ }
+ }
+ return false;
+}
+
+ 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.9" },
+ { 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.9 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
index 5a5b66a22..2274a581a 100644
--- a/GameAssist/CHANGELOG.md
+++ b/GameAssist/CHANGELOG.md
@@ -4,24 +4,41 @@ All notable changes to this project will be documented in this file.
---
+## [0.1.1.2] – 2025-06-10
+
+### CritFumble Module
+
+- **Natural 1 Detection Bugfix:**
+ Refactored the `hasNaturalOne` function to robustly detect natural 1s on all d20 attack rolls, regardless of template complexity or non-standard inline roll formats. This eliminates `"Cannot read properties of undefined (reading 'r')"` errors and ensures that all valid attack rolls are properly checked for critical fumbles.
+
+- **GM Visibility Improvement:**
+ The ❓ **Confirm Critical Miss** confirmation menu is now whispered to both the GM and the player, not just the player. This makes GM oversight consistent across all critical miss event prompts.
+
+---
+
## [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.
@@ -37,43 +54,3 @@ All notable changes to this project will be documented in this file.
*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 061069340..8c32bc712 100644
--- a/GameAssist/GameAssist.js
+++ b/GameAssist/GameAssist.js
@@ -1,5 +1,5 @@
// =============================
-// === GameAssist v0.1.1.1 ===
+// === GameAssist v0.1.1.2 ===
// === Author: Mord Eagle ===
// =============================
// Released under the MIT License (see https://opensource.org/licenses/MIT)
@@ -24,7 +24,7 @@
(() => {
'use strict';
- const VERSION = '0.1.1.1';
+ const VERSION = '0.1.1.2';
const STATE_KEY = 'GameAssist';
const MODULES = {};
let READY = false;
@@ -425,7 +425,7 @@
GameAssist.log('Status', status);
}, 'Core', { gmOnly: true });
- // ————— CRITFUMBLE MODULE v0.2.4.8 —————
+ // ————— CRITFUMBLE MODULE v0.2.4.9 —————
GameAssist.register('CritFumble', function() {
// ─── Module Setup ──────────────────────────────────────────────────────────────
const modState = getState('CritFumble');
@@ -486,14 +486,20 @@
}
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 }
- ]);
- }
+ const confirmButtons = [
+ `[Confirm-Crit-Martial](!Confirm-Crit-Martial)`,
+ `[Confirm-Crit-Magic](!Confirm-Crit-Magic)`
+ ].join(' ');
+
+ // Send to player
+ sendTemplateMessage(who, `${emoji('❓')} Confirm Critical Miss`, [
+ { label: "Choose Confirmation Type", value: confirmButtons }
+ ]);
+ // Also send to GM
+ sendTemplateMessage('gm', `${emoji('❓')} Confirm Critical Miss for ${who}!`, [
+ { label: "Choose Confirmation Type", value: confirmButtons }
+ ]);
+}
function sendFumbleMenu(who) {
sendConfirmMenu(who);
@@ -553,16 +559,21 @@
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 hasNaturalOne(inlinerolls) {
+ for (const group of inlinerolls) {
+ if (!group || !group.results || !Array.isArray(group.results.rolls)) continue;
+ for (const roll of group.results.rolls) {
+ // Only look at d20 dice rolls
+ if (roll.type !== 'R' || roll.sides !== 20 || !Array.isArray(roll.results)) continue;
+ for (const result of roll.results) {
+ // Defensive: result must have .v (value); .r is not always present
+ if (typeof result.v !== 'number') continue;
+ if (result.v === 1) return true;
+ }
}
+ }
+ return false;
+}
function showManualTriggerMenu() {
const players = Object.values(modState.runtime.activePlayers);
@@ -587,7 +598,7 @@
function showHelpMessage(who) {
sendTemplateMessage(who, "📘 CritFumble Help", [
- { label: "Version", value: "v0.2.4.8" },
+ { label: "Version", value: "v0.2.4.9" },
{ 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(', ') }
@@ -642,7 +653,7 @@
}
GameAssist.onEvent('chat:message', handleRoll, 'CritFumble');
- GameAssist.log('CritFumble','v0.2.4.8 Ready: Auto fumble detection + !critfail','INFO',{startup:true});
+ GameAssist.log('CritFumble','v0.2.4.9 Ready: Auto fumble detection + !critfail','INFO',{startup:true});
}, {
enabled: true,
events: ['chat:message'],
@@ -1228,5 +1239,3 @@ GameAssist.register('ConcentrationTracker', function() {
});
})();
-
-
diff --git a/GameAssist/script.json b/GameAssist/script.json
index 0c57b443b..09db558ca 100644
--- a/GameAssist/script.json
+++ b/GameAssist/script.json
@@ -1,9 +1,9 @@
{
"name": "GameAssist",
"script": "GameAssist.js",
- "version": "0.1.1.1",
- "previousversions": ["0.1.1.0"],
- "description": "GameAssist is a modular Roll20 API automation suite for D&D 5E (2014 Character Sheet). It streamlines DM workflow with hot-reload modules for crit fumbles, NPC death markers, concentration checks, and automatic HP rolling, all managed by a stable event queue and in-chat config. Requires TokenMod and several rollable tables (see README) for some features. Use !ga-enable, !ga-disable, and !ga-config to manage modules in-game. Full setup, documentation, and troubleshooting: github.com/Mord-Eagle/GameAssist.",
+ "version": "0.1.1.2",
+ "previousversions": ["0.1.1.1", "0.1.1.0"],
+ "description": "GameAssist is a modular Roll20 API automation suite for D&D 5E (2014 Character Sheet).\n\nFeatures:\n- Crit fumble handling\n- NPC death auto-marking\n- Concentration tracking\n- Automatic HP rolling\n- Enable/disable modules in-game\n- Stable event queue\n- In-chat configuration\n\nAPI Commands:\n!ga-enable, !ga-disable, !ga-config, !npc-hp-all, !npc-hp-selected, !npc-death-report, !concentration, !cc, !critfail, !critfumble\n\nRequirements:\n- TokenMod API (strongly recommended)\n- Rollable tables for CritFumble (see README)\n\nSetup and documentation:\ngithub.com/Mord-Eagle/GameAssist\nREADME: https://github.com/Mord-Eagle/GameAssist#readme\n\nCompatible with D&D 5E (2014) sheet. For setup, troubleshooting, and latest updates, see the README. Please report bugs and feature requests via GitHub Issues.",
"authors": "Mord Eagle",
"roll20userid": "10646976",
"modifies": [],