diff --git a/GameAssist/0.1.4.7/GameAssist.js b/GameAssist/0.1.4.7/GameAssist.js new file mode 100644 index 0000000000..257a0f371d --- /dev/null +++ b/GameAssist/0.1.4.7/GameAssist.js @@ -0,0 +1,5555 @@ +/* +======================================== +GameAssist — Roll20 API Script +Version: 0.1.4.7 +Last Updated: 2026-07-17 (America/New_York) +Development line: standalone TokenMod and StatusInfo interoperability v0.1.4.7 based on the v0.1.4.6 system-health release. +Author: Mord Eagle +License: MIT (see repository LICENSE) +Homepage: https://github.com/Roll20/roll20-api-scripts (submission target) + +DESCRIPTION +GameAssist is a small framework for organizing Roll20 API modules with an explicit opt-in +task queue, watchdog, config/state helpers, and consistent logging. Normal event handlers +execute directly unless a module deliberately calls GameAssist.enqueue(). This package ships with six modules: +• ConfigUI — GM-only chat controls for toggling modules and boolean options. +• CritFumble — Detects natural-1s on selected rolltemplates and offers fumble/confirm menus. +• ConcentrationTracker — Runs concentration checks (normal/adv/dis), whispers results, toggles marker. +• NPCManager 1.1.1 — Tracks NPC death markers, hierarchical death-history buckets, report writing, audits, and curated arc rosters. +• NPCHPRoller — Rolls NPC HP from `npc_hpformula` and writes to bar1 (value/max). +• DebugTools — Optional GM diagnostics (dry-run by default) for damage, markers, and save rolls. + +INSTALL / USAGE (One-Click or Manual) +• One-Click: install “GameAssist”. (Make sure “TokenMod” is also installed; see Dependencies.) +• Manual (Pro): paste this entire file into the API Scripts editor and Save. + +CORE COMMANDS (GM): +• !ga-config list — write config handout “GameAssist Config” +• !ga-config modules — list modules + status +• !ga-config set key=value — set module config (JSON/number/bool supported) +• !ga-config get key — echo module config value +• !ga-config ui / !ga-config-ui — open the GM chat control panel +• !ga-enable / !ga-disable +• !ga-status [--details] — show simple health or troubleshooting details +• !ga-debug — run DebugTools helpers (`damage`, `marker`, `save`) + +MODULE COMMANDS: +• CritFumble: + !critfail — GM menu to trigger fumble UI for a player + !critfumble — help + !critfumble help — help + !critfumble menu — guided natural-1 menu + !critfumble- + !confirm-crit-martial | !confirm-crit-magic +• ConcentrationTracker (players/GM): + !concentration [--damage N] [--mode normal|adv|dis] [--off|--status|--last|--config randomize on|off] + !cc (alias) +• NPCManager (GM): !npc-death-help + !npc-death-report [--scope campaign|chapter|section|session] [--recent|--page N|--write|--help] + !npc-death-buckets | !npc-death-clear [--scope session] [--nested] [--confirm] + !npc-death-write | !npc-wr | !npc-death-audit | !npc-death-arc +• NPCHPRoller (GM): !npc-hp-selected | !npc-hp-all +• ConfigUI (GM): !ga-config ui [--page N] | !ga-config-ui +• DebugTools (GM-only): !ga-debug damage|marker|save [flags] (module disabled by default) + +DEPENDENCIES +• TokenMod 0.8.88 baseline — required for marker/status changes issued by NPCManager and ConcentrationTracker. +• StatusInfo 0.3.11 baseline — optional; provides condition descriptions and menus while observing TokenMod changes. + +CONFIGURATION NOTES +• Global flags (in code): GameAssist.flags.DEBUG_COMPAT, GameAssist.flags.QUIET_STARTUP. +• Per-module config via !ga-config set; types: boolean, number, JSON object/array, string. + +COMPATIBILITY / FOOTPRINT +• Namespaced under global `GameAssist` only; avoids other global pollution. +• Uses standard events: `on('ready')`, `on('chat:message')`, and graphic change events. +• Writes only to Roll20 objects documented in script.json: handout notes, token bars/markers. + +SUPPORT / HELP +• Use !ga-status for quick health; !ga-config list creates a “GameAssist Config” handout. +• For bug reports, include the console whisper output from `GameAssist.log(...)`. + +V0.1.4.7 STANDALONE INTEROPERABILITY NOTES +- Normal commands/events execute directly; GameAssist.enqueue(task, options) is explicitly opt-in. +- Queue timeouts release the queue but cannot cancel JavaScript or Roll20 operations. +- Dependency status is confirmed, missing, or unverifiable. +- !ga-config list exports configuration only; runtime caches and metrics are excluded. +- Hidden/admin commands: !ga-config cleanup, !npc-death-buckets, !npc-death-clear [--scope session] [--nested] [--confirm], !npc-death-write, !npc-wr, !npc-death-audit, !npc-death-arc, !ga-metrics [reset]. +- Additional config: NPCManager autoHide/hideLayer; registration dependsOn; onCommand match options. +- Built-in and custom status-marker names resolve to the exact ids Roll20 stores on tokens. +- Concentration status reports invalid marker configuration instead of silently returning an incorrect result. +- CritFumble help and NPC death-audit output use clearer DM-facing wording. +- NPC deaths record into Campaign, Chapter, Section, and Session buckets, with one handout per bucket. +- NPC death audits show bounded token-specific findings in chat and write the complete findings to the GameAssist NPC Death Audit handout. +- Disabling NPCManager clears its configured page markers but preserves saved death-history and Arc records. +- Arc buckets let the GM append selected linked PC/NPC tokens or current-session deaths to story handouts. +- GameAssist uses TokenMod's documented --api-as path, so internal marker requests do not require players-can-ids. +- Marker requests are checked after TokenMod runs; mismatches produce a direct manual test command for the GM. +- !ga-status --details reports detected TokenMod and optional StatusInfo versions/configuration evidence. +- Marker mutation remains inside standalone TokenMod so StatusInfo continues receiving TokenMod observer notifications. +- NPCManager ignores placeholder HP while NPCHPRoller auto-roll-on-add establishes a new token's starting HP. + +HEADER REQUIREMENTS NOTE +Per the Roll20 API repo contribution guidelines, this header provides name, version, last updated, +description, syntax/commands, and configuration pointers near the top of the script. +======================================== +*/ + +// --- MECHSUITS BANNER (YAML) --- +// mechsuit: +// codename: "GAMEASSIST" +// project_version: "v0.1.4.7" +// purpose: "Roll20 API modular kernel and bundled modules with MECHSUITS v1.5.2 contracts, explicit opt-in queue execution, state self-healing, contract-aware dependency diagnostics, verified standalone TokenMod marker requests, and preserved StatusInfo observer delivery. Non-goals: embedded TokenMod/StatusInfo, sheet-specific integrations, implicit event queueing, or transport changes beyond Roll20 chat API." +// order: ["policy","app.utils","core.queue","core.compat","core.state","core.object","interfaces.events","interfaces.commands","modules.configui","modules.critfumble","modules.npcmanager","modules.concentrationtracker","modules.npchproller","modules.debugtools","bootstrap"] +// env: +// required: [] +// optional: [] +// secrets: [] +// data_class: "Internal" +// ai_data: "internal_redacted" +// refusals: +// - "Do not emit secrets or player data outside the Roll20 sandbox." +// - "Do not override Roll20 global on/off handlers." +// observability: +// logs: "roll20_whisper_to_gm" +// metrics: [{ name: "gameassist.queue.task_duration_ms", unit: "ms" }] +// spans: ["[GAMEASSIST:CORE:QUEUE]","[GAMEASSIST:MODULES:CRITFUMBLE]"] +// performance: { notes: "No current benchmark claim; validate in the target Roll20 campaign sandbox." } +// concurrency: { model: "Direct event handlers plus explicit opt-in serialized task queue", idempotency: "N/A (event-driven)" } +// compatibility: { accepts: ["Roll20 API sandbox; current campaign smoke test required"], emits: "Roll20 chat whispers/logs" } +// policy: { notes_ref: "[GAMEASSIST:POLICY]" } +// error_codes: ["INVALID_ARGUMENT","NOT_FOUND","CONFLICT","UNAUTHORIZED","FORBIDDEN","UNPROCESSABLE","RATE_LIMITED","TIMEOUT","UNAVAILABLE","INTERNAL"] +// transport_map: +// chat: "Errors are whispered to GM; status/info are whispered as structured text" +// canonical_tree: | +// [GAMEASSIST]/ +// ├─ [GAMEASSIST:POLICY] +// ├─ [GAMEASSIST:APP] +// │ └─ [GAMEASSIST:APP:UTILS] +// ├─ [GAMEASSIST:CORE] +// │ ├─ [GAMEASSIST:CORE:QUEUE] +// │ ├─ [GAMEASSIST:CORE:COMPAT] +// │ ├─ [GAMEASSIST:CORE:STATE] +// │ └─ [GAMEASSIST:CORE:OBJECT] +// ├─ [GAMEASSIST:INTERFACES] +// │ ├─ [GAMEASSIST:INTERFACES:EVENTS] +// │ └─ [GAMEASSIST:INTERFACES:COMMANDS] +// ├─ [GAMEASSIST:MODULES] +// │ ├─ [GAMEASSIST:MODULES:CONFIGUI] +// │ ├─ [GAMEASSIST:MODULES:CRITFUMBLE] +// │ ├─ [GAMEASSIST:MODULES:NPCMANAGER] +// │ ├─ [GAMEASSIST:MODULES:CONCENTRATIONTRACKER] +// │ ├─ [GAMEASSIST:MODULES:NPCHPROLLER] +// │ └─ [GAMEASSIST:MODULES:DEBUGTOOLS] +// └─ [GAMEASSIST:BOOTSTRAP] +// --- prose banner --- +// Guarantee: GameAssist v0.1.4.7 runs policy, utilities, guarded core services, interfaces, modules, then bootstrap using the declared order and preserves standalone TokenMod/StatusInfo ownership. Secrets required: none. It refuses to emit player data outside Roll20 or override Roll20 global on/off handlers. + +// ============================= +// === GameAssist v0.1.4.7 === +// === 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 R20_ON = (typeof on === 'function') ? on : (typeof globalThis?.on === 'function' ? globalThis.on : null); + if (!R20_ON) throw new Error('Roll20 "on" is unavailable.'); + + // ============================================================================= + // [GAMEASSIST:POLICY] BEGIN + // Section Title: Tunables and operational policy + // ------------------------------------------------------------------------- + // mechsuit_section: { codename: "GAMEASSIST", area: "POLICY", title: "Tunables", + // guarantees: ["Shared behavioral knobs and snapshot identifiers have one owner; marker verification and NPC initialization waits are explicit"], + // provides: ["POLICY"], last_updated_version: "v0.1.4.7", lifecycle: "active" } + // ------------------------------------------------------------------------- + // Narrative + // POLICY owns shared timeouts, cache limits, UI defaults, snapshot identifiers, + // and declared metric names. + // Values preserve v0.1.4 behavior; callers reference POLICY so future + // changes have one explicit review and rollback point. + // ------------------------------------------------------------------------- + const POLICY = Object.freeze({ + queue: Object.freeze({ + defaultTimeoutMs: 30000, + watchdogIntervalMs: 15000, + watchdogMultiplier: 2 + }), + metrics: Object.freeze({ + historyLimit: 50, + durationLimit: 20, + queueDurationName: 'gameassist.queue.task_duration_ms' + }), + runtime: Object.freeze({ + activePlayerLimit: 50, + deathLogLimit: 100, + npcAuditDetailLimit: 8, + deathReportSummaryLimit: 5, + deathReportDetailLimit: 10, + npcHpInitializationGraceMs: 2000, + lastDamageLimit: 50 + }), + timestamps: Object.freeze({ + maxFutureMs: 1000 * 60 * 60 * 24 * 7 + }), + configUi: Object.freeze({ + pageSize: 3 + }), + critFumble: Object.freeze({ + rollDelayMs: 200 + }), + standaloneInterop: Object.freeze({ + markerVerificationDelayMs: 1000 + }), + config: Object.freeze({ + unsafeKeys: Object.freeze(['__proto__', 'prototype', 'constructor']) + }), + snapshots: Object.freeze({ + configFormat: 'gameassist-config-snapshot', + configSchemaVersion: 1 + }) + }); + // --- Notes & Comments --- + // Changed (v0.1.4.7): Added a two-second NPC HP initialization grace period so auto-roll-on-add setup is not recorded as a death/revival; rollback: remove the guard and this policy value. + // Changed (v0.1.4.7): Added a one-second standalone TokenMod marker-verification delay; rollback: remove standaloneInterop and delayed verification. + // Changed (v0.1.4.5): Added runtime death-report summary/detail limits for bounded GM-facing history reports. + // Changed (v0.1.4.4): Added runtime.npcAuditDetailLimit to bound grouped NPC death-audit chat output. + // Changed (v0.1.4.2): Added stable configuration-snapshot format and schema identifiers. + // Maintenance (v0.1.4.3, no semantic change): Removed internal development-provenance wording from the narrative. + // Decision log: + // CHOICE: Version configuration-only exports before import exists; safe import validation remains deferred. + // CHOICE: Freeze shallow policy groups to prevent accidental runtime mutation — ALT: mutable config; REJECTED: hidden drift. + // Prior notes: + // Changed (v0.1.4.1): Centralized existing defaults without changing their values; rollback: restore section-local aliases. + // Maintenance (v0.1.3, no semantic change): Added POLICY narrative and corrected top-level version metadata. + // Maintenance (v0.1.1.2, no semantic change): Updated MECHSUITS metadata for v1.5.1 compliance. + // [GAMEASSIST:POLICY] END + // [GAMEASSIST:APP] BEGIN + // Section Title: App wrapper (utilities and shared helpers) + // ------------------------------------------------------------------------- + // mechsuit_section: { codename: "GAMEASSIST", area: "APP", title: "Wrapper", + // guarantees: ["APP-scoped shared helpers are grouped here", "Standalone marker requests preserve TokenMod observer delivery"], + // depends_on: [], last_updated_version: "v0.1.4.7" } + // ------------------------------------------------------------------------- + // Narrative + // The APP tree houses shared helpers used by core services and bundled modules. + // Utilities below cover argument parsing, state helpers, auditing, sanitization, + // exact Roll20 status-marker identity resolution, and standalone script interoperability. + // ------------------------------------------------------------------------- + + // ————— UTILITIES ————— + // ============================================================================= + // [GAMEASSIST:APP:UTILS] BEGIN + // Section Title: Utilities (arg parsing, state helpers, audit, sanitize) + // ------------------------------------------------------------------------- + // mechsuit_section: { codename: "GAMEASSIST", area: "APP:UTILS", title: "Utilities", + // guarantees: ["Shared helpers; known module state branches self-heal without deleting valid config","Configured built-in/custom marker names resolve to stored Roll20 marker ids","TokenMod marker requests use documented API impersonation and delayed outcome verification"], + // depends_on: ["[GAMEASSIST:POLICY]"], last_updated_version: "v0.1.4.7", lifecycle: "active" } + // ------------------------------------------------------------------------- + // Narrative + // APP:UTILS collects helpers for metrics/state initialization, conservative state + // repair, argument parsing, sanitization, time access, token-character linking, + // exact built-in/custom status-marker identity resolution, external-script evidence, + // and verified requests through standalone TokenMod. + // Roll20 does not guarantee a high-resolution monotonic clock, so monotonic() + // falls back to Date.now() while keeping duration access behind a named seam. + // ------------------------------------------------------------------------- + function now() { + return Date.now(); + } + + function monotonic() { + if (typeof performance !== 'undefined' && typeof performance.now === 'function') { + return performance.now(); + } + return Date.now(); + } + + function isoNow() { + return new Date(now()).toISOString(); + } + + function localNow() { + return new Date(now()).toLocaleString(); + } + + function localTime(raw = now()) { + return new Date(raw).toLocaleTimeString(); + } + + function createMetricsStore() { + return { + totals: {}, + history: [], + durations: [], + sessionStart: isoNow(), + lastUpdate: null + }; + } + + function ensureStateRoot() { + const current = state[STATE_KEY]; + const root = (current && typeof current === 'object' && !Array.isArray(current)) + ? current + : {}; + + if (!root.config || typeof root.config !== 'object') { + root.config = {}; + } + + if (!root.metrics || typeof root.metrics !== 'object') { + root.metrics = createMetricsStore(); + } else { + root.metrics.totals = root.metrics.totals || {}; + root.metrics.history = Array.isArray(root.metrics.history) ? root.metrics.history : []; + root.metrics.durations = Array.isArray(root.metrics.durations) ? root.metrics.durations : []; + if (!root.metrics.sessionStart) { + root.metrics.sessionStart = isoNow(); + } + } + + state[STATE_KEY] = root; + return root; + } + + function getMetricsStore() { + return ensureStateRoot().metrics; + } + + function resetMetricsStore() { + const root = ensureStateRoot(); + root.metrics = createMetricsStore(); + return root.metrics; + } + + function _parseArgs(content) { + // CHOICE: A following --name begins a new flag; it is never consumed as the prior flag's value. + 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 ensureStateBranch(root, mod) { + const repairs = []; + let branch = root[mod]; + + if (!branch || typeof branch !== 'object' || Array.isArray(branch)) { + branch = {}; + root[mod] = branch; + repairs.push('branch'); + } + if (!branch.config || typeof branch.config !== 'object' || Array.isArray(branch.config)) { + branch.config = {}; + repairs.push('config'); + } + if (!branch.runtime || typeof branch.runtime !== 'object' || Array.isArray(branch.runtime)) { + branch.runtime = {}; + repairs.push('runtime'); + } + + return { branch, repairs }; + } + + function getState(mod) { + const root = ensureStateRoot(); + return ensureStateBranch(root, mod).branch; + } + + function saveState(mod, data) { + const root = ensureStateRoot(); + Object.assign(getState(mod), data); + ensureStateBranch(root, mod); + } + + function commandMatches(content, prefix, { caseInsensitive = false, mode = 'token' } = {}) { + const raw = (content || '').trim(); + const pfx = caseInsensitive ? prefix.toLowerCase() : prefix; + const txt = caseInsensitive ? raw.toLowerCase() : raw; + + if (mode === 'prefix') return txt.startsWith(pfx); + if (txt === pfx) return true; + + const escaped = pfx.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const boundary = new RegExp(`^${escaped}(\\s|$)`); + return boundary.test(txt); + } + + const BUILT_IN_MARKER_IDS = new Set([ + 'red', 'blue', 'green', 'brown', 'purple', 'pink', 'yellow', 'dead', + 'skull', 'sleepy', 'half-heart', 'half-haze', 'interdiction', 'snail', + 'lightning-helix', 'spanner', 'chained-heart', 'chemical-bolt', + 'death-zone', 'drink-me', 'edge-crack', 'ninja-mask', 'stopwatch', + 'fishing-net', 'overdrive', 'strong', 'fist', 'padlock', 'three-leaves', + 'fluffy-wing', 'pummeled', 'tread', 'arrowed', 'aura', 'back-pain', + 'black-flag', 'bleeding-eye', 'bolt-shield', 'broken-heart', 'cobweb', + 'broken-shield', 'flying-flag', 'radioactive', 'trophy', 'broken-skull', + 'frozen-orb', 'rolling-bomb', 'white-tower', 'grab', 'screaming', + 'grenade', 'sentry-gun', 'all-for-one', 'angel-outfit', 'archery-target' + ]); + + let customMarkerRegistryCache = { + raw: null, + markers: [], + error: null + }; + + function unwrapMarkerText(marker) { + const text = String(marker || '').split('@')[0].trim(); + if (text.length >= 2) { + const first = text[0]; + const last = text[text.length - 1]; + if ((first === '"' && last === '"') || (first === "'" && last === "'")) { + return text.slice(1, -1).trim(); + } + } + return text; + } + + function normalizeMarkerId(marker) { + return unwrapMarkerText(marker).toLowerCase(); + } + + /** + * getCustomMarkerRegistry — Read Roll20's custom marker display-name/tag map. + * Failure: returns an empty registry plus an actionable error; never throws. + * Design: cache by the raw campaign value so audits do not repeatedly parse JSON. + */ + function getCustomMarkerRegistry() { + let raw; + try { + raw = String(Campaign().get('_token_markers') || '[]'); + } catch (error) { + return { + raw: null, + markers: [], + error: `Campaign marker registry unavailable: ${error.message || error}` + }; + } + + if (customMarkerRegistryCache.raw === raw) { + return customMarkerRegistryCache; + } + + try { + const parsed = JSON.parse(raw); + const markers = (Array.isArray(parsed) ? parsed : []) + .filter(entry => entry && entry.name && entry.tag) + .map(entry => ({ + name: String(entry.name).trim(), + tag: String(entry.tag).split('@')[0].trim() + })) + .filter(entry => entry.name && entry.tag); + + customMarkerRegistryCache = { raw, markers, error: null }; + } catch (error) { + customMarkerRegistryCache = { + raw, + markers: [], + error: `Campaign marker registry is invalid JSON: ${error.message || error}` + }; + } + + return customMarkerRegistryCache; + } + + /** + * resolveMarkerId — Resolve configured marker text to the exact stored marker id. + * Inputs: built-in id, custom display name, or custom stored tag. + * Outputs: a result object; callers must inspect ok before using id. + * Design: literal lowercase built-in ids retain precedence, exact stored + * custom tags do not require the campaign registry, and exact custom display + * names such as "Dead" can still select a custom marker. + */ + function resolveMarkerId(marker) { + const requested = unwrapMarkerText(marker); + const normalized = normalizeMarkerId(requested); + + if (!requested) { + return { + ok: false, + requested, + id: null, + source: null, + ambiguous: false, + candidates: [], + reason: 'empty', + registryError: null + }; + } + + if (requested === normalized && BUILT_IN_MARKER_IDS.has(normalized)) { + return { + ok: true, + requested, + id: normalized, + source: 'built-in', + ambiguous: false, + candidates: [normalized], + reason: null, + registryError: null + }; + } + + if (requested.includes('::')) { + return { + ok: true, + requested, + id: requested, + source: 'custom-tag-direct', + ambiguous: false, + candidates: [requested], + reason: null, + registryError: null + }; + } + + const registry = getCustomMarkerRegistry(); + + const resolved = (matches, source) => ({ + ok: true, + requested, + id: matches[0].tag, + source, + ambiguous: matches.length > 1, + candidates: matches.map(entry => entry.tag), + reason: null, + registryError: registry.error + }); + + const directTagMatches = registry.markers + .filter(entry => normalizeMarkerId(entry.tag) === normalized); + if (directTagMatches.length) return resolved(directTagMatches, 'custom-tag'); + + const exactNameMatches = registry.markers + .filter(entry => entry.name === requested); + if (exactNameMatches.length) return resolved(exactNameMatches, 'custom-name'); + + if (BUILT_IN_MARKER_IDS.has(normalized)) { + return { + ok: true, + requested, + id: normalized, + source: 'built-in', + ambiguous: false, + candidates: [normalized], + reason: null, + registryError: registry.error + }; + } + + const foldedNameMatches = registry.markers + .filter(entry => entry.name.toLowerCase() === normalized); + if (foldedNameMatches.length) return resolved(foldedNameMatches, 'custom-name-folded'); + + return { + ok: false, + requested, + id: null, + source: null, + ambiguous: false, + candidates: [], + reason: registry.error ? 'registry-error' : 'not-found', + registryError: registry.error + }; + } + + function tokenHasMarker(token, marker) { + if (!token || typeof token.get !== 'function') return false; + + const requested = unwrapMarkerText(marker); + const normalized = normalizeMarkerId(requested); + const isStoredId = requested.includes('::') || + (requested === normalized && BUILT_IN_MARKER_IDS.has(normalized)); + const resolution = isStoredId + ? { ok: true, id: requested } + : resolveMarkerId(requested); + + if (!resolution.ok) return false; + const want = normalizeMarkerId(resolution.id); + const list = String(token.get('statusmarkers') || '') + .split(',') + .map(normalizeMarkerId) + .filter(Boolean); + return list.includes(want); + } + + /** + * getStandaloneScriptEvidence — Inspect public contracts without assuming Roll20's script registry exists. + * Inputs: the literal upstream script name TokenMod or StatusInfo. + * Outputs: detected contract/version/config evidence; absence is not automatically proof of missing installation. + * Design: both supplied upstream scripts intentionally expose these globals for script interoperability. + */ + function getStandaloneScriptEvidence(name) { + const requested = String(name || ''); + if (requested === 'TokenMod') { + const api = (typeof TokenMod !== 'undefined' && TokenMod) ? TokenMod : null; + const meta = (typeof API_Meta !== 'undefined' && API_Meta?.TokenMod) + ? API_Meta.TokenMod + : null; + return { + name: requested, + confirmed: Boolean((api && typeof api.ObserveTokenChange === 'function') || meta), + contract: Boolean(api && typeof api.ObserveTokenChange === 'function'), + version: meta?.version ? String(meta.version) : null, + playersCanUseIds: typeof state?.TokenMod?.playersCanUse_ids === 'boolean' + ? state.TokenMod.playersCanUse_ids + : null + }; + } + + if (requested === 'StatusInfo') { + const api = (typeof StatusInfo !== 'undefined' && StatusInfo) ? StatusInfo : null; + return { + name: requested, + confirmed: Boolean(api && typeof api.ObserveTokenChange === 'function'), + contract: Boolean(api && typeof api.ObserveTokenChange === 'function'), + version: api?.version ? String(api.version) : null, + showDescriptions: typeof state?.STATUSINFO?.config?.showDescOnStatusChange === 'boolean' + ? state.STATUSINFO.config.showDescOnStatusChange + : null + }; + } + + return { name: requested, confirmed: false, contract: false, version: null }; + } + + function getStandaloneIntegrationLines() { + const tokenMod = getStandaloneScriptEvidence('TokenMod'); + const statusInfo = getStandaloneScriptEvidence('StatusInfo'); + const tokenModVersion = tokenMod.version ? ` v${tokenMod.version}` : ''; + const statusInfoVersion = statusInfo.version ? ` v${statusInfo.version}` : ''; + const statusInfoDescriptions = statusInfo.showDescriptions === true + ? 'condition descriptions are enabled' + : (statusInfo.showDescriptions === false + ? 'condition descriptions are disabled' + : 'the condition-description setting could not be read'); + const tokenModLine = tokenMod.confirmed + ? `TokenMod${tokenModVersion}: detected; GameAssist can send authorized marker requests and check their results.` + : 'TokenMod: not directly confirmed; enabled marker modules still require a live marker test.'; + const statusInfoLine = statusInfo.confirmed + ? `StatusInfo${statusInfoVersion}: detected; ${statusInfoDescriptions}. Live observation remains the final check.` + : 'StatusInfo: not detected. It is optional unless this campaign uses condition descriptions and menus.'; + return [tokenModLine, statusInfoLine]; + } + + function getTokenModApiIdentity() { + const gm = (findObjs({ _type: 'player' }) || []) + .filter(player => { + try { return playerIsGM(player.id); } + catch { return false; } + }) + .sort((a, b) => String(a.id).localeCompare(String(b.id)))[0] || null; + const evidence = getStandaloneScriptEvidence('TokenMod'); + return { + gmPlayerId: gm?.id || null, + playersCanUseIds: evidence.playersCanUseIds, + evidence + }; + } + + const pendingTokenModMarkerOps = new Map(); + let tokenModMarkerOpId = 0; + + /** + * requestTokenModMarker — Request one marker mutation through standalone TokenMod and verify the result. + * Inputs: Roll20 token, resolved stored marker id, desired boolean state, owning module name. + * Outputs: true when a request was accepted for dispatch; false when validation/authentication prevents dispatch. + * Failure: delayed mismatch warnings give the GM a direct TokenMod command to try on a selected token. + * Design: use TokenMod rather than token.set so StatusInfo keeps receiving TokenMod.ObserveTokenChange notifications. + */ + function requestTokenModMarker(token, marker, on, moduleName) { + if (!token || typeof token.get !== 'function' || !token.id) { + GameAssist.log(moduleName, 'TokenMod marker request could not identify the target token.', 'WARN'); + return false; + } + + const markerId = unwrapMarkerText(marker); + if (!markerId || /[\s|#]/.test(markerId)) { + GameAssist.log(moduleName, `TokenMod marker request refused unsafe or invalid marker id "${_sanitize(markerId)}".`, 'WARN'); + return false; + } + + const desired = on === true; + if (tokenHasMarker(token, markerId) === desired) return true; + + const auth = getTokenModApiIdentity(); + if (!auth.gmPlayerId && auth.playersCanUseIds !== true) { + GameAssist.log( + moduleName, + 'TokenMod marker request could not find a GM identity for --api-as, and TokenMod does not report players-can-ids as enabled. Open !ga-status --details and test TokenMod directly.', + 'WARN' + ); + return false; + } + + const apiAs = auth.gmPlayerId ? ` --api-as ${auth.gmPlayerId}` : ''; + const operation = desired ? '+' : '-'; + const command = `!token-mod${apiAs} --ids ${token.id} --set statusmarkers|${operation}${markerId}`; + const key = `${token.id}|${normalizeMarkerId(markerId)}`; + const operationId = ++tokenModMarkerOpId; + pendingTokenModMarkerOps.set(key, operationId); + sendChat('api', command); + + setTimeout(() => { + if (pendingTokenModMarkerOps.get(key) !== operationId) return; + pendingTokenModMarkerOps.delete(key); + + const current = getObj('graphic', token.id); + if (!current) { + GameAssist.log(moduleName, `TokenMod marker result could not be verified because token ${token.id} no longer exists.`, 'WARN'); + return; + } + if (tokenHasMarker(current, markerId) === desired) return; + + const tokenName = current.get('name') || '(Unnamed Token)'; + const verb = desired ? 'add' : 'remove'; + const manual = `!token-mod --ids @{selected|token_id} --set statusmarkers|${operation}${markerId}`; + GameAssist.log( + moduleName, + `TokenMod did not ${verb} marker "${markerId}" on ${tokenName}. Select that token and try ${manual}. Check !ga-status --details for detected standalone versions.`, + 'WARN' + ); + }, POLICY.standaloneInterop.markerVerificationDelayMs); + + return true; + } + function clearState(mod) { + const root = ensureStateRoot(); + if (root?.[mod]) delete root[mod]; + } + + function auditState() { + const root = ensureStateRoot(); + const whitelist = new Set(['config', 'flags', 'metrics']); + const repaired = []; + const unknown = []; + + Object.keys(root).forEach(k => { + if (whitelist.has(k)) return; + + const mod = MODULES[k]; + if (!mod || mod.internal) { + unknown.push(k); + GameAssist.log('Core', `Unexpected state branch: ${k}`, 'WARN'); + } + }); + + Object.entries(MODULES) + .filter(([, mod]) => !mod.internal) + .forEach(([name]) => { + if (!Object.prototype.hasOwnProperty.call(root, name)) return; + + const result = ensureStateBranch(root, name); + if (!result.repairs.length) return; + + repaired.push({ name, fields: result.repairs.slice() }); + GameAssist.log('Core', `Repaired state for ${name}: ${result.repairs.join(', ')}`, 'WARN'); + recordMetric('state_repair', { mod: name, note: result.repairs.join(',') }); + }); + + GameAssist._metrics.stateAudits++; + GameAssist._metrics.lastUpdate = isoNow(); + recordMetric('audit', { noHistory: true }); + return { repaired, unknown }; + } + + function seedDefaults() { + Object.entries(MODULES).forEach(([name, mod]) => { + if (mod.internal) return; + const cfg = getState(name).config; + if (cfg.enabled === undefined) cfg.enabled = mod.enabled; + }); + } + + function recordMetric(type, { mod = null, note = '', noHistory = false, duration = null } = {}) { + if (!type) return; + + const store = getMetricsStore(); + const totals = store.totals; + totals[type] = (totals[type] || 0) + 1; + + const timestamp = isoNow(); + store.lastUpdate = timestamp; + + if (!noHistory) { + const entry = { ts: timestamp, type }; + if (mod) entry.mod = mod; + if (note !== undefined && note !== null) { + const text = String(note).slice(0, 120); + if (text) entry.note = text; + } + store.history.push(entry); + if (store.history.length > POLICY.metrics.historyLimit) { + store.history.splice(0, store.history.length - POLICY.metrics.historyLimit); + } + } + + if (typeof duration === 'number' && isFinite(duration)) { + const durations = store.durations; + durations.push(Math.max(0, Math.round(duration))); + if (durations.length > POLICY.metrics.durationLimit) { + durations.splice(0, durations.length - POLICY.metrics.durationLimit); + } + } + } + + function ensureRuntimeObject(modState) { + if (!modState || typeof modState !== 'object') { + return {}; + } + + const runtime = modState.runtime; + + if (!runtime || typeof runtime !== 'object' || Array.isArray(runtime)) { + modState.runtime = {}; + } + + return modState.runtime; + } + + function ensureRuntimeKey(runtime, key, kind) { + if (!runtime || typeof runtime !== 'object') return null; + + const value = runtime[key]; + if (kind === 'array') { + if (!Array.isArray(value)) runtime[key] = []; + } else if (kind === 'object') { + if (!value || typeof value !== 'object' || Array.isArray(value)) runtime[key] = {}; + } + + return runtime[key]; + } + + function ensureModRuntimeKey(modState, key, kind) { + const runtime = ensureRuntimeObject(modState); + return ensureRuntimeKey(runtime, key, kind); + } + + function sanitizeTimestamp(raw, fallback) { + const current = now(); + const fb = (typeof fallback === 'number' && Number.isFinite(fallback) && fallback >= 0) + ? fallback + : current; + + const ts = Number(raw); + + if (!Number.isFinite(ts)) return fb; + if (ts <= 0) return fb; + + if (ts > current + POLICY.timestamps.maxFutureMs) return fb; + + return Math.floor(ts); + } + + function _sanitize(str = '') { + return str.toString() + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/'/g, ''') + .replace(/"/g, '"'); + } + + function getLinkedCharacter(token) { + if (!token || typeof token.get !== 'function') return null; + if (token.get('layer') !== 'objects') return null; + + const charId = token.get('represents'); + if (!charId) return null; + + const character = getObj('character', charId); + if (!character) return null; + + return { token, character }; + } + // --- Notes & Comments --- + // NOTE: State auditor warns about unexpected branches; no automatic deletion occurs. + // Changed (v0.1.4.7): Detect TokenMod/StatusInfo through their public contracts, dispatch marker requests through TokenMod --api-as, and warn when delayed marker verification fails. + // Changed (v0.1.4.5): Keep adjacent command flags independent so combinations such as --nested --confirm and --session --allowDuplicates execute as displayed. + // Changed (v0.1.4.3): Resolve configured built-in/custom marker names to exact stored Roll20 ids and return explicit resolution failures. + // Changed (v0.1.4.3): Exact stored custom marker tags resolve before registry access so valid configured tags survive registry read failures. + // Changed (v0.1.4.3): Marker resolution strips simple matching quote pairs so chat config like marker="red" resolves as red. + // Decision log: + // CHOICE: Unknown branches remain warning-only; explicit cleanup is required before deletion. + // CHOICE: monotonic() falls back to Date.now() in Roll20 — ALT: assume performance.now; REJECTED: sandbox portability. + // CHOICE: Preserve literal lowercase built-in ids before custom display names, then honor exact-case custom names — ALT: always prefer custom names; REJECTED: a custom "dead" could silently replace NPCManager's built-in default. + // CHOICE: Fast-path exact stored ids during resolution and token comparison — ALT: require registry confirmation; REJECTED: valid stored tags should remain usable when the registry is unavailable. + // CHOICE: Keep marker mutation inside standalone TokenMod — ALT: token.set from GameAssist; REJECTED: bypassed TokenMod observers used by StatusInfo. + // CHOICE: Prefer TokenMod --api-as with a GM id — ALT: require players-can-ids; REJECTED: script-to-script calls should not depend on a player-facing permission toggle. + // Prior notes: + // Changed (v0.1.4.2): Known module state branches now self-heal malformed config/runtime containers while preserving valid configuration. + // Changed (v0.1.4.1): Added wall/monotonic clock seams and routed shared limits through POLICY. + // Changed (v0.1.4): Added regex-based command matching and shared marker helpers for teardown visibility. + // Changed (v0.1.3): Added ensureModRuntimeKey, sanitizeTimestamp, and runtime guards for post-toggle self-healing. + // Maintenance (v0.1.3, no semantic change): Added narrative for APP utilities and aligned version metadata. + // Maintenance (v0.1.1.2, no semantic change): Section metadata aligned to MECHSUITS v1.5.1. + // [GAMEASSIST:APP:UTILS] END + // ============================================================================= + + // --- Notes & Comments --- + // Changed (v0.1.4.7): Updated APP contract to include verified standalone TokenMod requests while preserving StatusInfo observer delivery. + // Changed (v0.1.4.3): Updated APP contract to include shared exact marker-identity resolution. + // Prior notes (selected): + // Maintenance (v0.1.3, no semantic change): Updated APP wrapper commentary after utility narrative addition; nesting remains explicit over APP:UTILS only. + // • Maintenance (v0.1.1.2, no semantic change): APP wrapper relocated to avoid implied nesting over non-APP sections while still covering APP:UTILS. + // • MIT license retained; copyright © 2025 Mord Eagle. + // • Modules: ConfigUI, CritFumble, NPCManager, ConcentrationTracker, NPCHPRoller, DebugTools. + // • Queue/watchdog defaults preserved (30s/15s). + // • Logging emits /w gm with icons and timestamp. + // [GAMEASSIST:APP] END + // ============================================================================= + + // ============================================================================= + // [GAMEASSIST:CORE] BEGIN + // Section Title: Core wrapper (constants and kernel services) + // ------------------------------------------------------------------------- + // mechsuit_section: { codename: "GAMEASSIST", area: "CORE", title: "Core wrapper", + // guarantees: ["Core constants and kernel services are grouped; children own logic"], + // depends_on: ["[GAMEASSIST:POLICY]","[GAMEASSIST:APP]"], last_updated_version: "v0.1.4.7", + // lifecycle: "active" } + // ------------------------------------------------------------------------- + // Narrative + // CORE wraps the foundational constants, queue, compatibility checks, state, + // and object utilities. Children carry the executable code; this wrapper + // documents scope and anchors the hierarchy for MECHSUITS compliance. + // ------------------------------------------------------------------------- + + const VERSION = '0.1.4.7'; + const STATE_KEY = 'GameAssist'; + const MODULES = {}; + const _transitioning = {}; + let READY = false; + const METRIC_HISTORY_LIMIT = POLICY.metrics.historyLimit; + const METRIC_DURATION_LIMIT = POLICY.metrics.durationLimit; + + MODULES.Core = { + internal: true, + initFn: () => {}, + teardown: null, + enabled: true, + initialized: true, + active: true, + events: [], + prefixes: [], + wired: true, + dependsOn: [] + }; + + // ————— QUEUE + WATCHDOG ————— + // ============================================================================= + // [GAMEASSIST:CORE:QUEUE] BEGIN + // Section Title: Serialized task queue + watchdog + // ------------------------------------------------------------------------- + // mechsuit_section: { codename: "GAMEASSIST", area: "CORE:QUEUE", title: "Queue", + // guarantees: ["Only explicitly enqueued tasks serialize; stale completions cannot advance the queue"], + // depends_on: ["[GAMEASSIST:POLICY]","[GAMEASSIST:APP:UTILS]"], + // observability: { metrics: [{ name: "gameassist.queue.task_duration_ms", unit: "ms" }] }, + // last_updated_version: "v0.1.4.2", lifecycle: "active" } + // ------------------------------------------------------------------------- + // Narrative + // CORE:QUEUE serializes only tasks explicitly submitted through internal _enqueue + // or public GameAssist.enqueue. Normal command/event handlers execute directly. + // A timeout releases the queue but cannot cancel underlying JavaScript or Roll20 work. + // ------------------------------------------------------------------------- + let _busy = false; + let _lastStart = 0; + let _jobId = 0; + const _queue = []; + const DEFAULT_TIMEOUT = POLICY.queue.defaultTimeoutMs; + const WATCHDOG_INTERVAL = POLICY.queue.watchdogIntervalMs; + + function _enqueue(task, priority = 0, timeout = DEFAULT_TIMEOUT) { + _queue.push({ task, priority, enqueuedAt: monotonic(), timeout }); + _queue.sort((a,b) => b.priority - a.priority || a.enqueuedAt - b.enqueuedAt); + _runNext(); + } + + function _runNext() { + if (_busy || !_queue.length) return; + + const job = _queue.shift(); + const myId = ++_jobId; + _busy = true; + _lastStart = monotonic(); + + let timedOut = false; + + const timer = setTimeout(() => { + if (myId !== _jobId) return; + timedOut = true; + GameAssist.log('Core', `Task timed out after ${job.timeout}ms`, 'WARN'); + _busy = false; + _runNext(); + }, job.timeout); + + Promise.resolve() + .then(job.task) + .catch(err => GameAssist.log('Core', `Error in task: ${err?.message || err}`, 'ERROR')) + .finally(() => { + clearTimeout(timer); + if (myId !== _jobId) return; + if (timedOut) return; + _busy = false; + const duration = monotonic() - _lastStart; + GameAssist._metrics.taskDurations.push(duration); + if (GameAssist._metrics.taskDurations.length > METRIC_DURATION_LIMIT) { + GameAssist._metrics.taskDurations.shift(); + } + GameAssist._metrics.lastUpdate = isoNow(); + recordMetric('task', { noHistory: true }); + recordMetric(POLICY.metrics.queueDurationName, { duration, noHistory: true }); + _runNext(); + }); + } + + setInterval(() => { + if (_busy && monotonic() - _lastStart > DEFAULT_TIMEOUT * POLICY.queue.watchdogMultiplier) { + GameAssist.log('Core', 'Watchdog forced queue reset', 'WARN'); + _busy = false; + _runNext(); + } + }, WATCHDOG_INTERVAL); + // --- Notes & Comments --- + // Decision log: + // CHOICE: FIFO with priority bump via sort; simple and sufficient for sandbox. + // CHOICE: Watchdog multiplier remains 2; policy owns the value and preserves legacy behavior. + // CHOICE: Timeouts release queue ownership but never claim to cancel underlying Roll20 work. + // Changed (v0.1.4.2): Clarified the opt-in queue contract and timeout limitation for the public enqueue seam. + // Prior notes: + // Changed (v0.1.4.1): Routed queue timing through POLICY/monotonic() and emitted the declared duration metric name. + // Changed (v0.1.4): Added job id guard to prevent stale completions advancing the queue after timeout. + // Maintenance (v0.1.3, no semantic change): Added narrative and reconfirmed queue/watchdog defaults. + // Maintenance (v0.1.1.2, no semantic change): MECHSUITS metadata updated for v1.5.1 compliance. + // [GAMEASSIST:CORE:QUEUE] END + // ============================================================================= + + // ————— HANDLER TRACKING ————— + // ============================================================================= + // ============================================================================= + + // ============================================================================= + // ============================================================================= + + // ————— COMPATIBILITY ————— + // ============================================================================= + // [GAMEASSIST:CORE:COMPAT] BEGIN + // Section Title: Compatibility audit + // ------------------------------------------------------------------------- + // mechsuit_section: { codename: "GAMEASSIST", area: "CORE:COMPAT", title: "Compat", + // guarantees: ["Optional visibility of known/unknown scripts"], last_updated_version: "v0.1.1.2" } + // ------------------------------------------------------------------------- + // Narrative + // CORE:COMPAT inspects other loaded scripts against known signatures to highlight + // overlapping prefixes or event hooks. It is gated by DEBUG_COMPAT to avoid noisy + // logs during normal play. + // ------------------------------------------------------------------------- + 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, ''); + } + const SIGNATURES = (() => { + const defs = [ + { + id: 'TokenMod', + displayName: 'TokenMod', + aliases: ['TokenMod', 'Token Mod'], + prefixes: ['!token-mod', '!tokenmod', '!tm'], + events: ['chat:message', 'change:graphic:statusmarkers'], + hints: [ + 'TokenMod drives status-marker automation; NPCManager and ConcentrationTracker expect it for marker toggles.', + 'If TokenMod is offline, disable marker-dependent modules with !ga-disable to avoid duplicate whispers.' + ] + }, + { + id: 'ScriptCards', + displayName: 'ScriptCards', + aliases: ['ScriptCards'], + prefixes: ['!scriptcard', '!scriptcards'], + events: ['chat:message'], + hints: [ + 'ScriptCards and GameAssist both watch chat:message—keep command prefixes distinct to prevent clashes.' + ] + }, + { + id: 'APILogic', + displayName: 'APILogic', + aliases: ['APILogic'], + prefixes: ['!apilogic'], + events: ['chat:message', 'change:graphic'], + hints: [ + 'APILogic can rewrite chat commands; prefer explicit !ga- prefixes when integrating with it.' + ] + } + ]; + const map = {}; + defs.forEach(def => { + const key = normalizeScriptName(def.id); + const match = new Set(def.aliases.map(normalizeScriptName)); + match.add(key); + map[key] = { + displayName: def.displayName, + match, + prefixes: def.prefixes.map(p => p.toLowerCase()), + events: def.events, + hints: def.hints + }; + }); + return map; + })(); + function resolveSignature(normName) { + if (SIGNATURES[normName]) return SIGNATURES[normName]; + return Object.values(SIGNATURES).find(sig => sig.match.has(normName)) || null; + } + function auditCompatibility() { + if (!GameAssist.flags.DEBUG_COMPAT) return; + + const plannedEvents = GameAssist._plannedEvents; + const plannedPrefixes = GameAssist._plannedChatPrefixes; + const scriptState = state.api?.scripts; + + if (!scriptState || !Object.keys(scriptState).length) { + GameAssist.log('Compat', 'Sandbox did not expose external scripts; compatibility scoring limited.'); + GameAssist.log('Compat', '🔌 Events: ' + (plannedEvents.join(', ') || 'none')); + GameAssist.log('Compat', '💬 Commands: ' + (plannedPrefixes.join(', ') || 'none')); + return; + } + + const activeEntries = Object.keys(scriptState); + const activeNormalized = activeEntries.map(normalizeScriptName); + + const knownSet = new Set([ + ...KNOWN_SCRIPTS.map(normalizeScriptName), + ...activeNormalized + .map(resolveSignature) + .filter(Boolean) + .flatMap(sig => Array.from(sig.match)) + ]); + + const known = []; + const unknown = []; + + activeNormalized.forEach((norm, idx) => { + const original = activeEntries[idx]; + if (knownSet.has(norm)) { + known.push(original); + } else { + unknown.push(original); + } + }); + + GameAssist.log('Compat', '✅ Known: ' + (known.join(', ') || 'none')); + GameAssist.log('Compat', '❓ Unknown: ' + (unknown.join(', ') || 'none')); + GameAssist.log('Compat', '🔌 Events: ' + (plannedEvents.join(', ') || 'none')); + GameAssist.log('Compat', '💬 Commands: ' + (plannedPrefixes.join(', ') || 'none')); + + const rows = []; + + activeNormalized.forEach((norm, idx) => { + const signature = resolveSignature(norm); + if (!signature) return; + + const prefixMatches = plannedPrefixes.filter(prefix => + signature.prefixes.includes(prefix.toLowerCase()) + ); + const eventMatches = plannedEvents.filter(evt => + signature.events.includes(evt) + ); + + const score = prefixMatches.length + (eventMatches.length * 2); + const notes = []; + + if (signature.hints?.length) notes.push(...signature.hints); + if (prefixMatches.length) notes.push('Shared prefixes: ' + prefixMatches.join(', ')); + if (eventMatches.length) notes.push('Shared events: ' + eventMatches.join(', ')); + + rows.push({ + raw: activeEntries[idx], + score, + notes + }); + }); + + if (!rows.length) { + GameAssist.log('Compat', 'No signature overlaps detected.'); + return; + } + + rows.sort((a, b) => b.score - a.score || a.raw.localeCompare(b.raw)); + + const table = [ + '| Script | Score | Notes |', + '| ------ | ----: | ----- |', + ...rows.map(row => { + const noteText = row.notes.length ? row.notes.join(' · ') : 'No overlaps detected.'; + return `| ${row.raw} | ${row.score} | ${noteText} |`; + }) + ].join('\n'); + + GameAssist.log('Compat', `Compatibility hints:\n${table}`); + } + // --- Notes & Comments --- + // CHOICE: DEBUG_COMPAT gate avoids noise; GM toggles as needed. + // Maintenance (v0.1.3, no semantic change): Added narrative clarifying gating and kept + // compatibility heuristics unchanged; version metadata corrected. + // Prior notes: Maintenance (v0.1.1.2, no semantic change): MECHSUITS compliance metadata refreshed. + // [GAMEASSIST:CORE:COMPAT] END + // ============================================================================= + + // ————— CONFIG PARSER ————— + // ============================================================================= + // [GAMEASSIST:CORE:STATE] BEGIN + // Section Title: Config parser (aux to state management) + // ------------------------------------------------------------------------- + // mechsuit_section: { codename: "GAMEASSIST", area: "CORE:STATE", title: "Config parser", + // guarantees: ["Parse JSON/boolean/number safely"], last_updated_version: "v0.1.4" } + // ------------------------------------------------------------------------- + // Narrative + // CORE:STATE handles normalization of chat-provided configuration strings into + // booleans, numbers, JSON, or passthrough text. It logs rather than throws to + // protect chat usability. + // ------------------------------------------------------------------------- + function parseConfigValue(raw) { + raw = String(raw ?? '').trim(); + if (raw === 'true') return true; + if (raw === 'false') return false; + if (raw !== '' && /^-?\d+(\.\d+)?$/.test(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; + } + // --- Notes & Comments --- + // CHOICE: Gracefully log bad JSON rather than throwing; keeps chat usable. + // Changed (v0.1.4): Guarded numeric parsing against empty strings to avoid silent 0 writes from blank inputs. + // Prior notes: Maintenance (v0.1.3, no semantic change): Added narrative about normalization while keeping parsing + // behavior identical; version metadata aligned to 0.1.3 banner. + // Prior notes: Maintenance (v0.1.1.2, no semantic change): Added MECHSUITS v1.5.1 tracking metadata. + // [GAMEASSIST:CORE:STATE] END + // ============================================================================= + + // ————— GameAssist CORE ————— + // ============================================================================= + // [GAMEASSIST:CORE:OBJECT] BEGIN + // Section Title: GameAssist kernel object + // ------------------------------------------------------------------------- + // mechsuit_section: { codename: "GAMEASSIST", area: "CORE:OBJECT", title: "Kernel", + // guarantees: ["Logging, explicit enqueue, dependency diagnostics, register/enable/disable, listener management", "Public standalone contracts can confirm dependencies when Roll20 script metadata is absent", "Module registration may explicitly retain durable runtime state across disable/enable transitions", "Failed dependency enable checks preserve the module's existing configured intent"], + // depends_on: ["[GAMEASSIST:POLICY]","[GAMEASSIST:APP:UTILS]","[GAMEASSIST:CORE:QUEUE]"], + // last_updated_version: "v0.1.4.7", lifecycle: "active" } + // ------------------------------------------------------------------------- + // Narrative + // CORE:OBJECT exposes the GameAssist singleton with metrics, logging, explicit + // queue submission, contract-aware dependency diagnostics, registration helpers, and + // safe enable/disable hooks. A refused dependency enable leaves prior configuration + // unchanged; existing command/event execution remains direct. + // ------------------------------------------------------------------------- + const GameAssist = { + _metrics: { + commands: 0, + messages: 0, + errors: 0, + stateAudits: 0, + taskDurations: [], + lastUpdate: null + }, + _plannedEvents: [], + _plannedChatPrefixes: [], + _listeners: {}, + _commandHandlers: {}, + _eventHandlers: {}, + _transitioning, + config: {}, + flags: { DEBUG_COMPAT: false, QUIET_STARTUP: true }, + + log(mod, msg, level = 'INFO', { startup = false } = {}) { + if (startup && GameAssist.flags.QUIET_STARTUP) return; + + const timestamp = localTime(); + const levelIcon = { INFO: 'ℹ️', WARN: '⚠️', ERROR: '❌' }[level] || 'ℹ️'; + const safeLines = _sanitize(String(msg ?? '')).split('\n'); + const body = safeLines.join('
'); + + sendChat( + 'GameAssist', + `/w gm ${levelIcon} [${timestamp}] [${_sanitize(mod)}] ${body}` + ); + }, + + handleError(mod, err) { + this._metrics.errors++; + this._metrics.lastUpdate = isoNow(); + recordMetric('error', { mod, note: err?.message || String(err) }); + this.log(mod, err.message || String(err), 'ERROR'); + }, + + register(name, initFn, { + enabled = true, + events = [], + prefixes = [], + teardown = null, + dependsOn = [], + preserveRuntimeOnDisable = false + } = {}) { + 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, + active: false, + events, + prefixes, + dependsOn, + preserveRuntimeOnDisable: preserveRuntimeOnDisable === true, + wired: false, + internal: false + }; + this._plannedEvents.push(...events); + this._plannedChatPrefixes.push(...prefixes); + }, + + onCommand(prefix, fn, mod, { gmOnly = false, acl = [], match = { caseInsensitive: true, mode: 'token' } } = {}) { + const wrapped = msg => { + if (!MODULES[mod]?.initialized || !MODULES[mod]?.active) return; + if (msg.type !== 'api' || !commandMatches(msg.content, prefix, match)) return; + if (gmOnly && !playerIsGM(msg.playerid)) return; + if (acl.length && !acl.includes(msg.playerid)) return; + this._metrics.commands++; + this._metrics.lastUpdate = isoNow(); + recordMetric('command', { mod, note: prefix, noHistory: true }); + try { fn(msg); } + catch(e) { this.handleError(mod, e); } + }; + R20_ON('chat:message', wrapped); + this._commandHandlers[mod] = (this._commandHandlers[mod] || []).concat({ event:'chat:message', fn:wrapped }); + }, + + offCommands(mod) { + // Clears internal bookkeeping only; does not detach from Roll20 bus. + this._commandHandlers[mod] = []; + }, + + onEvent(evt, fn, mod) { + const wrapped = (...args) => { + if (!MODULES[mod]?.initialized || !MODULES[mod]?.active) return; + if (!READY) return; + this._metrics.messages++; + this._metrics.lastUpdate = isoNow(); + recordMetric('event', { mod, note: evt, noHistory: true }); + try { fn(...args); } + catch(e) { this.handleError(mod, e); } + }; + R20_ON(evt, wrapped); + this._listeners[mod] = (this._listeners[mod] || []).concat({ event:evt, fn:wrapped }); + }, + + offEvents(mod) { + // Clears internal bookkeeping only; does not detach from Roll20 bus. + this._listeners[mod] = []; + }, + + _clearAllListeners() { + this._commandHandlers = {}; + this._listeners = {}; + }, + + _dedupePlanned() { + if (this._deduped) return; + this._plannedEvents = [...new Set(this._plannedEvents)]; + this._plannedChatPrefixes = [...new Set(this._plannedChatPrefixes)]; + this._deduped = true; + }, + + _getActiveScriptNames() { + const scripts = state.api?.scripts; + if (!scripts) return null; + return Object.keys(scripts).map(normalizeScriptName); + }, + + _checkDependencies(name) { + const mod = MODULES[name]; + if (!mod) { + return { + status: 'missing', + missing: [name], + confirmed: [], + unverifiable: [], + verified: true + }; + } + + const deps = mod.dependsOn || []; + if (!deps.length) { + return { + status: 'confirmed', + missing: [], + confirmed: [], + unverifiable: [], + verified: true + }; + } + + const activeExternal = this._getActiveScriptNames(); + const missing = []; + const confirmed = []; + const unverifiable = []; + + deps.forEach(dep => { + const normalized = normalizeScriptName(dep); + if (MODULES[dep]) { + if (MODULES[dep].active) confirmed.push(dep); + else missing.push(dep); + return; + } + + const contractEvidence = getStandaloneScriptEvidence(dep); + if (contractEvidence.confirmed) { + confirmed.push(dep); + return; + } + + if (activeExternal === null) { + unverifiable.push(dep); + } else if (activeExternal.includes(normalized)) { + confirmed.push(dep); + } else { + missing.push(dep); + } + }); + + const status = missing.length + ? 'missing' + : (unverifiable.length ? 'unverifiable' : 'confirmed'); + + return { + status, + missing, + confirmed, + unverifiable, + verified: status !== 'unverifiable' + }; + }, + + /** + * enqueue — Explicitly submit work to the serialized queue. + * Async work must return a Promise that settles when the queued portion is done. + * A timeout releases the queue; it cannot cancel underlying Roll20 operations. + */ + enqueue(task, options = {}) { + if (typeof task !== 'function') { + this.log('Core', 'GameAssist.enqueue requires a task function.', 'WARN'); + return false; + } + + const opts = (options && typeof options === 'object') ? options : {}; + const priorityRaw = Number(opts.priority); + const timeoutRaw = Number(opts.timeout); + const priority = Number.isFinite(priorityRaw) ? priorityRaw : 0; + const timeout = Number.isFinite(timeoutRaw) && timeoutRaw > 0 + ? timeoutRaw + : DEFAULT_TIMEOUT; + + _enqueue(task, priority, timeout); + return true; + }, + + enableModule(name) { + const mod = MODULES[name]; + if (!mod) { + this.log('Core', `No such module: ${name}`, 'WARN'); + return; + } + if (mod.internal) { + this.log('Core', `${name} is managed by the core and cannot be toggled.`, 'WARN'); + return; + } + if (mod.active && mod.initialized) { + this.log('Core', `${name} already enabled`, 'INFO'); + return; + } + if (_transitioning[name]) { + this.log('Core', `${name} is already transitioning`, 'WARN'); + return; + } + + const depInfo = this._checkDependencies(name); + if (depInfo.status === 'missing') { + this.log('Core', `${name} requires ${depInfo.missing.join(', ')}. Enable dependencies first.`, 'WARN'); + // CHOICE: Refuse activation without changing config - the module may be configured-and-skipped or deliberately disabled. + return; + } + if (depInfo.status === 'unverifiable') { + this.log('Core', `${name} dependencies unverifiable (${depInfo.unverifiable.join(', ')}); proceeding without confirmation.`, 'WARN'); + } + + _transitioning[name] = true; + + _enqueue(() => { + const m = MODULES[name]; + const finish = () => { delete _transitioning[name]; }; + if (!m) { finish(); return; } + + const branch = getState(name); + branch.config.enabled = true; + branch.runtime = branch.runtime || {}; + + if (!m.wired) { + try { + m.initFn(); + m.wired = true; + } catch (e) { + m.initialized = false; + m.active = false; + branch.config.enabled = false; + finish(); + this.handleError(name, e); + return; + } + } + + m.initialized = true; + m.active = true; + this._metrics.lastUpdate = isoNow(); + this.log(name, 'Enabled'); + recordMetric('toggle', { mod: name, note: 'enabled' }); + finish(); + }); + }, + + disableModule(name) { + const mod = MODULES[name]; + if (!mod) { + this.log('Core', `No such module: ${name}`, 'WARN'); + return; + } + if (mod.internal) { + this.log('Core', `${name} is managed by the core and cannot be toggled.`, 'WARN'); + return; + } + const configured = getState(name).config.enabled !== false; + // CHOICE: A configured-but-inactive dependency skip is still enabled from the DM's perspective and must remain disableable. + if (!configured && !mod.active && !mod.initialized) { + this.log('Core', `${name} already disabled`, 'INFO'); + return; + } + if (_transitioning[name]) { + this.log('Core', `${name} is already transitioning`, 'WARN'); + return; + } + _transitioning[name] = true; + + _enqueue(() => { + const m = MODULES[name]; + const finish = () => { delete _transitioning[name]; }; + if (!m) { finish(); return; } + + m.active = false; + + if (typeof m.teardown === 'function' && m.wired) { + try { m.teardown(); } + catch(e) { this.log(name, `Teardown failed: ${e.message}`, 'WARN'); } + } + + const branch = getState(name); + branch.config.enabled = false; + if (!m.preserveRuntimeOnDisable) { + branch.runtime = {}; + } + + m.initialized = false; + this._metrics.lastUpdate = isoNow(); + this.log(name, 'Disabled'); + recordMetric('toggle', { mod: name, note: 'disabled' }); + finish(); + }); + }, + + createButton(label, command) { + const safeLabel = _sanitize(label ?? 'Button') + .replace(/\[/g, '[') + .replace(/\]/g, ']'); + const trimmed = (command || '').trim(); + if (!trimmed) { + this.log('Core', 'createButton requires a command string', 'WARN'); + return ''; + } + const final = trimmed.startsWith('!') ? trimmed : `!${trimmed}`; + return `[${safeLabel}](${final})`; + }, + + rollTable(tableName) { + const name = (tableName || '').toString().trim(); + if (!name) { + this.log('Core', 'rollTable requires a table name', 'WARN'); + return; + } + const sanitized = name.replace(/[\[\]]/g, ''); + sendChat('CritFumble', `/roll 1t[${sanitized}]`); + } + }; + + GameAssist.getState = getState; + GameAssist.saveState = saveState; + GameAssist.clearState = clearState; + GameAssist.getMetricsStore = getMetricsStore; + GameAssist.recordMetric = recordMetric; + GameAssist.getLinkedCharacter = getLinkedCharacter; + + globalThis.GameAssist = GameAssist; + // --- Notes & Comments --- + // CHOICE: Expose globally under same name for console and other scripts. + // CHOICE: Keep normal handlers direct and make serialized work an explicit GameAssist.enqueue call. + // CHOICE: Runtime retention is registration-level opt-in; ALT: preserve every module runtime; REJECTED: existing modules use runtime as disposable cache state. + // Changed (v0.1.4.7): Public TokenMod contract/API metadata can confirm the dependency when Roll20's internal script registry is unavailable. + // Changed (v0.1.4.6): Refused enable attempts with confirmed missing dependencies preserve configured intent, and configured-but-inactive dependency skips can still be disabled through normal lifecycle commands. + // Decision log: + // CHOICE: Leave config.enabled unchanged when dependency enablement is refused - ALT: force false; REJECTED: erased enabled intent and concealed dependency-skipped modules from status reporting. + // CHOICE: Treat a module as already disabled only when both config and runtime are disabled - ALT: runtime-only check; REJECTED: trapped dependency-skipped modules in the enabled configuration. + // Prior notes: + // Changed (v0.1.4.5): Added preserveRuntimeOnDisable so modules with durable runtime-owned records can survive ordinary disable/enable transitions without changing the default cache-clearing lifecycle. + // Changed (v0.1.4.2): Added public opt-in enqueue and confirmed/missing/unverifiable dependency diagnostics without changing direct event execution. + // Maintenance (v0.1.4.1, no semantic change): Routed kernel timestamps through the shared wall-clock seam. + // Changed (v0.1.4): Hardened logging whisper formatting, adopted captured R20_ON, and added safer command matching. + // Maintenance (v0.1.3, no semantic change): Added kernel narrative to document invariants. + // Maintenance (v0.1.1.2, no semantic change): Updated MECHSUITS metadata; behavior untouched. + // [GAMEASSIST:CORE:OBJECT] END + // ============================================================================= + + // --- Notes & Comments --- + // Changed (v0.1.4.7): Advanced runtime VERSION for the standalone TokenMod/StatusInfo interoperability release; child service order is unchanged. + // Changed (v0.1.4.6): Advanced runtime VERSION for the DM-readable status and troubleshooting release; child service order is unchanged. + // Changed (v0.1.4.5): Advanced runtime VERSION for the NPC death-history bucket and handout release; child service order is unchanged. + // Changed (v0.1.4.4): Advanced runtime VERSION for the DM-facing readability release; child service order is unchanged. + // Prior notes: + // Changed (v0.1.4.3): Advanced runtime VERSION for standalone-interoperability stabilization; child service order is unchanged. + // Changed (v0.1.4.2): Advanced runtime VERSION for the diagnostic and migration-readiness release; child service order is unchanged. + // Changed (v0.1.4.1): Advanced runtime VERSION and linked inherited limits to POLICY; child service order is unchanged. + // Maintenance (v0.1.3, no semantic change): Added CORE wrapper to enclose kernel children and satisfy parent rules. + // [GAMEASSIST:CORE] END + // ============================================================================= + + // ————— INTERFACES (EVENTS + COMMANDS) ————— + // ============================================================================= + // [GAMEASSIST:INTERFACES] BEGIN + // Section Title: Interfaces wrapper (events + commands) + // ------------------------------------------------------------------------- + // mechsuit_section: { codename: "GAMEASSIST", area: "INTERFACES", title: "Interfaces wrapper", + // guarantees: ["Interfaces are grouped; children retain behavior"], + // depends_on: ["[GAMEASSIST:CORE]"], last_updated_version: "v0.1.4" } + // ------------------------------------------------------------------------- + // Narrative + // INTERFACES groups inbound chat/event surfaces. Children hold the executable + // handlers; this wrapper documents scope, ordering, and dependencies while + // preserving runtime behavior. Roll20 chat is a human-facing event bus rather + // than HTTP/GraphQL/CLI, so legacy whispers/templates are the declared transport + // adaptation; GameAssist does not invent trace-id envelopes that Roll20 cannot + // propagate consistently. + // ------------------------------------------------------------------------- + + // [GAMEASSIST:INTERFACES:EVENTS] BEGIN + // Section Title: Roll20 handler registry (non-invasive) + // ------------------------------------------------------------------------- + // mechsuit_section: { codename: "GAMEASSIST", area: "INTERFACES:EVENTS", title: "Handlers", + // guarantees: ["Track listeners for safe enable/disable"], last_updated_version: "v0.1.4" } + // ------------------------------------------------------------------------- + // Narrative + // INTERFACES:EVENTS tracks handlers registered through GameAssist.onEvent and + // GameAssist.onCommand without overriding Roll20 globals. Registries live in + // GameAssist._listeners and GameAssist._commandHandlers; Roll20's native `on` + // is captured once (R20_ON) and reused to avoid polluting the global scope. + // ------------------------------------------------------------------------- + // --- Notes & Comments --- + // CHOICE: Use captured Roll20 `on` without overriding globals; registries remain internal. + // Changed (v0.1.4): Removed global on/off overrides to prevent cross-script collisions; rely on R20_ON and internal tracking. + // Prior notes: Maintenance (v0.1.1.2, no semantic change): MECHSUITS metadata refreshed for v1.5.1. + // [GAMEASSIST:INTERFACES:EVENTS] END + + // [GAMEASSIST:INTERFACES:COMMANDS] BEGIN + // Section Title: Admin/config commands (!ga-*) + // ------------------------------------------------------------------------- + // mechsuit_section: { codename: "GAMEASSIST", area: "INTERFACES:COMMANDS", title: "Commands", + // guarantees: ["GM-gated admin commands; unsafe config keys refused; versioned config-only export; plain-language health summary with opt-in troubleshooting and standalone-integration details"], + // depends_on: ["[GAMEASSIST:POLICY]","[GAMEASSIST:CORE:STATE]","[GAMEASSIST:CORE:OBJECT]"], + // last_updated_version: "v0.1.4.7", lifecycle: "active" } + // ------------------------------------------------------------------------- + // Narrative + // INTERFACES:COMMANDS contains GM/admin chat commands for listing modules, toggling + // config, exporting versioned configuration-only snapshots, and inspecting health. + // The default status view prioritizes a DM's next action; --details preserves + // session counters, queue state, timestamps, event-hook counts, dependency evidence, + // and optional standalone TokenMod/StatusInfo evidence. + // ------------------------------------------------------------------------- + function getModuleHealth() { + return Object.entries(MODULES) + .filter(([, mod]) => !mod.internal) + .map(([name, mod]) => { + const cfg = GameAssist.getState(name).config; + const dependencies = GameAssist._checkDependencies(name); + const configured = cfg.enabled !== false; + const running = !!(mod.initialized && mod.active); + const skipped = configured && !running && dependencies.status === 'missing'; + + return { name, mod, cfg, configured, running, skipped, dependencies }; + }); + } + + function formatDependencyStatus(depInfo) { + if (depInfo.status === 'missing') { + return `missing (${depInfo.missing.join(', ')})`; + } + if (depInfo.status === 'unverifiable') { + return `unverifiable (${depInfo.unverifiable.join(', ')})`; + } + return 'confirmed'; + } + + /** + * statusPanelText — Sanitize dynamic text at the Roll20 template boundary. + * Inputs: module names, dependency names, counters, or fixed status guidance. + * Outputs: chat-safe text with template-closing braces encoded. + * Invariants: dynamic values cannot create a new default-template field. + * Failure: nullish values become an empty string; raw values are never emitted. + * Design: keep escaping at the interface edge so health rules stay readable. + */ + function statusPanelText(value) { + return _sanitize(value ?? '') + .replace(/\{/g, '{') + .replace(/\}/g, '}'); + } + + function statusField(label, value) { + const lines = Array.isArray(value) ? value : [value]; + return `{{${statusPanelText(label)}=${lines.map(statusPanelText).join('
')}}}`; + } + + function readableList(items) { + const values = (items || []).filter(Boolean); + if (values.length < 2) return values[0] || ''; + if (values.length === 2) return `${values[0]} and ${values[1]}`; + return `${values.slice(0, -1).join(', ')}, and ${values[values.length - 1]}`; + } + + function groupDependencies(rows, property) { + const groups = {}; + rows.forEach(row => { + (row.dependencies[property] || []).forEach(dependency => { + if (!groups[dependency]) groups[dependency] = []; + groups[dependency].push(row.name); + }); + }); + return Object.entries(groups).map(([dependency, modules]) => + `${dependency} for ${readableList(modules)}` + ); + } + + function formatStatusTimestamp(raw) { + if (!raw) return 'No activity recorded yet.'; + const parsed = new Date(raw); + return Number.isFinite(parsed.getTime()) + ? `${parsed.toLocaleString()} (sandbox time)` + : 'Time unavailable.'; + } + + /** + * buildStatusSnapshot — Convert runtime diagnostics into one status model. + * Inputs: current module lifecycle state, dependency evidence, metrics, and queue state. + * Outputs: counts plus plain-language health and dependency guidance for both panels. + * Invariants: disabled modules do not create active dependency warnings; unverifiable is not missing. + * Failure: absent numeric error data falls back to zero; no state is mutated. + * Design: classify once so the simple and detailed views cannot disagree. + */ + function buildStatusSnapshot() { + const metrics = GameAssist._metrics; + const modules = getModuleHealth(); + const enabled = modules.filter(row => row.configured); + const running = enabled.filter(row => row.running); + const stopped = enabled.filter(row => !row.running); + const disabled = modules.filter(row => !row.configured); + const missing = enabled.filter(row => row.dependencies.status === 'missing'); + const unverifiable = enabled.filter(row => row.dependencies.status === 'unverifiable'); + const skipped = modules.filter(row => row.skipped); + const errors = Number(metrics.errors) || 0; + const missingGroups = groupDependencies(missing, 'missing'); + const unverifiableGroups = groupDependencies(unverifiable, 'unverifiable'); + + // CHOICE: Unverifiable remains ready-with-check — ALT: report failure; REJECTED: Roll20 metadata absence is not proof that the dependency is missing. + let overall; + if (errors || stopped.length || missing.length) { + overall = 'Attention needed - review the items below.'; + } else if (unverifiable.length) { + overall = 'Ready - enabled modules are running. A marker check is recommended.'; + } else { + overall = 'Ready - GameAssist is responding and every enabled module is running.'; + } + + const moduleLines = [ + stopped.length + ? `${running.length} of ${enabled.length} enabled modules are running; ${stopped.length} need attention.` + : `${running.length} enabled module${running.length === 1 ? '' : 's'} running.`, + `${disabled.length} module${disabled.length === 1 ? '' : 's'} turned off.` + ]; + + const dependencyLines = []; + if (missingGroups.length) { + dependencyLines.push(`Missing: ${missingGroups.join('; ')}. Install or enable the dependency, or turn off the affected module.`); + } + if (unverifiableGroups.length) { + dependencyLines.push(`Could not confirm: ${unverifiableGroups.join('; ')}. This is not automatically a failure; test one death or concentration marker.`); + } + if (!dependencyLines.length) { + dependencyLines.push('Required dependencies were confirmed for enabled modules.'); + } + + const avgDuration = metrics.taskDurations.length + ? `${(metrics.taskDurations.reduce((a, b) => a + b, 0) / metrics.taskDurations.length).toFixed(2)} ms` + : 'N/A - no queued task duration has been recorded.'; + + return { + metrics, + modules, + enabled, + running, + stopped, + disabled, + missing, + unverifiable, + skipped, + errors, + overall, + moduleLines, + dependencyLines, + integrationLines: getStandaloneIntegrationLines(), + avgDuration, + listenerCount: Object.values(GameAssist._listeners).flat().length + }; + } + + function sendStatusPanel(snapshot, detailed = false) { + const fields = [ + `&{template:default} {{name=GameAssist ${VERSION} System Check}}`, + statusField('Overall', snapshot.overall), + statusField('Modules', snapshot.moduleLines), + statusField('Errors This Sandbox Session', snapshot.errors + ? `${snapshot.errors} error${snapshot.errors === 1 ? '' : 's'} recorded. Open Troubleshooting Details.` + : 'None recorded.'), + statusField('Dependency Check', snapshot.dependencyLines) + ]; + + if (detailed) { + fields.push( + statusField('Module Counts', `${snapshot.modules.length} registered | ${snapshot.enabled.length} enabled | ${snapshot.running.length} running | ${snapshot.skipped.length} dependency-skipped`), + statusField('Session Activity', `${snapshot.metrics.commands} commands handled | ${snapshot.metrics.messages} chat messages observed | ${snapshot.errors} errors recorded`), + statusField('Queue', `${_queue.length} waiting. Normal Roll20 events run directly; the queue is used only when a feature requests it.`), + statusField('Average Queued Task Time', snapshot.avgDuration), + statusField('Last Recorded Activity', formatStatusTimestamp(snapshot.metrics.lastUpdate)), + statusField('GameAssist Event Hooks', `${snapshot.listenerCount} tracked internally. This is troubleshooting information, not a pass/fail test.`), + statusField('Standalone Integrations', snapshot.integrationLines) + ); + } + + const buttons = detailed + ? [ + GameAssist.createButton('Refresh Details', '!ga-status --details'), + GameAssist.createButton('Simple View', '!ga-status'), + GameAssist.createButton('Module List', '!ga-config modules'), + GameAssist.createButton('Metrics', '!ga-metrics') + ] + : [ + GameAssist.createButton('Troubleshooting Details', '!ga-status --details'), + GameAssist.createButton('Module List', '!ga-config modules'), + GameAssist.createButton('Open Settings', '!ga-config ui') + ]; + const actionTitle = detailed ? 'Troubleshooting Actions' : 'GameAssist Actions'; + const actionRow = `
${actionTitle}
${buttons.join(' ')}
`; + + sendChat('GameAssist', `/w gm ${fields.join(' ')}`); + // CHOICE: Use a normal whisper for navigation; the live sandbox dropped button-only rows from the default template. + sendChat('GameAssist', `/w gm ${actionRow}`); + } + + GameAssist.onCommand('!ga-config', msg => { + const parts = msg.content.trim().split(/\s+/); + const sub = parts[1]; + if (sub === 'list') { + const ts = localNow(); + const ver = `v${VERSION}`; + + const root = ensureStateRoot(); + const snapshot = { + format: POLICY.snapshots.configFormat, + schemaVersion: POLICY.snapshots.configSchemaVersion, + scope: 'configuration-only', + generatedAt: isoNow(), + version: VERSION, + flags: GameAssist.flags, + globalConfig: root.config || {}, + modules: {} + }; + + Object.entries(MODULES) + .filter(([, mod]) => !mod.internal) + .forEach(([name]) => { + snapshot.modules[name] = GameAssist.getState(name).config || {}; + }); + + const cfg = JSON.stringify(snapshot, 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})\nScope: configuration-only; runtime caches and metrics are excluded.\n\n${cfg}
`); + sendChat('GameAssist', `/w gm Configuration-only snapshot 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); + const BAD_KEYS = new Set(POLICY.config.unsafeKeys); + if (!MODULES[mod] || MODULES[mod].internal) { + GameAssist.log('Config', `Unknown module: ${mod}`, 'WARN'); + return; + } + const k = key.trim(); + if (BAD_KEYS.has(k)) { + GameAssist.log('Config', `Refusing unsafe config key: ${k}`, 'WARN'); + return; + } + if (k === 'enabled') { + if (typeof parsed !== 'boolean') { + GameAssist.log('Config', 'enabled must be true/false', 'WARN'); + return; + } + parsed ? GameAssist.enableModule(mod) : GameAssist.disableModule(mod); + return; + } + GameAssist.getState(mod).config[k] = parsed; + GameAssist.log('Config', `Set ${mod}.${k} = ${JSON.stringify(parsed)}`); + } + else if (sub === 'get') { + if (parts.length < 3) { + GameAssist.log('Config', 'Usage: !ga-config get [key]', 'WARN'); + return; + } + const mod = parts[2]; + if (!MODULES[mod] || MODULES[mod].internal) { + GameAssist.log('Config', `Unknown module: ${mod}`, 'WARN'); + return; + } + const modState = GameAssist.getState(mod); + if (parts.length >= 4) { + // Get specific key + const key = parts[3]; + const val = modState.config[key]; + GameAssist.log('Config', `${mod}.${key} = ${JSON.stringify(val)}`); + } else { + // Get all config for module + const cfg = JSON.stringify(modState.config, null, 2); + GameAssist.log('Config', `${mod} config:\n${cfg}`); + } + } + else if (sub === 'ui') { + if (typeof GameAssist.renderConfigUI !== 'function') { + GameAssist.log('Config', 'Config UI module is disabled or unavailable.', 'WARN'); + return; + } + const raw = msg.content.trim().split(/\s+/).slice(2).join(' '); + GameAssist.renderConfigUI(msg.playerid, { rawArgs: raw }); + } + else if (sub === 'modules') { + const moduleList = Object.entries(MODULES) + .filter(([, mod]) => !mod.internal) + .map(([name, mod]) => { + const cfg = GameAssist.getState(name).config; + const depInfo = GameAssist._checkDependencies(name); + const configured = cfg.enabled ? '✅' : '❌'; + const running = mod.initialized && mod.active ? '🟢' : '⏸️'; + return `${name}: config ${configured} | runtime ${running} | deps ${formatDependencyStatus(depInfo)}`; + }).join('\n'); + GameAssist.log('Config', `Modules:\n${moduleList}`); + } + else if (sub === 'cleanup') { + const root = ensureStateRoot(); + const whitelist = new Set(['config', 'flags', 'metrics']); + const known = new Set(Object.keys(MODULES)); + + const removed = []; + Object.keys(root).forEach(k => { + if (whitelist.has(k)) return; + if (!known.has(k)) { delete root[k]; removed.push(k); } + }); + + GameAssist.log('Config', removed.length + ? `Removed orphaned module state branches: ${removed.join(', ')}` + : 'No orphaned module state branches found.'); + } + else { + GameAssist.log('Config', 'Usage: !ga-config list|set|get|modules|cleanup [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 detailed = /(?:^|\s)(?:--details|details)(?:\s|$)/i.test(msg.content || ''); + sendStatusPanel(buildStatusSnapshot(), detailed); + }, 'Core', { gmOnly: true }); + + GameAssist.onCommand('!ga-metrics', msg => { + const parts = msg.content.trim().split(/\s+/); + const sub = (parts[1] || 'summary').toLowerCase(); + + if (sub === 'reset') { + resetMetricsStore(); + GameAssist._metrics.commands = 0; + GameAssist._metrics.messages = 0; + GameAssist._metrics.errors = 0; + GameAssist._metrics.stateAudits = 0; + GameAssist._metrics.taskDurations = []; + GameAssist._metrics.lastUpdate = isoNow(); + recordMetric('system', { mod: 'Core', note: 'Metrics reset' }); + GameAssist.log('Metrics', 'Metrics reset. Session counters cleared.'); + return; + } + + const store = getMetricsStore(); + const totals = store.totals || {}; + const durations = store.durations || []; + const labels = { + command: 'Commands', + event: 'Events', + task: 'Queue Tasks', + [POLICY.metrics.queueDurationName]: 'Queue Duration Samples', + toggle: 'Module Toggles', + error: 'Errors', + audit: 'State Audits', + state_repair: 'State Repairs', + system: 'System Events' + }; + + const summary = [ + '**GameAssist Metrics**', + `Session Start: ${store.sessionStart || 'unknown'}`, + `Last Update: ${store.lastUpdate || 'unknown'}` + ]; + + Object.entries(labels).forEach(([key, label]) => { + summary.push(`${label}: ${totals[key] || 0}`); + }); + + const extraKeys = Object.keys(totals).filter(key => !labels[key]).sort(); + extraKeys.forEach(key => { + summary.push(`${key}: ${totals[key]}`); + }); + + if (durations.length) { + const totalDur = durations.reduce((acc, val) => acc + val, 0); + const avg = (totalDur / durations.length).toFixed(2); + const min = Math.min(...durations); + const max = Math.max(...durations); + summary.push(`Queue Durations (last ${durations.length}): avg ${avg}ms | min ${min}ms | max ${max}ms`); + } else { + summary.push('Queue Durations: no tasks recorded yet.'); + } + + const history = (store.history || []).slice(-5).reverse(); + if (history.length) { + summary.push('Recent activity:'); + history.forEach(entry => { + const segments = []; + if (entry.ts) segments.push(entry.ts); + segments.push(entry.type); + if (entry.mod) segments.push(`[${entry.mod}]`); + if (entry.note) segments.push(`— ${entry.note}`); + summary.push(`• ${segments.join(' ')}`.trim()); + }); + } else { + summary.push('Recent activity: none logged.'); + } + + GameAssist.log('Metrics', summary.join('\n')); + }, 'Core', { gmOnly: true }); + // --- Notes & Comments --- + // CHOICE: Keep command syntax identical to legacy for drop‑in replacement. + // CHOICE: Keep the default status action-oriented and place volatile counters behind --details — ALT: one exhaustive panel; REJECTED: the health signal became difficult for non-programmer DMs to find. + // CHOICE: Send status navigation as a separate normal whisper — ALT: button-only default-template row; REJECTED: Roll20 live smoke testing omitted the row and every contained button. + // Changed (v0.1.4.7): Troubleshooting details now report TokenMod and optional StatusInfo contract/version evidence without making StatusInfo a required dependency. + // Changed (v0.1.4.6): Rebuilt !ga-status as a plain-language system check with explicit module/error/dependency guidance and an opt-in troubleshooting view; unavailable queue duration now renders without an ms suffix. + // Changed (v0.1.4.6): Moved status navigation into a separate action strip so Troubleshooting Details, Module List, Open Settings, Simple View, and Metrics render in the Roll20 API sandbox. + // Changed (v0.1.4.2): Added versioned configuration-only snapshots plus module/dependency health reporting. + // Maintenance (v0.1.4.3, no semantic change): Replaced an editorial guarantee label with the specific health states reported. + // CHOICE: Report dependency certainty instead of treating unavailable Roll20 script metadata as absence. + // Decision log: + // CHOICE: export flags/global/module config in one snapshot — ALT: module-only export; REJECTED: incomplete recovery data. + // Prior notes: + // Changed (v0.1.4.1): Ported the v0.1.5 complete config snapshot while retaining v0.1.4 key safety and enabled routing. + // Changed (v0.1.4): Refused unsafe keys, routed enabled toggles through lifecycle methods, and expanded get/modules output. + // Maintenance (v0.1.3, no semantic change): Added narrative describing GM/admin scope. + // Maintenance (v0.1.1.2, no semantic change): Updated MECHSUITS tracking fields only. + // [GAMEASSIST:INTERFACES:COMMANDS] END + // ============================================================================= + + // --- Notes & Comments --- + // Maintenance (v0.1.4.1, no semantic change): Documented the Roll20 chat-envelope adaptation and preserved command order. + // Maintenance (v0.1.3, no semantic change): Introduced INTERFACES wrapper to nest + // events/commands under a parent section for MECHSUITS compliance while + // preserving handler behavior and ordering. + // Prior notes: N/A (wrapper added for compliance). + // [GAMEASSIST:INTERFACES] END + // ============================================================================= + + // ————— MODULES ————— + // ============================================================================= + // [GAMEASSIST:MODULES] BEGIN + // Section Title: Modules wrapper (bundled features) + // ------------------------------------------------------------------------- + // mechsuit_section: { codename: "GAMEASSIST", area: "MODULES", title: "Modules wrapper", + // guarantees: ["Bundled feature modules remain grouped and independently lifecycle-managed"], + // depends_on: ["[GAMEASSIST:CORE]","[GAMEASSIST:INTERFACES]"], last_updated_version: "v0.1.4.3" } + // ------------------------------------------------------------------------- + // Narrative + // MODULES encloses all shipped feature modules. Each child retains its own + // guarantees and version signals; this wrapper documents grouping and + // sequencing while child sections own their observable behavior. + // ------------------------------------------------------------------------- + + // ————— CONFIG UI MODULE v0.1.0 ————— + // ============================================================================= + // [GAMEASSIST:MODULES:CONFIGUI] BEGIN + // Section Title: Config UI module + // ------------------------------------------------------------------------- + // mechsuit_section: { codename: "GAMEASSIST", area: "MODULES:CONFIGUI", title: "Config UI", + // guarantees: ["GM chat menu for module toggles and quick config"], + // depends_on: ["[GAMEASSIST:POLICY]","[GAMEASSIST:INTERFACES:COMMANDS]"], + // last_updated_version: "v0.1.1.2", + // independent_versions: { module_version: "0.1.0" } } + // ------------------------------------------------------------------------- + // Narrative + // MODULES:CONFIGUI provides GM-facing chat controls to page through modules, + // toggle enablement, and write configs without changing legacy defaults. It reuses + // shared button helpers for consistency across modules. + // ------------------------------------------------------------------------- + GameAssist.register('ConfigUI', function() { + const modState = GameAssist.getState('ConfigUI'); + Object.assign(modState.config, { + enabled: true, + pageSize: POLICY.configUi.pageSize, + showSummaries: true, + ...modState.config + }); + + function getPageSize() { + const raw = modState.config.pageSize; + const parsed = Number(raw); + return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : POLICY.configUi.pageSize; + } + + function formatValue(value) { + if (value === null || value === undefined) return '—'; + if (typeof value === 'object') { + try { return JSON.stringify(value); } + catch { return '[object]'; } + } + return String(value); + } + + function formatConfigSummary(cfg) { + const entries = Object.entries(cfg || {}) + .filter(([key]) => key !== 'enabled') + .map(([key, val]) => `${_sanitize(key)}: ${_sanitize(formatValue(val))}`); + return entries.length ? entries.join(' • ') : ''; + } + + function buildConfigButtons(name, cfg) { + return Object.entries(cfg || {}) + .filter(([key, val]) => key !== 'enabled' && typeof val === 'boolean') + .map(([key, val]) => { + const label = `${key}: ${val ? 'ON' : 'OFF'}`; + const next = (!val).toString(); + return GameAssist.createButton(label, `!ga-config set ${name} ${key}=${next}`); + }) + .join(' '); + } + + function parsePage(rawArgs) { + if (!rawArgs) return 0; + const parsed = _parseArgs(rawArgs); + const pageArg = parsed.args.page; + if (typeof pageArg === 'number') return Math.max(0, pageArg); + if (typeof pageArg === 'string') { + const val = parseInt(pageArg, 10); + if (!isNaN(val)) return Math.max(0, val); + } + if (parsed.cmd && /^\d+$/.test(parsed.cmd)) { + return Math.max(0, parseInt(parsed.cmd, 10)); + } + const direct = rawArgs.split(/\s+/).find(part => /^\d+$/.test(part)); + if (direct) return Math.max(0, parseInt(direct, 10)); + return 0; + } + + function getModuleEntries() { + return Object.entries(MODULES) + .filter(([, mod]) => !mod.internal) + .sort((a, b) => a[0].localeCompare(b[0], 'en', { sensitivity: 'base' })); + } + + function buildNav(page, total) { + if (total <= 1) return ''; + const buttons = []; + if (page > 0) { + buttons.push(GameAssist.createButton('⬅ Prev', `!ga-config ui --page ${page - 1}`)); + } + buttons.push(GameAssist.createButton('🔄 Refresh', `!ga-config ui --page ${page}`)); + if (page < total - 1) { + buttons.push(GameAssist.createButton('Next ➡', `!ga-config ui --page ${page + 1}`)); + } + return buttons.join(' '); + } + + function renderModuleBlock(name, mod) { + const branch = GameAssist.getState(name); + const cfg = branch.config || {}; + const enabled = cfg.enabled !== false; + const active = !!(mod.initialized && mod.active); + const statusIcon = enabled ? (active ? '🟢' : '⏸️') : '⛔'; + const statusText = enabled ? (active ? 'Enabled' : 'Disabled (inactive)') : 'Disabled'; + const toggleCmd = enabled ? `!ga-disable ${name}` : `!ga-enable ${name}`; + const toggleBtn = GameAssist.createButton(`${enabled ? 'Disable' : 'Enable'} ${name}`, toggleCmd); + const configButtons = buildConfigButtons(name, cfg); + const summary = modState.config.showSummaries ? formatConfigSummary(cfg) : ''; + + const rows = [ + `${statusIcon} ${_sanitize(name)} — ${_sanitize(statusText)}`, + toggleBtn + ]; + if (configButtons) { + rows.push(`Config: ${configButtons}`); + } + if (summary) { + rows.push(summary); + } + return `
${rows.join('
')}
`; + } + + function renderInternal(playerId, { page: explicitPage, rawArgs = '' } = {}) { + const modules = getModuleEntries(); + if (!modules.length) { + sendChat('GameAssist', '/w gm No modules registered.'); + return; + } + + const pageSize = getPageSize(); + const totalPages = Math.max(1, Math.ceil(modules.length / pageSize)); + let page = typeof explicitPage === 'number' ? explicitPage : parsePage(rawArgs); + if (!Number.isFinite(page) || page < 0) page = 0; + if (page > totalPages - 1) page = totalPages - 1; + + const slice = modules.slice(page * pageSize, page * pageSize + pageSize); + const blocks = slice.map(([name, mod]) => renderModuleBlock(name, mod)).join(''); + const nav = buildNav(page, totalPages); + + const header = `
🛠️ GameAssist Config UI Page ${page + 1}/${totalPages}
`; + const footer = '
Use !ga-config set <Module> key=value for advanced settings.
'; + const navLine = nav ? `
${nav}
` : ''; + + const message = `${header}${blocks}${navLine}${footer}`; + sendChat('GameAssist', `/w gm ${message}`); + } + + GameAssist.renderConfigUI = function(playerId, options = {}) { + if (!MODULES.ConfigUI?.initialized || !MODULES.ConfigUI?.active) { + GameAssist.log('ConfigUI', 'Config UI module is disabled.', 'WARN'); + return; + } + renderInternal(playerId, options); + }; + + GameAssist.onCommand('!ga-config-ui', msg => { + const rawArgs = msg.content.replace(/^!ga-config-ui\s*/i, ''); + renderInternal(msg.playerid, { rawArgs }); + }, 'ConfigUI', { gmOnly: true }); + + GameAssist.log('ConfigUI', 'Ready: !ga-config ui (or !ga-config-ui) to open chat controls.', 'INFO', { startup: true }); + }, { + enabled: true, + prefixes: ['!ga-config-ui', '!ga-config ui'] + }); + // --- Notes & Comments --- + // CHOICE: Button helper reused; nav uses the same command path for refresh/paging. + // Maintenance (v0.1.4.1, no semantic change): Routed the unchanged default page size through POLICY. + // Prior notes: + // Maintenance (v0.1.3, no semantic change): Added module narrative; preserved UI behavior and pagination defaults. + // Maintenance (v0.1.1.2, no semantic change): Updated section metadata for MECHSUITS v1.5.1. + // [GAMEASSIST:MODULES:CONFIGUI] END + // ============================================================================= + + // ————— CRITFUMBLE MODULE v0.2.4.9 ————— + // ============================================================================= + // [GAMEASSIST:MODULES:CRITFUMBLE] BEGIN + // Section Title: CritFumble module + // ------------------------------------------------------------------------- + // mechsuit_section: { codename: "GAMEASSIST", area: "MODULES:CRITFUMBLE", title: "CritFumble", + // guarantees: ["Readable help output; natural‑1 detection bugfix retained"], + // depends_on: ["[GAMEASSIST:POLICY]","[GAMEASSIST:APP:UTILS]"], + // last_updated_version: "v0.1.4.4", + // independent_versions: { module_version: "0.2.4.9" } } + // ------------------------------------------------------------------------- + // Narrative + // MODULES:CRITFUMBLE watches rolltemplate outputs for natural-1 results and serves + // confirm/fumble menus with legacy tables. It keeps the prior bugfix for + // inlineroll detection intact while relying on core helpers for logging and state. + // ------------------------------------------------------------------------- + GameAssist.register('CritFumble', function() { + // ─── Module Setup ────────────────────────────────────────────────────────────── + const modState = GameAssist.getState('CritFumble'); + Object.assign(modState.config, { + enabled: true, + debug: false, + useEmojis: true, + rollDelayMs: POLICY.critFumble.rollDelayMs, + // Preserve any values previously saved in state + ...modState.config + }); + + const ACTIVE_PLAYER_LIMIT = POLICY.runtime.activePlayerLimit; + + // ─── Constants ───────────────────────────────────────────────────────────────── + /** Roll20 rolltemplates monitored for natural-1s */ + const VALID_TEMPLATES = ['atk','atkdmg','npcatk','npcfullatk','npcaction','spell','simple','dmg','default']; + const FUMBLE_TABLES = { + melee: 'CF-Melee', + ranged: 'CF-Ranged', + spell: 'CF-Spell', + natural: 'CF-Natural', + thrown: 'CF-Thrown' + }; + const FUMBLE_TYPE_ORDER = ['melee', 'ranged', 'thrown', 'spell', 'natural']; + // Lookup for confirm tables + const CONFIRM_TABLES = { + 'confirm-crit-martial': 'Confirm-Crit-Martial', + 'confirm-crit-magic': 'Confirm-Crit-Magic' + }; + const REQUIRED_TABLES = [ + ...FUMBLE_TYPE_ORDER.map(type => FUMBLE_TABLES[type]), + 'Confirm-Crit-Martial', + 'Confirm-Crit-Magic' + ]; + + function ensureCritFumbleRuntime() { + const runtime = ensureRuntimeObject(modState); + runtime.activePlayers = ensureModRuntimeKey(modState, 'activePlayers', 'object'); + return runtime; + } + + function getActivePlayerTs(entry) { + const ts = Number(entry && entry.ts); + return (Number.isFinite(ts) && ts > 0) ? ts : 0; + } + + function pruneActivePlayers(rt) { + const entries = Object.entries(rt.activePlayers); + if (entries.length <= ACTIVE_PLAYER_LIMIT) return; + + entries + .sort(([, a], [, b]) => getActivePlayerTs(a) - getActivePlayerTs(b)) + .slice(0, entries.length - ACTIVE_PLAYER_LIMIT) + .forEach(([id]) => delete rt.activePlayers[id]); + } + + function noteActivePlayer(rt, playerId) { + const p = getObj('player', playerId); + if (!p) return; + + const seenAt = sanitizeTimestamp(now()); + const entry = rt.activePlayers[playerId]; + + if (typeof entry === 'string') { + rt.activePlayers[playerId] = { name: entry, ts: seenAt }; + } else if (!entry || typeof entry !== 'object') { + rt.activePlayers[playerId] = { name: p.get('displayname'), ts: seenAt }; + } else { + entry.name = entry.name || p.get('displayname'); + entry.ts = seenAt; + } + + pruneActivePlayers(rt); + } + + // ─── 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 whisperPrefix(who) { + const cleaned = sanitizeWho(String(who || '')); + if (cleaned.toLowerCase() === 'gm') return '/w gm '; + return `/w "${cleaned}" `; + } + + function sendTemplateMessage(who,title,fields) { + const content = fields.map(f=>`{{${f.label}=${f.value}}}`).join(' '); + sendChat('CritFumble', `${whisperPrefix(who)}&{template:default} {{name=${title}}} ${content}`); + } + + function getFumbleTableName(type) { + return FUMBLE_TABLES[type]||null; + } + + function sendConfirmMenu(who) { + const confirmButtons = [ + GameAssist.createButton('Confirm-Crit-Martial', '!confirm-crit-martial'), + GameAssist.createButton('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 = [ + GameAssist.createButton('⚔ Melee', '!critfumble-melee'), + GameAssist.createButton('🏹 Ranged', '!critfumble-ranged'), + GameAssist.createButton('🎯 Thrown', '!critfumble-thrown'), + GameAssist.createButton('🔥 Spell', '!critfumble-spell'), + GameAssist.createButton('👊 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(()=>{ + GameAssist.rollTable(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: FUMBLE_TYPE_ORDER.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 rt = ensureCritFumbleRuntime(); + const entries = Object.entries(rt.activePlayers || {}); + if (!entries.length) { + sendTemplateMessage('gm', "⚠️ No Players Detected", [ + { label:"Note", value:"No players have been active yet this session." } + ]); + return; + } + const buttons = entries.map(([pid, entry]) => { + const label = typeof entry === 'string' ? entry : entry?.name || pid; + return GameAssist.createButton(label, `!critfumblemenu --pid ${pid}`); + }).join(' '); + sendTemplateMessage('gm',"Manually Trigger Fumble Menu",[ + { label:"Select Player", value:buttons } + ]); + } + + function handleManualTrigger(playerId) { + const p = getObj('player', playerId); + if (!p) return; + sendFumbleMenu(p.get('displayname').replace(/ \(GM\)$/, '')); + debugLog(`Manually triggered fumble menu for: ${playerId}`); + } + + function showHelpMessage(who) { + const menuButton = GameAssist.createButton('Open Natural 1 Menu', '!critfumble menu'); + sendTemplateMessage(who, "CritFumble Quick Reference", [ + { label: "What It Does", value: "Helps resolve natural 1 attack rolls with a guided Natural 1 menu or direct table rolls." }, + { label: "Best First Step", value: menuButton }, + { label: "Common Commands", value: "!critfumble menu = guided Natural 1 menu
!critfail = open the player picker directly
!critfumble-melee = roll melee
!critfumble-ranged = roll ranged
!critfumble-thrown = roll thrown
!critfumble-spell = roll spell
!critfumble-natural = roll natural" }, + { label: "Attack Types", value: "melee, ranged, thrown, spell, natural" }, + { label: "Before First Use", value: "Create Roll20 rollable tables with these exact names:
" + REQUIRED_TABLES.join('
') } + ]); + } + + function showCritFumbleMenu(who) { + const pickerButton = GameAssist.createButton('Open Player Picker', '!critfail'); + const directButtons = FUMBLE_TYPE_ORDER + .map(type => GameAssist.createButton(type.charAt(0).toUpperCase() + type.slice(1), `!critfumble-${type}`)) + .join(' '); + const confirmButtons = [ + GameAssist.createButton('Martial Confirm', '!confirm-crit-martial'), + GameAssist.createButton('Magic Confirm', '!confirm-crit-magic') + ].join(' '); + + sendTemplateMessage(who, "CritFumble Help: Natural 1 Attacks", [ + { label: "When To Use", value: "Use this when a player rolls a natural 1 on an attack." }, + { label: "Normal Steps", value: "1. Click Open Player Picker.
2. Choose the player who rolled the natural 1.
3. The player chooses the attack type.
4. GameAssist rolls the fumble result." }, + { label: "Start", value: pickerButton }, + { label: "Attack Types", value: "melee = close weapon
ranged = bow, crossbow, or firearm
thrown = thrown weapon
spell = spell attack
natural = bite, claw, or unarmed" }, + { label: "Roll Directly", value: directButtons }, + { label: "Confirm Rolls", value: confirmButtons } + ]); + } + + function handleRoll(msg) { + if (!msg) return; + + // Ignore GameAssist's own log messages and CritFumble's own messages to prevent feedback loops + if (msg.who === 'GameAssist' || msg.who === 'CritFumble') return; + + const rt = ensureCritFumbleRuntime(); + + // register active players + if (msg.playerid) noteActivePlayer(rt, msg.playerid); + + // API‐style commands + if (msg.type==='api') { + const rawCmd = (msg.content||'').trim(); + const cmd = rawCmd.toLowerCase(); + + if (cmd==='!critfail') { + debugLog(`Manual trigger: ${rawCmd}`); + return showManualTriggerMenu(); + } + if (/^!critfumble\s+menu$/.test(cmd)) { + return showCritFumbleMenu(msg.who); + } + if (/^!critfumble(?:\s+help)?$/.test(cmd)) { + return showHelpMessage(msg.who); + } + if (cmd.startsWith('!critfumblemenu')) { + const { args } = _parseArgs(rawCmd.replace('!critfumblemenu', '').trim()); + if (args.pid) { + return handleManualTrigger(String(args.pid)); + } + return; + } + if (cmd.startsWith('!critfumble-')) { + const who = sanitizeWho(msg.who); + const fumbleType = cmd.slice('!critfumble-'.length).split(/\s+/)[0]; + 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) { + if (modState.config.debug) debugLog('No rolltemplate in message'); + return; + } + if (!VALID_TEMPLATES.includes(msg.rolltemplate)) { + if (modState.config.debug) debugLog(`Rolltemplate "${msg.rolltemplate}" not in VALID_TEMPLATES: ${VALID_TEMPLATES.join(', ')}`); + return; + } + const rolls = msg.inlinerolls||[]; + if (!hasNaturalOne(rolls)) { + if (modState.config.debug) debugLog(`No natural 1 detected in ${rolls.length} inline roll(s)`); + 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 + !critfumble help/menu','INFO',{startup:true}); + }, { + enabled: true, + events: ['chat:message'], + prefixes: ['!critfail','!critfumble'] + }); + // --- Notes & Comments --- + // Bugfix retained: robust natural‑1 detection across templates/inlineroll variants. + // Changed (v0.1.4.4): CritFumble help is a quick reference; !critfumble menu opens the guided natural-1 dialogue; !critfail remains the direct player picker; command parsing tolerates extra whitespace and mixed-case direct rolls. + // Maintenance (v0.1.4.3, no semantic change): Reworded an internal comment for collaborator clarity. + // Maintenance (v0.1.4.1, no semantic change): Routed unchanged defaults through POLICY and timestamps through now(). + // Prior notes: + // Changed (v0.1.4): Default debug off, corrected GM whisper handling, and made manual buttons target player ids. + // Maintenance (v0.1.3, no semantic change): Added runtime self-healing for activePlayers and deterministic pruning. + // Maintenance (v0.1.3, no semantic change): Added module narrative; retained natural-1 detection behavior. + // Maintenance (v0.1.1.2, no semantic change): MECHSUITS metadata updated only. + // [GAMEASSIST:MODULES:CRITFUMBLE] END + // ============================================================================= + + // ————— NPC MANAGER MODULE v1.1.1 ————— + // ============================================================================= + // [GAMEASSIST:MODULES:NPCMANAGER] BEGIN + // Section Title: NPCManager module + // ------------------------------------------------------------------------- + // mechsuit_section: { codename: "GAMEASSIST", area: "MODULES:NPCMANAGER", title: "NPCManager", + // guarantees: ["Auto toggle resolved configured dead marker based on known HP transitions; maintain hierarchical death-history handouts and curated arc rosters", "NPCHPRoller auto-roll initialization is not recorded as death/revival history", "Death-marker requests use verified standalone TokenMod interoperability"], + // depends_on: ["[GAMEASSIST:POLICY]","[GAMEASSIST:APP:UTILS]","[GAMEASSIST:CORE:OBJECT]"], + // last_updated_version: "v0.1.4.7", + // independent_versions: { module_version: "1.1.1" } } + // ------------------------------------------------------------------------- + // Narrative + // MODULES:NPCMANAGER monitors token HP changes to set or clear the configured + // death marker through verified standalone TokenMod requests. New-token HP initialization + // is ignored while NPCHPRoller auto-roll-on-add establishes the token's starting HP. + // It records deaths into active Campaign, Chapter, + // Section, and Session buckets; writes bucket/audit handouts; rolls date-based + // sessions forward before new activity; and maintains deduplicated, editable + // story arc rosters for selected linked PC/NPC tokens or Session imports. + // ------------------------------------------------------------------------- + GameAssist.register('NPCManager', function() { + const modState = GameAssist.getState('NPCManager'); + + Object.assign(modState.config, { + enabled: true, + autoTrackDeath: true, + deadMarker: 'dead', + autoHide: false, + hideLayer: 'gmlayer', + ...modState.config + }); + + const DEATH_BUCKET_SCOPES = ['campaign', 'chapter', 'section', 'session']; + const DEATH_BUCKET_TITLES = { + campaign: 'Campaign', + chapter: 'Chapter', + section: 'Section', + session: 'Session' + }; + const DEFAULT_BUCKET_NAMES = { + campaign: 'Campaign', + chapter: 'Chapter', + section: 'Section' + }; + const NESTED_BUCKET_SCOPES = { + campaign: ['campaign', 'chapter', 'section', 'session'], + chapter: ['chapter', 'section', 'session'], + section: ['section', 'session'], + session: ['session'] + }; + const DEATH_LOG_LIMIT = POLICY.runtime.deathLogLimit; + const AUDIT_DETAIL_LIMIT = POLICY.runtime.npcAuditDetailLimit; + const DEATH_REPORT_SUMMARY_LIMIT = POLICY.runtime.deathReportSummaryLimit; + const DEATH_REPORT_DETAIL_LIMIT = POLICY.runtime.deathReportDetailLimit; + const AUDIT_HANDOUT_NAME = 'GameAssist NPC Death Audit'; + const NPCMANAGER_MODULE_VERSION = '1.1.1'; + const initializingNpcHp = new Set(); + + function currentSessionDateKey(raw = now()) { + return new Date(raw).toISOString().slice(0, 10); + } + + function defaultBucketName(scope) { + return scope === 'session' ? currentSessionDateKey() : DEFAULT_BUCKET_NAMES[scope]; + } + + function ensureDeathBucketConfig() { + const existing = modState.config.deathBuckets; + const current = existing && typeof existing === 'object' && !Array.isArray(existing) + ? existing + : {}; + + modState.config.deathBuckets = DEATH_BUCKET_SCOPES.reduce((next, scope) => { + const raw = current[scope]; + const fallback = defaultBucketName(scope); + next[scope] = String(raw || fallback).trim() || fallback; + return next; + }, {}); + + return modState.config.deathBuckets; + } + + ensureDeathBucketConfig(); + + function ensureNPCManagerRuntime() { + const state = ensureRuntimeObject(modState); + const deathLog = ensureModRuntimeKey(modState, 'deathLog', 'array'); + const buckets = ensureModRuntimeKey(modState, 'deathBuckets', 'object'); + const arcs = ensureModRuntimeKey(modState, 'deathArcs', 'array'); + + DEATH_BUCKET_SCOPES.forEach(scope => { + if (!Array.isArray(buckets[scope])) buckets[scope] = []; + }); + + return { state, deathLog, buckets, arcs }; + } + + function getDeathMarkerResolution() { + return resolveMarkerId(modState.config.deadMarker || 'dead'); + } + + function deathMarkerWarning(resolution) { + const marker = _sanitize(resolution.requested || modState.config.deadMarker || 'dead'); + const detail = resolution.registryError + ? ` Roll20 marker registry problem: ${_sanitize(resolution.registryError)}.` + : ''; + return `Configured NPC death marker "${marker}" could not be recognized.${detail}` + + ' Use a built-in marker id such as dead or configure the exact custom marker tag.'; + } + + function panelText(value) { + return _sanitize(value ?? '') + .replace(/\{/g, '{') + .replace(/\}/g, '}'); + } + + function panelValue(value) { + if (Array.isArray(value)) { + return value.map(panelText).join('
'); + } + return panelText(value); + } + + function sendNPCPanel(title, fields) { + const content = fields + .map(({ label, value }) => `{{${panelValue(label)}=${panelValue(value)}}}`) + .join(' '); + sendChat('GameAssist', `/w gm &{template:default} {{name=${panelValue(title)}}} ${content}`); + } + + function sendAuditReport(fields) { + sendNPCPanel('NPC Death Audit', fields); + } + + function sendDeathReport(fields) { + sendNPCPanel('NPC Death Report', fields); + } + + function summarizeAuditNames(names, limit = 5) { + const listed = names.slice(0, limit).join(', '); + return names.length > limit ? `${listed}, +${names.length - limit} more` : listed; + } + + function formatAuditEntries(entries, limit = AUDIT_DETAIL_LIMIT) { + const lines = []; + entries.slice(0, limit).forEach((entry, index) => { + if (index > 0) lines.push(''); + lines.push( + entry.name, + `HP: ${entry.hp}`, + `Markers: ${entry.markers}`, + `Token ID: ${entry.id}` + ); + }); + if (entries.length > limit) { + lines.push('', `Showing ${limit} of ${entries.length}. Run again after fixing these to see the next set.`); + } + return lines; + } + + function normalizeScope(scope, fallback = 'session') { + const value = String(scope || fallback).toLowerCase(); + return DEATH_BUCKET_SCOPES.includes(value) ? value : fallback; + } + + function htmlText(value) { + return _sanitize(value ?? ''); + } + + function handoutSafeName(value, fallback = 'Unnamed') { + return String(value || fallback) + .replace(/[<>]/g, '') + .replace(/\s+/g, ' ') + .trim() + .slice(0, 80) || fallback; + } + + function queryDefault(value, fallback = 'Bucket') { + return String(value || fallback) + .replace(/[|"{}]/g, ' ') + .replace(/\s+/g, ' ') + .trim() || fallback; + } + + function bucketHandoutName(scope, name) { + return `GameAssist Deaths - ${DEATH_BUCKET_TITLES[scope]} - ${handoutSafeName(name)}`; + } + + function arcHandoutName(name) { + return `GameAssist Arc - ${handoutSafeName(name)}`; + } + + function getOrCreateHandout(name) { + let handout = findObjs({ type: 'handout', name })[0]; + if (!handout) handout = createObj('handout', { name, archived: false }); + return handout; + } + + function setHandoutNotes(name, notes) { + const handout = getOrCreateHandout(name); + handout.set('notes', notes); + return handout; + } + + function findNamedCollectionItem(list, name) { + const target = String(name || '').trim(); + return (Array.isArray(list) ? list : []).find(item => item && item.name === target) || null; + } + + function getActiveBucket(scope) { + const safeScope = normalizeScope(scope); + const runtime = ensureNPCManagerRuntime(); + const names = ensureDeathBucketConfig(); + const bucketName = names[safeScope] || defaultBucketName(safeScope); + let bucket = findNamedCollectionItem(runtime.buckets[safeScope], bucketName); + + if (!bucket) { + bucket = { + scope: safeScope, + name: bucketName, + createdAt: isoNow(), + entries: [] + }; + runtime.buckets[safeScope].push(bucket); + } + + if (!Array.isArray(bucket.entries)) bucket.entries = []; + return bucket; + } + + function getActiveBuckets() { + return DEATH_BUCKET_SCOPES.reduce((memo, scope) => { + memo[scope] = getActiveBucket(scope); + return memo; + }, {}); + } + + function nextArcEntryId() { + const runtime = ensureNPCManagerRuntime(); + const current = Number(runtime.state.arcEntryCounter); + runtime.state.arcEntryCounter = Number.isFinite(current) && current >= 0 ? current + 1 : 1; + return `arc-${now()}-${runtime.state.arcEntryCounter}`; + } + + function ensureArcEntryShape(entry) { + if (!entry || typeof entry !== 'object') return null; + if (!entry.arcEntryId) entry.arcEntryId = nextArcEntryId(); + return entry; + } + + function arcEntityKey(entry) { + if (entry?.tokenId) return `token:${entry.tokenId}`; + if (entry?.characterId) return `character:${entry.characterId}`; + const name = String(entry?.name || '').trim().toLowerCase(); + return name ? `name:${name}` : null; + } + + function findArcEntityEntry(arc, candidate) { + const key = arcEntityKey(candidate); + if (!key) return null; + return arc.entries.find(entry => arcEntityKey(entry) === key) || null; + } + + function beginArcBatch(arc, label) { + return { + id: `batch-${now()}-${arc.entries.length}`, + label, + createdAt: isoNow(), + addedArcEntryIds: [], + updatedEntries: [] + }; + } + + function rememberArcEntryBeforeUpdate(batch, entry) { + if (!batch || batch.addedArcEntryIds.includes(entry.arcEntryId)) return; + if (batch.updatedEntries.some(item => item.arcEntryId === entry.arcEntryId)) return; + batch.updatedEntries.push({ + arcEntryId: entry.arcEntryId, + before: { ...entry } + }); + } + + function mergeArcEntry(existing, incoming, batch) { + let changed = false; + const incomingNote = String(incoming.note || '').trim(); + + if (incomingNote && !String(existing.note || '').split(' | ').includes(incomingNote)) { + rememberArcEntryBeforeUpdate(batch, existing); + existing.note = existing.note ? `${existing.note} | ${incomingNote}` : incomingNote; + changed = true; + } + + if (incoming.sourceEventId && !existing.sourceEventId) { + rememberArcEntryBeforeUpdate(batch, existing); + [ + 'sourceEventId', 'source', 'hp', 'marker', 'time', 'timestamp', + 'revivedAt', 'revivedTime', 'revivedHp' + ].forEach(key => { + if (incoming[key] !== undefined) existing[key] = incoming[key]; + }); + changed = true; + } + + return changed; + } + + function appendArcCandidate(arc, candidate, options = {}) { + const allowDuplicates = Boolean(options.allowDuplicates); + const batch = options.batch || null; + const existing = allowDuplicates ? null : findArcEntityEntry(arc, candidate); + + if (existing) { + return mergeArcEntry(existing, candidate, batch) + ? { added: 0, updated: 1, duplicate: 0 } + : { added: 0, updated: 0, duplicate: 1 }; + } + + const entry = ensureArcEntryShape({ ...candidate }); + arc.entries.push(entry); + pruneEntries(arc.entries); + if (batch) batch.addedArcEntryIds.push(entry.arcEntryId); + return { added: 1, updated: 0, duplicate: 0 }; + } + + function finalizeArcBatch(arc, batch) { + const changed = batch.addedArcEntryIds.length > 0 || batch.updatedEntries.length > 0; + if (changed) arc.lastBatch = batch; + return changed; + } + + function undoLastArcBatch(arc) { + const batch = arc.lastBatch; + if (!batch) return { addedRemoved: 0, updatesRestored: 0, label: null }; + + const addedIds = new Set(batch.addedArcEntryIds || []); + const beforeCount = arc.entries.length; + arc.entries = arc.entries.filter(entry => !addedIds.has(entry.arcEntryId)); + + let updatesRestored = 0; + (batch.updatedEntries || []).forEach(snapshot => { + const index = arc.entries.findIndex(entry => entry.arcEntryId === snapshot.arcEntryId); + if (index < 0) return; + arc.entries[index] = { ...snapshot.before }; + updatesRestored++; + }); + + arc.lastBatch = null; + return { + addedRemoved: beforeCount - arc.entries.length, + updatesRestored, + label: batch.label || 'last arc update' + }; + } + + function removeArcEntryById(arc, arcEntryId) { + const before = arc.entries.length; + arc.entries = arc.entries.filter(entry => entry.arcEntryId !== arcEntryId); + arc.lastBatch = null; + return before - arc.entries.length; + } + + function removeSelectedArcEntries(arc, msg) { + const selected = Array.isArray(msg.selected) ? msg.selected : []; + const selectedKeys = new Set(); + + selected.forEach(sel => { + const token = getObj('graphic', sel._id); + if (!token) return; + const link = GameAssist.getLinkedCharacter(token); + selectedKeys.add(`token:${token.id}`); + if (link?.character?.id) selectedKeys.add(`character:${link.character.id}`); + }); + + const before = arc.entries.length; + arc.entries = arc.entries.filter(entry => !selectedKeys.has(arcEntityKey(entry))); + arc.lastBatch = null; + return before - arc.entries.length; + } + + function getOrCreateArc(name) { + const runtime = ensureNPCManagerRuntime(); + const arcName = handoutSafeName(name, 'Unnamed Arc'); + let arc = findNamedCollectionItem(runtime.arcs, arcName); + + if (!arc) { + arc = { + name: arcName, + createdAt: isoNow(), + entries: [] + }; + runtime.arcs.push(arc); + } + + if (!Array.isArray(arc.entries)) arc.entries = []; + arc.entries = arc.entries.filter(Boolean).map(ensureArcEntryShape); + return arc; + } + + function entryStatus(entry) { + return entry.revivedAt + ? `Revived ${entry.revivedTime || entry.revivedAt}` + : 'Dead'; + } + + function deathEntryKey(entry) { + return entry?.sourceEventId || entry?.id || `${entry?.name || ''}|${entry?.timestamp || ''}|${entry?.hp ?? ''}`; + } + + function renderDeathEntries(entries) { + if (!entries.length) return '

No recorded NPC deaths in this bucket yet.

'; + + const rows = entries.slice().reverse().map(entry => [ + '', + `${htmlText(entry.name)}`, + `${htmlText(entry.hp ?? 'unknown')}`, + `${htmlText(entry.time || entry.timestamp || 'time unknown')}`, + `${htmlText(entryStatus(entry))}`, + '' + ].join('')); + + return [ + '', + '', + `${rows.join('')}`, + '
NPCHPRecordedStatus
' + ].join('\n'); + } + + function renderDeathBucketHandout(bucket) { + const title = `${DEATH_BUCKET_TITLES[bucket.scope]}: ${bucket.name}`; + return [ + `

${htmlText(title)}

`, + `

Updated: ${htmlText(localNow())}

`, + `

Total entries: ${bucket.entries.length}

`, + renderDeathEntries(bucket.entries), + '

Deaths are recorded into Campaign, Chapter, Section, and Session buckets. Revivals are annotated on the matching entry instead of silently deleting history.

' + ].join('\n'); + } + + function writeBucketHandout(bucket) { + setHandoutNotes(bucketHandoutName(bucket.scope, bucket.name), renderDeathBucketHandout(bucket)); + } + + function writeActiveBucketHandouts() { + const buckets = getActiveBuckets(); + DEATH_BUCKET_SCOPES.forEach(scope => writeBucketHandout(buckets[scope])); + return buckets; + } + + function renderArcHandout(arc) { + const rows = arc.entries.length + ? '
    ' + arc.entries.slice().reverse().map(entry => { + const source = entry.source ? ` | ${htmlText(entry.source)}` : ''; + const note = entry.note ? ` | ${htmlText(entry.note)}` : ''; + const status = entry.revivedAt ? ` | ${htmlText(entryStatus(entry))}` : ''; + return `
  1. ${htmlText(entry.name)} | ${htmlText(entry.time || entry.timestamp || 'time unknown')}${source}${status}${note}
  2. `; + }).join('') + '
' + : '

No entries recorded in this arc yet.

'; + + return [ + `

Arc: ${htmlText(arc.name)}

`, + `

Updated: ${htmlText(localNow())}

`, + `

Total entries: ${arc.entries.length}

`, + rows, + '

Arc buckets keep one entry per linked creature by default. Use the Arc management menu to remove entries, undo the last addition, or deliberately allow duplicates.

' + ].join('\n'); + } + + function writeArcHandout(arc) { + setHandoutNotes(arcHandoutName(arc.name), renderArcHandout(arc)); + } + + function deathEventIdentity(token, hp) { + const linked = GameAssist.getLinkedCharacter(token); + const character = linked?.character || null; + const name = token.get('name') || character?.get('name') || '(Unnamed NPC)'; + return { + id: `${now()}-${token.id}`, + tokenId: token.id, + characterId: character?.id || null, + name, + hp, + marker: modState.config.deadMarker || 'dead', + time: localNow(), + timestamp: isoNow() + }; + } + + function cloneDeathEntry(entry) { + return { + id: entry.id, + sourceEventId: entry.sourceEventId || entry.id || null, + tokenId: entry.tokenId || null, + characterId: entry.characterId || null, + name: entry.name || '(Unnamed NPC)', + hp: entry.hp ?? null, + marker: entry.marker || modState.config.deadMarker || 'dead', + time: entry.time || entry.timestamp || 'time unknown', + timestamp: entry.timestamp || null, + source: entry.source || null, + note: entry.note || null, + revivedAt: entry.revivedAt || null, + revivedTime: entry.revivedTime || null, + revivedHp: entry.revivedHp ?? null + }; + } + + function pruneEntries(entries, limit = DEATH_LOG_LIMIT) { + if (entries.length > limit) entries.splice(0, entries.length - limit); + } + + function backfillBucketsFromLegacyLog() { + const runtime = ensureNPCManagerRuntime(); + if (runtime.state.deathBucketsBackfilled) return; + if (!runtime.deathLog.length) { + runtime.state.deathBucketsBackfilled = true; + return; + } + + const anyBucketHasEntries = DEATH_BUCKET_SCOPES.some(scope => + runtime.buckets[scope].some(bucket => Array.isArray(bucket.entries) && bucket.entries.length) + ); + if (anyBucketHasEntries) { + runtime.state.deathBucketsBackfilled = true; + return; + } + + const activeBuckets = getActiveBuckets(); + DEATH_BUCKET_SCOPES.forEach(scope => { + const bucket = activeBuckets[scope]; + runtime.deathLog.forEach(entry => { + bucket.entries.push(cloneDeathEntry(entry)); + }); + pruneEntries(bucket.entries); + writeBucketHandout(bucket); + }); + runtime.state.deathBucketsBackfilled = true; + } + + function recordDeathInBuckets(entry) { + const runtime = ensureNPCManagerRuntime(); + runtime.deathLog.push(cloneDeathEntry(entry)); + pruneEntries(runtime.deathLog); + + DEATH_BUCKET_SCOPES.forEach(scope => { + const bucket = getActiveBucket(scope); + bucket.entries.push(cloneDeathEntry(entry)); + pruneEntries(bucket.entries); + writeBucketHandout(bucket); + }); + } + + function entryMatchesToken(entry, token, fallbackName) { + if (entry?.tokenId) return entry.tokenId === token.id; + return Boolean(entry?.name && fallbackName && entry.name === fallbackName); + } + + function hasOpenDeathEntry(token) { + const runtime = ensureNPCManagerRuntime(); + const collections = [runtime.deathLog]; + DEATH_BUCKET_SCOPES.forEach(scope => { + runtime.buckets[scope].forEach(bucket => { + if (Array.isArray(bucket.entries)) collections.push(bucket.entries); + }); + }); + return collections.some(entries => entries.some(entry => + entry?.tokenId === token.id && !entry.revivedAt + )); + } + + function annotateRevivalInEntries(entries, token, fallbackName, hp, isEligible = null) { + for (let i = entries.length - 1; i >= 0; i--) { + const entry = entries[i]; + if (entry?.revivedAt) continue; + if (isEligible && !isEligible(entry)) continue; + if (!entryMatchesToken(entry, token, fallbackName)) continue; + + entry.revivedAt = isoNow(); + entry.revivedTime = localNow(); + entry.revivedHp = hp; + return true; + } + return false; + } + + function annotateRevivalInBuckets(token, hp) { + const runtime = ensureNPCManagerRuntime(); + const fallbackName = token.get('name') || '(Unnamed NPC)'; + let changed = false; + + if (annotateRevivalInEntries(runtime.deathLog, token, fallbackName, hp)) changed = true; + DEATH_BUCKET_SCOPES.forEach(scope => { + runtime.buckets[scope].forEach(bucket => { + if (annotateRevivalInEntries(bucket.entries, token, fallbackName, hp)) { + changed = true; + writeBucketHandout(bucket); + } + }); + }); + runtime.arcs.forEach(arc => { + if (annotateRevivalInEntries( + arc.entries, + token, + fallbackName, + hp, + entry => Boolean(entry?.sourceEventId) + )) { + changed = true; + writeArcHandout(arc); + } + }); + return changed; + } + + function normalizeDeathEvent(entry) { + const item = entry && typeof entry === 'object' ? entry : {}; + return { + name: item.name || '(Unnamed NPC)', + hp: item.hp ?? 'unknown', + time: item.time || item.timestamp || 'time unknown', + revivedAt: item.revivedAt || null, + revivedTime: item.revivedTime || null + }; + } + + function formatDeathEvent(entry, index) { + const item = normalizeDeathEvent(entry); + const status = item.revivedAt ? ` (${entryStatus(item)})` : ''; + return `${index}. ${item.name} - HP ${item.hp} - ${item.time}${status}`; + } + + function summarizeDeathCounts(log, limit = DEATH_REPORT_SUMMARY_LIMIT) { + const counts = {}; + log.forEach(entry => { + const item = normalizeDeathEvent(entry); + counts[item.name] = (counts[item.name] || 0) + 1; + }); + + const rows = Object.entries(counts) + .sort(([nameA, countA], [nameB, countB]) => { + if (countB !== countA) return countB - countA; + return nameA.localeCompare(nameB); + }); + + if (!rows.length) return 'No recorded NPC deaths yet.'; + + const lines = rows.slice(0, limit) + .map(([name, count]) => `${name}: ${count}`); + + if (rows.length > limit) { + lines.push(`+${rows.length - limit} more NPC name${rows.length - limit === 1 ? '' : 's'}`); + } + + return lines; + } + + function getDeathReportPage(log, page = 1, limit = DEATH_REPORT_DETAIL_LIMIT) { + const newestFirst = log.slice().reverse(); + const pageCount = Math.max(1, Math.ceil(newestFirst.length / limit)); + const currentPage = Math.min(Math.max(parseInt(page, 10) || 1, 1), pageCount); + const start = (currentPage - 1) * limit; + const entries = newestFirst.slice(start, start + limit); + const lines = entries.map((entry, index) => formatDeathEvent(entry, start + index + 1)); + + return { currentPage, pageCount, lines }; + } + + function deathReportActions(scope = 'session', currentPage = 1, pageCount = 1) { + const safeScope = normalizeScope(scope); + const scoped = `--scope ${safeScope}`; + const buttons = [ + GameAssist.createButton('Summary', `!npc-death-report ${scoped}`), + GameAssist.createButton('Recent', `!npc-death-report ${scoped} --recent`) + ]; + + if (currentPage > 1) { + buttons.push(GameAssist.createButton('Newer', `!npc-death-report ${scoped} --page ${currentPage - 1}`)); + } + if (currentPage < pageCount) { + buttons.push(GameAssist.createButton('Older', `!npc-death-report ${scoped} --page ${currentPage + 1}`)); + } + + buttons.push( + GameAssist.createButton('Buckets', '!npc-death-buckets'), + GameAssist.createButton('Write Reports', '!npc-wr'), + GameAssist.createButton('Run Audit', '!npc-death-audit'), + GameAssist.createButton('Clear This Bucket', `!npc-death-clear ${scoped}`) + ); + + return buttons.join(' '); + } + + function showDeathReportHelp() { + const names = ensureDeathBucketConfig(); + const buckets = getActiveBuckets(); + const activeLines = DEATH_BUCKET_SCOPES.map(scope => + `${DEATH_BUCKET_TITLES[scope]}: ${names[scope]} (${buckets[scope].entries.length} deaths)` + ); + + sendNPCPanel('NPCManager Guide: Death Reports', [ + { + label: 'Start Here', + value: [ + '1. Name the Campaign, Chapter, Section, and Session you are currently playing.', + '2. Let linked NPC HP changes record deaths automatically.', + '3. Read reports in chat or write the full histories to handouts.', + '4. Use Arc buckets for a separate character or story tally.' + ] + }, + { + label: 'How The Four Levels Work', + value: [ + 'Every new NPC death is recorded in all four active levels.', + 'Session is the smallest unit. Section contains its sessions; Chapter contains its sections; Campaign contains everything.', + 'When Session Date mode is active, the next NPCManager command or tracked HP change after a sandbox/UTC date change starts a new date-named Session.', + 'A custom Session name remains active across date changes until Reset Session Date is used.', + 'Changing a bucket name starts or resumes that named history; it does not erase the old one.' + ] + }, + { label: 'Active Now', value: activeLines }, + { + label: 'Read Reports', + value: DEATH_BUCKET_SCOPES.map(scope => + GameAssist.createButton(DEATH_BUCKET_TITLES[scope], `!npc-death-report --scope ${scope}`) + ) + }, + { + label: 'Write Or Adjust Reports', + value: [ + GameAssist.createButton('Open Report Writer', '!npc-wr'), + GameAssist.createButton('Change Active Names', '!npc-death-buckets'), + 'The writer can update one report, all four reports, or start a new Section using only the current Session.' + ] + }, + { + label: 'Clear History', + value: [ + 'A clear menu always offers the safest choice: clear only the selected bucket.', + 'Campaign, Chapter, and Section also offer a second choice that clears that level and every nested level beneath it.', + ...DEATH_BUCKET_SCOPES.map(scope => + GameAssist.createButton(`Clear ${DEATH_BUCKET_TITLES[scope]}`, `!npc-death-clear --scope ${scope}`) + ) + ] + }, + { + label: 'Arc Buckets', + value: [ + 'Arc buckets are separate story rosters. A creature appears once by default, even when selected manually and later imported with the whole Session.', + 'Manage an Arc to remove one entry, remove selected tokens, or undo the last addition. Use --allowDuplicates only when repetition is intentional.', + GameAssist.createButton('Open Arc Menu', '!npc-death-arc') + ] + }, + { + label: 'Current Page Audit', + value: [ + 'Audit compares linked NPC HP with the configured death marker. Player characters are excluded.', + GameAssist.createButton('Run Audit', '!npc-death-audit') + ] + }, + { + label: 'Expert Shortcuts', + value: [ + '!npc-death-report --scope campaign|chapter|section|session', + '!npc-wr = report writer', + '!npc-death-clear --scope chapter --nested --confirm', + '!npc-death-arc --name "Arc Name" --manage' + ] + } + ]); + } + + function showNPCManagerHelp() { + showDeathReportHelp(); + } + + function showDeathReportSummary(bucket) { + const log = bucket.entries; + const scopeTitle = DEATH_BUCKET_TITLES[bucket.scope]; + const handoutName = bucketHandoutName(bucket.scope, bucket.name); + + if (!log.length) { + sendDeathReport([ + { label: 'Viewing', value: `${scopeTitle}: ${bucket.name}` }, + { label: 'Result', value: 'No NPC deaths recorded in this bucket yet.' }, + { label: 'Handout', value: handoutName }, + { + label: 'What To Use Next', + value: [ + GameAssist.createButton('Run Audit', '!npc-death-audit'), + GameAssist.createButton('Manage Buckets', '!npc-death-buckets') + ] + } + ]); + return; + } + + const latest = normalizeDeathEvent(log[log.length - 1]); + const recent = getDeathReportPage(log, 1, DEATH_REPORT_SUMMARY_LIMIT); + sendDeathReport([ + { + label: 'Viewing', + value: `${scopeTitle}: ${bucket.name}` + }, + { + label: 'Handout', + value: handoutName + }, + { + label: 'Total Recorded', + value: `${log.length} NPC death event${log.length === 1 ? '' : 's'}` + }, + { + label: 'Latest', + value: `${latest.name} - ${latest.time}` + }, + { + label: 'Most Frequent', + value: summarizeDeathCounts(log) + }, + { + label: 'Recent', + value: recent.lines + }, + { + label: 'Actions', + value: deathReportActions(bucket.scope) + } + ]); + } + + function showDeathReportPage(bucket, page = 1, label = 'Entries') { + const log = bucket.entries; + if (!log.length) { + showDeathReportSummary(bucket); + return; + } + + const reportPage = getDeathReportPage(log, page); + sendDeathReport([ + { + label, + value: `${DEATH_BUCKET_TITLES[bucket.scope]}: ${bucket.name}. Showing page ${reportPage.currentPage} of ${reportPage.pageCount}. Newest entries appear first.` + }, + { + label: 'Recorded Deaths', + value: reportPage.lines + }, + { + label: 'Actions', + value: deathReportActions(bucket.scope, reportPage.currentPage, reportPage.pageCount) + } + ]); + } + + function updateDeathBucketNames(args) { + const names = ensureDeathBucketConfig(); + const runtime = ensureNPCManagerRuntime(); + let changed = false; + + DEATH_BUCKET_SCOPES.forEach(scope => { + if (args[scope] === undefined) return; + if (args[scope] === true) return; + const next = handoutSafeName(args[scope], defaultBucketName(scope)); + if (names[scope] !== next) { + names[scope] = next; + changed = true; + } + if (scope === 'session') { + runtime.state.activeSessionDateKey = currentSessionDateKey(); + runtime.state.sessionDateManaged = false; + } + }); + + if (args.resetSession) { + const next = currentSessionDateKey(); + if (names.session !== next) { + names.session = next; + changed = true; + } + runtime.state.activeSessionDateKey = next; + runtime.state.sessionDateManaged = true; + } + + if (changed) writeActiveBucketHandouts(); + return changed; + } + + function ensureSessionDateRollover(announce = true) { + const runtime = ensureNPCManagerRuntime(); + const names = ensureDeathBucketConfig(); + const currentDate = currentSessionDateKey(); + const previousDate = runtime.state.activeSessionDateKey; + const sessionLooksDateNamed = /^\d{4}-\d{2}-\d{2}$/.test(names.session); + if (runtime.state.sessionDateManaged === undefined) { + runtime.state.sessionDateManaged = sessionLooksDateNamed; + } + const sessionDateManaged = Boolean(runtime.state.sessionDateManaged); + + if (!previousDate) { + runtime.state.activeSessionDateKey = currentDate; + if (!sessionDateManaged || names.session === currentDate) return false; + } else if (previousDate === currentDate) { + return false; + } + + runtime.state.activeSessionDateKey = currentDate; + if (!sessionDateManaged) return false; + + const previousName = names.session; + names.session = currentDate; + const bucket = getActiveBucket('session'); + writeBucketHandout(bucket); + + if (announce) { + GameAssist.log( + 'NPCManager', + `Date changed from ${previousDate || previousName} to ${currentDate}; active Session is now ${currentDate}.`, + 'INFO' + ); + } + return true; + } + + function prepareNPCManagerActivity() { + backfillBucketsFromLegacyLog(); + return ensureSessionDateRollover(); + } + + function showDeathBucketsPanel(message = null) { + const names = ensureDeathBucketConfig(); + const buckets = getActiveBuckets(); + const lines = DEATH_BUCKET_SCOPES.map(scope => + `${DEATH_BUCKET_TITLES[scope]}: ${names[scope]} (${buckets[scope].entries.length} deaths)` + ); + + sendNPCPanel('NPC Death Buckets', [ + { + label: 'Active Buckets', + value: lines + }, + { + label: 'Change Names', + value: [ + GameAssist.createButton('Set Campaign', `!npc-death-buckets --campaign "?{Campaign bucket name|${queryDefault(names.campaign)}}"`), + GameAssist.createButton('Set Chapter', `!npc-death-buckets --chapter "?{Chapter bucket name|${queryDefault(names.chapter)}}"`), + GameAssist.createButton('Set Section', `!npc-death-buckets --section "?{Section bucket name|${queryDefault(names.section)}}"`), + GameAssist.createButton('Set Session', `!npc-death-buckets --session "?{Session bucket name|${queryDefault(names.session)}}"`), + GameAssist.createButton('Reset Session Date', '!npc-death-buckets --resetSession') + ] + }, + { + label: 'Reports', + value: DEATH_BUCKET_SCOPES.map(scope => + GameAssist.createButton(DEATH_BUCKET_TITLES[scope], `!npc-death-report --scope ${scope}`) + ) + }, + { + label: 'Write Reports', + value: GameAssist.createButton('Open Report Writer', '!npc-wr') + }, + { + label: 'Tip', + value: message || 'Changing a bucket name starts or resumes that named bucket. Existing bucket handouts are retained.' + } + ]); + } + + function appendUniqueDeathEntries(targetBucket, sourceEntries) { + const existing = new Set(targetBucket.entries.map(deathEntryKey)); + let added = 0; + + sourceEntries.forEach(entry => { + const key = deathEntryKey(entry); + if (existing.has(key)) return; + targetBucket.entries.push(cloneDeathEntry(entry)); + existing.add(key); + added++; + }); + + pruneEntries(targetBucket.entries); + return added; + } + + function startSectionFromCurrentSession(sectionName) { + const names = ensureDeathBucketConfig(); + names.section = handoutSafeName(sectionName, defaultBucketName('section')); + const section = getActiveBucket('section'); + const session = getActiveBucket('session'); + const added = appendUniqueDeathEntries(section, session.entries); + writeBucketHandout(section); + writeBucketHandout(session); + return { section, session, added }; + } + + function showReportWriterPanel(message = null) { + const names = ensureDeathBucketConfig(); + const buckets = getActiveBuckets(); + const activeLines = DEATH_BUCKET_SCOPES.map(scope => + `${DEATH_BUCKET_TITLES[scope]}: ${names[scope]} (${buckets[scope].entries.length} deaths)` + ); + + sendNPCPanel('NPC Report Writer', [ + { + label: 'Before You Write', + value: 'Review the active names and counts below. Writing updates handouts from saved GameAssist history; it does not create another death entry.' + }, + { label: 'Active Reports', value: activeLines }, + { + label: 'Write Now', + value: [ + GameAssist.createButton('Write All Four', '!npc-death-write --all'), + ...DEATH_BUCKET_SCOPES.map(scope => + GameAssist.createButton(`Write ${DEATH_BUCKET_TITLES[scope]}`, `!npc-death-write --scope ${scope}`) + ) + ] + }, + { + label: 'Adjust First', + value: [ + GameAssist.createButton('Change Active Names', '!npc-death-buckets'), + GameAssist.createButton('New Section From Current Session', `!npc-death-write --newSection "?{New section name|${queryDefault(names.section)}}"`) + ] + }, + { + label: 'New Section From Session', + value: 'Changes the active Section name, copies only missing deaths from the current Session into that Section, and writes both handouts. Campaign and Chapter are not rewritten by that action.' + }, + { + label: 'Short Command', + value: '!NPC-WR opens this menu. !npc-death-write is the full command.' + }, + { label: 'Result', value: message || 'No reports written yet.' } + ]); + } + + function handleReportWriter(args = {}) { + if (args.newSection && args.newSection !== true) { + const result = startSectionFromCurrentSession(args.newSection); + showReportWriterPanel( + `Section is now ${result.section.name}. Added ${result.added} current-session death${result.added === 1 ? '' : 's'} and updated the Section and Session handouts.` + ); + return; + } + + if (args.all) { + writeActiveBucketHandouts(); + showReportWriterPanel('Campaign, Chapter, Section, and Session handouts updated.'); + return; + } + + if (args.scope) { + const scope = normalizeScope(args.scope); + const bucket = getActiveBucket(scope); + writeBucketHandout(bucket); + showReportWriterPanel(`${DEATH_BUCKET_TITLES[scope]} handout updated: ${bucketHandoutName(scope, bucket.name)}.`); + return; + } + + showReportWriterPanel(); + } + + function showDeathClearConfirm(scope) { + const safeScope = normalizeScope(scope); + const onlyBucket = getActiveBucket(safeScope); + const nestedScopes = NESTED_BUCKET_SCOPES[safeScope]; + const nestedBuckets = nestedScopes.map(getActiveBucket); + const nestedCount = nestedBuckets.reduce((total, bucket) => total + bucket.entries.length, 0); + const nestedNames = nestedBuckets.map(bucket => `${DEATH_BUCKET_TITLES[bucket.scope]}: ${bucket.name}`).join(', '); + + if (!nestedCount) { + sendNPCPanel('NPC Death Bucket Clear', [ + { label: 'Result', value: `No recorded NPC deaths to clear from ${nestedNames}.` }, + { label: 'Actions', value: deathReportActions(safeScope) } + ]); + return; + } + + sendNPCPanel('NPC Death Bucket Clear', [ + { + label: 'Clear Only This Bucket', + value: `${DEATH_BUCKET_TITLES[safeScope]}: ${onlyBucket.name} contains ${onlyBucket.entries.length} recorded death${onlyBucket.entries.length === 1 ? '' : 's'}.` + }, + { + label: 'Clear This Level And Below', + value: nestedScopes.length > 1 + ? `${nestedCount} total entries across ${nestedNames}. Parent buckets above ${DEATH_BUCKET_TITLES[safeScope]} are retained.` + : 'Session has no nested bucket beneath it.' + }, + { + label: 'Buttons', + value: [ + GameAssist.createButton(`Clear Only ${DEATH_BUCKET_TITLES[safeScope]}`, `!npc-death-clear --scope ${safeScope} --confirm`), + ...(nestedScopes.length > 1 + ? [GameAssist.createButton(`Clear ${DEATH_BUCKET_TITLES[safeScope]} And Below`, `!npc-death-clear --scope ${safeScope} --nested --confirm`)] + : []), + GameAssist.createButton('Cancel', `!npc-death-report --scope ${safeScope}`) + ] + } + ]); + } + + function clearActiveDeathBuckets(scope, includeNested = false) { + const safeScope = normalizeScope(scope); + const scopes = includeNested ? NESTED_BUCKET_SCOPES[safeScope] : [safeScope]; + const results = scopes.map(itemScope => { + const bucket = getActiveBucket(itemScope); + const count = bucket.entries.length; + bucket.entries.length = 0; + writeBucketHandout(bucket); + return { bucket, count }; + }); + + if (scopes.includes('session')) { + const runtime = ensureNPCManagerRuntime(); + runtime.deathLog.length = 0; + } + + return { + scope: safeScope, + includeNested, + results, + count: results.reduce((total, result) => total + result.count, 0) + }; + } + + function renderAuditHandout(needsMarker, needsClear, invalid) { + function renderList(title, entries) { + if (!entries.length) return `

${htmlText(title)}

None.

`; + return [ + `

${htmlText(title)}

`, + '
    ', + entries.map(entry => + `
  • ${htmlText(entry.name)} | HP ${htmlText(entry.hp)} | Markers: ${htmlText(entry.markers)} | Token ID: ${htmlText(entry.id)}
  • ` + ).join(''), + '
' + ].join('\n'); + } + + return [ + '

NPC Death Audit

', + `

Updated: ${htmlText(localNow())}

`, + '

Checked linked NPC tokens on the current player page. Player characters are intentionally not included.

', + `

Configured death marker: ${htmlText(modState.config.deadMarker || 'dead')}

`, + renderList('Needs Death Marker', needsMarker), + renderList('Needs Marker Cleared', needsClear), + invalid.length + ? `

Ignored Unlinked Items

${htmlText(summarizeAuditNames(invalid, 20))}

Expected for party markers, scenery, labels, or props.

` + : '

Ignored Unlinked Items

None.

' + ].join('\n'); + } + + function selectedArcEntry(token, note = null) { + const link = GameAssist.getLinkedCharacter(token); + if (!link) return null; + const isNPC = Boolean(getNPCContext(token, link)); + const character = link.character; + const rawHP = token.get('bar1_value'); + return { + id: `${now()}-${token.id}`, + tokenId: token.id, + characterId: character.id, + name: token.get('name') || character.get('name') || '(Unnamed)', + hp: rawHP === '' || rawHP == null ? null : rawHP, + marker: token.get('statusmarkers') || null, + time: localNow(), + timestamp: isoNow(), + source: isNPC ? 'Selected NPC token' : 'Selected PC token', + note: note || null + }; + } + + function appendSelectedTokensToArc(arc, msg, note = null, options = {}) { + const selected = Array.isArray(msg.selected) ? msg.selected : []; + const skipped = []; + const result = { added: 0, updated: 0, duplicates: 0, skipped }; + + selected.forEach(sel => { + const token = getObj('graphic', sel._id); + if (!token) { + skipped.push('(missing token)'); + return; + } + + const entry = selectedArcEntry(token, note); + if (!entry) { + skipped.push(token.get('name') || '(Unnamed)'); + return; + } + + const change = appendArcCandidate(arc, entry, options); + result.added += change.added; + result.updated += change.updated; + result.duplicates += change.duplicate; + }); + + return result; + } + + function appendSessionToArc(arc, options = {}) { + const bucket = getActiveBucket('session'); + const result = { added: 0, updated: 0, duplicates: 0 }; + + bucket.entries.forEach(entry => { + const key = deathEntryKey(entry); + const change = appendArcCandidate(arc, { + ...cloneDeathEntry(entry), + source: `Session bucket: ${bucket.name}`, + sourceEventId: key + }, options); + result.added += change.added; + result.updated += change.updated; + result.duplicates += change.duplicate; + }); + + return result; + } + + function showArcPanel(message = null) { + const runtime = ensureNPCManagerRuntime(); + const arcLines = runtime.arcs.length + ? runtime.arcs.map(arc => [ + `${arc.name}: ${Array.isArray(arc.entries) ? arc.entries.length : 0} entries`, + GameAssist.createButton('Manage', `!npc-death-arc --name "${queryDefault(arc.name)}" --manage`) + ].join(' ')) + : ['No arc buckets created yet.']; + + sendNPCPanel('NPC Arc Buckets', [ + { + label: 'Default Rule', + value: 'Each linked creature appears once per arc. Adding it again updates the existing entry instead of creating a duplicate.' + }, + { + label: 'Current Arcs', + value: arcLines + }, + { + label: 'Commands', + value: [ + '!npc-death-arc --name "Paladin Atonement" = add selected linked PC/NPC tokens', + '!npc-death-arc --name "Paladin Atonement" --session = append current session deaths', + '!npc-death-arc --name "Paladin Atonement" --note "text" = add a short note to selected tokens', + '!npc-death-arc --name "Paladin Atonement" --manage = remove or undo entries', + '!npc-death-arc --name "Paladin Atonement" --allowDuplicates = deliberately bypass deduplication' + ] + }, + { + label: 'Buttons', + value: [ + GameAssist.createButton('Add Selected To Arc', '!npc-death-arc --name "?{Arc bucket name|Paladin Atonement}"'), + GameAssist.createButton('Append Session To Arc', '!npc-death-arc --name "?{Arc bucket name|Paladin Atonement}" --session'), + GameAssist.createButton('Manage Arc', '!npc-death-arc --name "?{Arc bucket name|Paladin Atonement}" --manage') + ] + }, + { + label: 'Tip', + value: message || 'Select tokens before using the selected-token command.' + } + ]); + } + + function showArcManagePanel(arc, page = 1, message = null) { + const limit = DEATH_REPORT_DETAIL_LIMIT; + const newest = arc.entries.slice().reverse(); + const pageCount = Math.max(1, Math.ceil(newest.length / limit)); + const currentPage = Math.min(Math.max(parseInt(page, 10) || 1, 1), pageCount); + const start = (currentPage - 1) * limit; + const entries = newest.slice(start, start + limit); + const arcName = queryDefault(arc.name, 'Arc'); + const entryLines = entries.length + ? entries.map(entry => [ + `${entry.name} | ${entry.source || 'Manual entry'}`, + GameAssist.createButton('Remove', `!npc-death-arc --name "${arcName}" --remove ${entry.arcEntryId}`) + ].join(' ')) + : ['No entries in this arc.']; + const nav = []; + + if (currentPage > 1) { + nav.push(GameAssist.createButton('Newer', `!npc-death-arc --name "${arcName}" --manage --page ${currentPage - 1}`)); + } + if (currentPage < pageCount) { + nav.push(GameAssist.createButton('Older', `!npc-death-arc --name "${arcName}" --manage --page ${currentPage + 1}`)); + } + + sendNPCPanel('Manage NPC Arc', [ + { label: 'Arc', value: `${arc.name} (${arc.entries.length} entries)` }, + { label: 'Entries', value: entryLines }, + { + label: 'Bulk Actions', + value: [ + GameAssist.createButton('Remove Selected Tokens', `!npc-death-arc --name "${arcName}" --removeSelected`), + GameAssist.createButton('Undo Last Addition', `!npc-death-arc --name "${arcName}" --undo`), + GameAssist.createButton('Append Current Session', `!npc-death-arc --name "${arcName}" --session`) + ] + }, + { label: 'Pages', value: nav.length ? nav : `Page ${currentPage} of ${pageCount}` }, + { + label: 'Tip', + value: message || 'Remove buttons affect only this arc handout. Campaign, Chapter, Section, and Session death history is unchanged.' + } + ]); + } + + function requestDeathMarker(token, on) { + const resolution = getDeathMarkerResolution(); + if (!resolution.ok) { + GameAssist.log('NPCManager', deathMarkerWarning(resolution), 'WARN'); + return false; + } + + if (resolution.ambiguous) { + GameAssist.log( + 'NPCManager', + `Marker "${resolution.requested}" matches multiple custom markers; using ${resolution.id}.`, + 'WARN' + ); + } + + return requestTokenModMarker(token, resolution.id, on, 'NPCManager'); + } + + function getNPCContext(token, link = null) { + const linked = link || GameAssist.getLinkedCharacter(token); + if (!linked) return null; + + const npcAttr = findObjs({ + _type: 'attribute', + _characterid: linked.character.id, + name: 'npc' + })[0]; + + if (!npcAttr || npcAttr.get('current') !== '1') return null; + return linked; + } + + function parseTrackedHP(raw) { + if (raw === '' || raw == null) return null; + const hp = parseInt(raw, 10); + return Number.isFinite(hp) ? hp : null; + } + + /** + * handleTokenAdd — Guard the short setup interval in which NPCHPRoller replaces placeholder HP. + * Context: Roll20 can expose zero/blank bar values before auto-roll-on-add writes rolled HP. + * Invariant: only active auto-roll-on-add receives the grace period; normal gameplay HP changes remain direct. + */ + function handleTokenAdd(token) { + const hpRollerConfig = GameAssist.getState('NPCHPRoller')?.config; + if (hpRollerConfig?.enabled === false || hpRollerConfig?.autoRollOnAdd !== true) return; + + initializingNpcHp.add(token.id); + setTimeout( + () => initializingNpcHp.delete(token.id), + POLICY.runtime.npcHpInitializationGraceMs + ); + } + + function checkForDeath(token) { + if (!modState.config.autoTrackDeath) return; + + if (!getNPCContext(token)) return; + + const hp = parseTrackedHP(token.get('bar1_value')); + if (hp === null) return; + + prepareNPCManagerActivity(); + const isDead = tokenHasMarker(token, modState.config.deadMarker); + + if (hp < 1) { + if (!isDead) requestDeathMarker(token, true); + if (hasOpenDeathEntry(token)) return; + + const name = token.get('name') || '(Unnamed NPC)'; + GameAssist.log('NPCManager', `${name} recorded as dead (HP: ${hp})`); + + // Auto-hide if enabled + if (modState.config.autoHide) { + token.set('layer', modState.config.hideLayer); + GameAssist.log('NPCManager', `${name} moved to ${modState.config.hideLayer}`); + } + + recordDeathInBuckets(deathEventIdentity(token, hp)); + } else if (hp >= 1) { + const annotated = annotateRevivalInBuckets(token, hp); + if (isDead) requestDeathMarker(token, false); + if (isDead || annotated) { + GameAssist.log('NPCManager', `${token.get('name')} revived (HP: ${hp})`); + } + } + } + + function handleTokenChange(obj, prev) { + const currentHp = parseTrackedHP(obj.get('bar1_value')); + const previousHp = parseTrackedHP(prev?.bar1_value); + if (currentHp === null || currentHp === previousHp) return; + + if (initializingNpcHp.has(obj.id)) { + if (currentHp >= 1) initializingNpcHp.delete(obj.id); + return; + } + + // CHOICE: unknown/blank -> dead is initialization, not evidence of a living NPC crossing zero. + if (previousHp === null && currentHp < 1) return; + checkForDeath(obj); + } + + GameAssist.onCommand('!npc-death-help', msg => { + prepareNPCManagerActivity(); + showNPCManagerHelp(); + }, 'NPCManager', { gmOnly: true }); + + GameAssist.onCommand('!npc-death-report', msg => { + const { args } = _parseArgs(msg.content); + prepareNPCManagerActivity(); + + if (args.help) { + showDeathReportHelp(); + return; + } + + if (args.write) { + handleReportWriter(args); + return; + } + + const scope = normalizeScope(args.scope); + const bucket = getActiveBucket(scope); + + if (args.recent) { + showDeathReportPage(bucket, 1, 'Recent Entries'); + return; + } + + if (args.page || args.details || args.all) { + showDeathReportPage(bucket, args.page || 1, 'Detail View'); + return; + } + + showDeathReportSummary(bucket); + }, 'NPCManager', { gmOnly: true }); + + GameAssist.onCommand('!npc-death-clear', msg => { + const { args } = _parseArgs(msg.content); + const scope = normalizeScope(args.scope); + prepareNPCManagerActivity(); + + if (!args.confirm) { + showDeathClearConfirm(scope); + return; + } + + const result = clearActiveDeathBuckets(scope, Boolean(args.nested)); + const cleared = result.results + .map(item => `${DEATH_BUCKET_TITLES[item.bucket.scope]} ${item.bucket.name}: ${item.count}`) + .join(', '); + sendNPCPanel('NPC Death Bucket Clear', [ + { label: 'Result', value: `Cleared ${result.count} recorded NPC death event${result.count === 1 ? '' : 's'}.` }, + { label: 'Buckets', value: cleared }, + { label: 'Mode', value: result.includeNested ? 'Selected level and all nested levels.' : 'Selected bucket only.' }, + { label: 'Actions', value: deathReportActions(result.scope) } + ]); + }, 'NPCManager', { gmOnly: true }); + + GameAssist.onCommand('!npc-death-buckets', msg => { + const { args } = _parseArgs(msg.content); + prepareNPCManagerActivity(); + const changed = updateDeathBucketNames(args); + showDeathBucketsPanel(changed ? 'Bucket names updated and active bucket handouts refreshed.' : null); + }, 'NPCManager', { gmOnly: true }); + + function reportWriterCommand(msg) { + const { args } = _parseArgs(msg.content); + prepareNPCManagerActivity(); + handleReportWriter(args); + } + + GameAssist.onCommand('!npc-death-write', reportWriterCommand, 'NPCManager', { gmOnly: true }); + GameAssist.onCommand('!npc-wr', reportWriterCommand, 'NPCManager', { gmOnly: true }); + + GameAssist.onCommand('!npc-death-arc', msg => { + const { args } = _parseArgs(msg.content); + prepareNPCManagerActivity(); + + if (args.help || args.list || !args.name || args.name === true) { + showArcPanel(args.name === true ? 'Add an arc name, for example: !npc-death-arc --name "Paladin Atonement".' : null); + return; + } + + const arc = getOrCreateArc(args.name); + if (args.manage) { + showArcManagePanel(arc, args.page || 1); + return; + } + + if (args.undo) { + const result = undoLastArcBatch(arc); + writeArcHandout(arc); + showArcManagePanel( + arc, + 1, + result.label + ? `Undid ${result.label}: removed ${result.addedRemoved} added entr${result.addedRemoved === 1 ? 'y' : 'ies'} and restored ${result.updatesRestored} updated entr${result.updatesRestored === 1 ? 'y' : 'ies'}.` + : 'There is no recent arc addition to undo.' + ); + return; + } + + if (args.removeSelected) { + const removed = removeSelectedArcEntries(arc, msg); + writeArcHandout(arc); + showArcManagePanel(arc, 1, removed + ? `Removed ${removed} entr${removed === 1 ? 'y' : 'ies'} matching the selected token${removed === 1 ? '' : 's'}.` + : 'No arc entries matched the selected tokens.'); + return; + } + + if (args.remove && args.remove !== true) { + const removed = removeArcEntryById(arc, String(args.remove)); + writeArcHandout(arc); + showArcManagePanel(arc, args.page || 1, removed ? 'Entry removed.' : 'That arc entry was not found.'); + return; + } + + const allowDuplicates = Boolean(args.allowDuplicates || args.duplicate); + const batch = beginArcBatch(arc, args.session ? `Session import: ${getActiveBucket('session').name}` : 'Selected-token addition'); + const options = { allowDuplicates, batch }; + const selectedResult = args.session + ? { added: 0, updated: 0, duplicates: 0, skipped: [] } + : appendSelectedTokensToArc(arc, msg, args.note || null, options); + const sessionResult = args.session + ? appendSessionToArc(arc, options) + : { added: 0, updated: 0, duplicates: 0 }; + const added = selectedResult.added + sessionResult.added; + const updated = selectedResult.updated + sessionResult.updated; + const duplicates = selectedResult.duplicates + sessionResult.duplicates; + const changed = finalizeArcBatch(arc, batch); + + if (!changed) { + showArcManagePanel( + arc, + 1, + args.session + ? `No new creatures were added. ${duplicates} existing entr${duplicates === 1 ? 'y was' : 'ies were'} kept without duplication.` + : 'No new linked creatures were added. Select linked tokens, or use --allowDuplicates when repetition is intentional.' + ); + return; + } + + writeArcHandout(arc); + sendNPCPanel('NPC Arc Bucket Updated', [ + { label: 'Arc', value: arc.name }, + { label: 'Added', value: `${added} entr${added === 1 ? 'y' : 'ies'}` }, + { label: 'Updated', value: `${updated} existing entr${updated === 1 ? 'y' : 'ies'}` }, + { label: 'Duplicates Avoided', value: duplicates }, + { label: 'Handout', value: arcHandoutName(arc.name) }, + { + label: 'Skipped', + value: selectedResult.skipped.length + ? `${selectedResult.skipped.length} unlinked or missing token${selectedResult.skipped.length === 1 ? '' : 's'}: ${summarizeAuditNames(selectedResult.skipped)}` + : 'None' + }, + { + label: 'Actions', + value: [ + GameAssist.createButton('Manage Arc', `!npc-death-arc --name "${queryDefault(arc.name)}" --manage`), + GameAssist.createButton('Undo This Addition', `!npc-death-arc --name "${queryDefault(arc.name)}" --undo`) + ] + } + ]); + }, 'NPCManager', { gmOnly: true }); + + GameAssist.onCommand('!npc-death-audit', msg => { + prepareNPCManagerActivity(); + const pageId = Campaign().get('playerpageid'); + const tokens = findObjs({ + _pageid: pageId, + _type: 'graphic', + layer: 'objects' + }); + + const needsMarker = []; + const needsClear = []; + const invalid = []; + for (let token of tokens) { + const link = GameAssist.getLinkedCharacter(token); + if (!link) { + invalid.push(token.get('name') || '(Unnamed)'); + continue; + } + + if (!getNPCContext(token, link)) continue; + + const hp = parseInt(token.get('bar1_value'), 10) || 0; + const isDead = tokenHasMarker(token, modState.config.deadMarker); + + if (hp < 1 && !isDead) { + needsMarker.push({ + name: token.get('name') || '(Unnamed)', + id: token.id, + hp, + markers: token.get('statusmarkers') || '(none)' + }); + } else if (hp >= 1 && isDead) { + needsClear.push({ + name: token.get('name') || '(Unnamed)', + id: token.id, + hp, + markers: token.get('statusmarkers') || '(none)' + }); + } + } + + const mismatchCount = needsMarker.length + needsClear.length; + setHandoutNotes(AUDIT_HANDOUT_NAME, renderAuditHandout(needsMarker, needsClear, invalid)); + const fields = [ + { + label: 'Result', + value: mismatchCount + ? `⚠️ ${mismatchCount} linked NPC${mismatchCount === 1 ? '' : 's'} need death-marker attention.` + : '✅ No death-marker problems found for linked NPCs.' + } + ]; + + if (needsMarker.length) { + fields.push({ + label: `Add Death Marker (${needsMarker.length})`, + value: formatAuditEntries(needsMarker) + }); + } + + if (needsClear.length) { + fields.push({ + label: `Remove Death Marker (${needsClear.length})`, + value: formatAuditEntries(needsClear) + }); + } + + fields.push( + { + label: 'Scope', + value: 'Checked linked NPC tokens on the current player page. Player characters are not included.' + }, + { + label: 'Detail Handout', + value: AUDIT_HANDOUT_NAME + }, + { + label: 'Configured Marker', + value: modState.config.deadMarker || 'dead' + }, + { + label: 'Counts', + value: [ + `Needs marker: ${needsMarker.length}`, + `Needs marker cleared: ${needsClear.length}`, + `Ignored unlinked: ${invalid.length}` + ] + } + ); + + if (invalid.length) { + fields.push({ + label: `Ignored Unlinked (${invalid.length})`, + value: [ + summarizeAuditNames(invalid), + 'Expected for party markers, scenery, labels, or props.' + ] + }); + } + + sendAuditReport(fields); + }, 'NPCManager', { gmOnly: true }); + + GameAssist.onEvent('add:graphic', handleTokenAdd, 'NPCManager'); + GameAssist.onEvent('change:graphic:bar1_value', handleTokenChange, 'NPCManager'); + GameAssist.log('NPCManager', `${NPCMANAGER_MODULE_VERSION} Ready: Auto death tracking + hierarchical reports/writer/audits/arcs`, 'INFO', { startup: true }); + }, { + enabled: true, + events: ['add:graphic', 'change:graphic:bar1_value'], + prefixes: ['!npc-death-help', '!npc-death-report', '!npc-death-clear', '!npc-death-audit', '!npc-death-buckets', '!npc-death-write', '!npc-wr', '!npc-death-arc'], + dependsOn: ['TokenMod'], + preserveRuntimeOnDisable: true, + teardown: () => { + const branch = GameAssist.getState('NPCManager'); + const marker = branch?.config?.deadMarker || 'dead'; + const resolution = resolveMarkerId(marker); + const pageId = Campaign().get('playerpageid'); + + if (!pageId) return; + if (!resolution.ok) { + const detail = resolution.registryError + ? ` Roll20 marker registry problem: ${_sanitize(resolution.registryError)}.` + : ''; + GameAssist.log('NPCManager', `Configured NPC death marker "${_sanitize(marker)}" could not be recognized during teardown.${detail}`, 'WARN'); + return; + } + + const targets = findObjs({ + _type: 'graphic', + _pageid: pageId, + layer: 'objects' + }).filter(token => tokenHasMarker(token, resolution.id)); + + if (!targets.length) return; + + const requested = targets.filter(token => + requestTokenModMarker(token, resolution.id, false, 'NPCManager') + ).length; + + if (requested) { + GameAssist.log('NPCManager', `Requested removal of ${resolution.id} from ${requested} token(s) during teardown; results will be verified.`); + } + } + }); + // --- Notes & Comments --- + // CHOICE: TokenMod used for status marker ops; keep dependency explicit in README. + // CHOICE: Arc rosters identify a creature by token before legacy character/name fallbacks — ALT: character-only identity; REJECTED: multiple NPC tokens may share one character sheet. + // Changed (v0.1.4.7): Suppress placeholder HP transitions during NPCHPRoller auto-roll-on-add and require a known starting HP before recording an automatic death; this prevents false death/revival history when an NPC is added to the map. + // Changed (v0.1.4.7): NPCManager module_version advanced to 1.1.1; marker add/remove/teardown now use TokenMod --api-as with delayed result verification and no longer claim teardown completion before confirmation. + // Maintenance (v0.1.4.5, no semantic change): Removed the unused init-time Session default; live Session defaults continue to come from currentSessionDateKey(). + // Changed (v0.1.4.5): NPCManager module_version advanced from 0.1.1.0 to 1.0.0 for scoped bucket/handout management, then to 1.1.0 for Arc curation, hierarchical clearing, date-managed Sessions, and report writing. + // Changed (v0.1.4.5): Explicit Session names now remain active across date changes; Reset Session Date re-enables automatic UTC date rollover. + // Changed (v0.1.4.5): Revival history annotations no longer depend on successful TokenMod marker removal. + // Changed (v0.1.4.5): Restored bounded, grouped token details to the death-audit chat panel while retaining the complete audit handout. + // Changed (v0.1.4.5): NPCManager opts into runtime retention so disabling marker automation does not erase saved bucket history or Arc records. + // Changed (v0.1.4.5): Open-death deduplication requires an exact token id so legacy name-only entries cannot suppress a different same-named NPC. + // Changed (v0.1.4.5): Added !npc-death-help as the GM-facing start-here menu for bucket naming, reports, audits, and arc handouts. + // Changed (v0.1.4.5): NPC death history now records into active Campaign/Chapter/Section/Session buckets independently of marker-write success, updates one handout per bucket, annotates revivals on matching entries, moves audit details into a handout, and adds manual arc buckets for selected linked PC/NPC tokens or current-session deaths. + // Changed (v0.1.4.5): Renamed the arc command to !npc-death-arc and hardened migration, same-name token matching, scoped-clear duplicate prevention, zero-HP capture, and death-only revival annotations in arc handouts. + // Changed (v0.1.4.4): NPC death-audit now sends a grouped GM report, states that PCs are excluded, categorizes marker mismatches by action, and caps detailed rows to keep Roll20 chat output usable. + // Changed (v0.1.4.3): NPCManager now resolves configured death markers before TokenMod add/remove/teardown requests, matching shared read-side marker identity behavior. + // Changed (v0.1.4.1): Use exact shared marker matching, POLICY-owned cache limits, and shared time seams. + // Prior notes: + // Changed (v0.1.3): Hardened deathLog self-healing, timestamps, cap, and in-place clearing. + // Maintenance (v0.1.3, no semantic change): Added module narrative; preserved dependency and marker logic. + // Maintenance (v0.1.1.2, no semantic change): Metadata updated for MECHSUITS v1.5.1 alignment. + // [GAMEASSIST:MODULES:NPCMANAGER] END + // ============================================================================= + + // ————— CONCENTRATION TRACKER MODULE v0.1.0.6 ————— + // ============================================================================= + // [GAMEASSIST:MODULES:CONCENTRATIONTRACKER] BEGIN + // Section Title: ConcentrationTracker module + // ------------------------------------------------------------------------- + // mechsuit_section: { codename: "GAMEASSIST", area: "MODULES:CONCENTRATIONTRACKER", title: "ConcentrationTracker", + // guarantees: ["Chat UI for concentration saves; exact configured-marker status reporting; verified standalone TokenMod marker requests"], + // depends_on: ["[GAMEASSIST:POLICY]","[GAMEASSIST:APP:UTILS]","[GAMEASSIST:CORE:OBJECT]"], + // last_updated_version: "v0.1.4.7", + // independent_versions: { module_version: "0.1.0.6" } } + // ------------------------------------------------------------------------- + // Narrative + // MODULES:CONCENTRATIONTRACKER manages concentration save rolls, whispering outcomes + // and requesting the configured marker through standalone TokenMod with delayed + // result verification. Status reads resolve custom marker + // display names to their stored Roll20 tags and report invalid configuration. + // ------------------------------------------------------------------------- + GameAssist.register('ConcentrationTracker', function() { + // ─── Module Setup ────────────────────────────────────────────────────────────── + const modState = GameAssist.getState('ConcentrationTracker'); + Object.assign(modState.config, { + enabled: true, + marker: 'Concentrating', + randomize: true, + ...modState.config + }); + + const LAST_DAMAGE_LIMIT = POLICY.runtime.lastDamageLimit; + + function getEntryTimestamp(entry) { + const ts = Number(entry && entry.timestamp); + return (Number.isFinite(ts) && ts > 0) ? ts : 0; + } + + function pruneLastDamage(lastDamage) { + const entries = Object.entries(lastDamage || {}); + if (entries.length <= LAST_DAMAGE_LIMIT) return; + + entries + .sort(([, a], [, b]) => getEntryTimestamp(a) - getEntryTimestamp(b)) + .slice(0, entries.length - LAST_DAMAGE_LIMIT) + .forEach(([playerId]) => delete lastDamage[playerId]); + } + + function normalizeLastDamageCache() { + const lastDamage = ensureModRuntimeKey(modState, 'lastDamage', 'object'); + + Object.entries(lastDamage).forEach(([playerId, payload]) => { + if (typeof payload === 'number' || typeof payload === 'string') { + const dmg = Number(payload) || 0; + lastDamage[playerId] = { + damage: dmg, + dc: Math.max(10, Math.floor(dmg / 2)), + bonus: null, + mode: 'normal', + tokenId: null, + tokenName: null, + characterId: null, + characterName: null, + player: (getObj('player', playerId)?.get('displayname') || null), + timestamp: 0 + }; + return; + } + + if (payload && typeof payload === 'object') { + let tokenId = null; + if (payload.tokenId) tokenId = payload.tokenId; + else if (payload.tokenID) tokenId = payload.tokenID; + else if (payload.tokenIdLegacy) tokenId = payload.tokenIdLegacy; + + const damage = Number(payload.damage) || 0; + const normalized = { + damage, + dc: payload.dc !== undefined ? (Number(payload.dc) || Math.max(10, Math.floor(damage / 2))) : Math.max(10, Math.floor(damage / 2)), + bonus: Number.isFinite(Number(payload.bonus)) ? Number(payload.bonus) : null, + mode: (payload.mode === 'adv' || payload.mode === 'dis' || payload.mode === 'normal') ? payload.mode : 'normal', + tokenId: tokenId, + tokenName: payload.tokenName || payload.token, + characterId: payload.characterId, + characterName: payload.characterName, + player: payload.player || payload.playerName, + timestamp: sanitizeTimestamp(payload.timestamp, 0) + }; + + lastDamage[playerId] = normalized; + return; + } + + delete lastDamage[playerId]; + }); + + pruneLastDamage(lastDamage); + return lastDamage; + } + + function ensureConcentrationRuntime() { + const runtime = ensureRuntimeObject(modState); + const lastDamage = normalizeLastDamageCache(); + return { runtime, lastDamage }; + } + + // One-time normalization/repair of runtime cache at module init. + // Return value intentionally ignored: this call is for side effects (mutates runtime). + ensureConcentrationRuntime(); + + // ─── Public Command Prefixes ─────────────────────────────────────────────────── + const CMDS = ['!concentration', '!cc']; + + // ─── Marker Helper ────────────────────────────────────────────────────────────── + function getMarker() { + return modState.config.marker || 'Concentrating'; + } + + function getMarkerResolution() { + return resolveMarkerId(getMarker()); + } + + function markerResolutionWarning(resolution) { + const marker = _sanitize(resolution.requested || getMarker()); + const detail = resolution.registryError + ? ` Roll20 marker registry problem: ${_sanitize(resolution.registryError)}.` + : ''; + return `⚠️ Configured concentration marker "${marker}" could not be recognized.${detail}` + + ` Run !token-mod --help-statusmarkers, then use !ga-config set ConcentrationTracker marker=.`; + } + + // ─── 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) { + const resolution = getMarkerResolution(); + if (!resolution.ok) { + GameAssist.log('ConcentrationTracker', markerResolutionWarning(resolution), 'WARN'); + return false; + } + + if (resolution.ambiguous) { + GameAssist.log( + 'ConcentrationTracker', + `Marker "${resolution.requested}" matches multiple custom markers; using ${resolution.id}.`, + 'WARN' + ); + } + + return requestTokenModMarker(token, resolution.id, on, 'ConcentrationTracker'); + } + + /** + * postButtons(recipient) + * Sends the three-button UI for a new concentration check. + */ + function postButtons(recipient) { + const dmg = '?{Damage taken?|0}'; + const buttons = [ + GameAssist.createButton('🎯 Maintain Control', `!concentration --damage ${dmg} --mode normal`), + GameAssist.createButton('🧠 Brace for the Distraction', `!concentration --damage ${dmg} --mode adv`), + GameAssist.createButton('😣 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 resolution = getMarkerResolution(); + if (!resolution.ok) { + return sendChat( + 'ConcentrationTracker', + `/w "${player}" ${markerResolutionWarning(resolution)}` + ); + } + if (!page) { + return sendChat( + 'ConcentrationTracker', + `/w "${player}" ⚠️ Current player page could not be determined. Check !ga-status and try again.` + ); + } + if (resolution.ambiguous) { + GameAssist.log( + 'ConcentrationTracker', + `Marker "${resolution.requested}" matches multiple custom markers; status uses ${resolution.id}.`, + 'WARN' + ); + } + const tokens = findObjs({ + _type: 'graphic', + _pageid: page, + layer: 'objects' + }).filter(t => tokenHasMarker(t, resolution.id)); + 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 buildStatusTemplate() { + const { lastDamage } = ensureConcentrationRuntime(); + const entries = Object.entries(lastDamage || {}); + if (!entries.length) return null; + + const compiled = entries.map(([playerId, payload]) => { + const data = (payload && typeof payload === 'object') + ? payload + : { damage: Number(payload) || 0, mode: 'normal', timestamp: 0 }; + const playerObj = getObj('player', playerId); + const display = data.player || playerObj?.get('displayname') || 'Unknown Player'; + const playerName = display.replace(/ \(GM\)$/, ''); + const damage = Number(data.damage) || 0; + const dc = data.dc ?? Math.max(10, Math.floor(damage / 2)); + const bonus = typeof data.bonus === 'number' ? data.bonus : null; + const mode = data.mode || 'normal'; + const token = data.tokenId ? getObj('graphic', data.tokenId) : null; + const character = data.characterId ? getObj('character', data.characterId) : null; + const tokenName = data.tokenName || token?.get('name') || character?.get('name') || '(Token)'; + const characterName = data.characterName || character?.get('name') || tokenName; + const recorded = data.timestamp ? localTime(data.timestamp) : '—'; + const bonusText = bonus !== null ? (bonus >= 0 ? `+${bonus}` : `${bonus}`) : '—'; + + return { + player: playerName, + info: `${characterName} • DMG ${damage} → DC ${dc} • Bonus ${bonusText} • Mode ${mode} • @ ${recorded}`, + timestamp: data.timestamp || 0 + }; + }); + + compiled.sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0)); + + const rows = compiled.map(row => `{{${_sanitize(row.player)}=${_sanitize(row.info)}}}`); + return `&{template:default} {{name=🧠 Concentration Status}} ${rows.join(' ')}`; + } + + /** + * 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 { lastDamage } = ensureConcentrationRuntime(); + 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 linked = GameAssist.getLinkedCharacter(token); + if (!linked) { + return sendChat('ConcentrationTracker', + `/w "${player}" ⚠️ Token must be on the Objects layer and linked to a character.` + ); + } + + const { character } = linked; + + 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}`; + + lastDamage[msg.playerid] = { + damage, + dc, + bonus, + mode, + tokenId: token.id, + tokenName: name, + characterId: character.id, + characterName: character.get('name'), + player, + timestamp: sanitizeTimestamp(now()) + }; + + pruneLastDamage(lastDamage); + + 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\)$/, ''); + + if (!msg.selected || msg.selected.length === 0) { + sendChat('ConcentrationTracker', `/w "${player}" ⚠️ No tokens selected.`); + return; + } + + const skipped = []; + + msg.selected.forEach(sel => { + const t = getObj('graphic', sel._id); + if (!t) { + skipped.push('(Missing Token)'); + return; + } + + if (!GameAssist.getLinkedCharacter(t)) { + skipped.push(t.get('name') || '(Unnamed)'); + return; + } + + if (!toggleMarker(t, false)) { + skipped.push(t.get('name') || '(Unnamed)'); + } + }); + + let response = '✅ Requested marker removal.'; + if (skipped.length) { + response += ` Skipped: ${skipped.join(', ')}.`; + } + + sendChat('ConcentrationTracker', `/w "${player}" ${response}`); + } + + /** + * handleLast(msg) + * Repeats the last concentration check. + */ + function handleLast(msg) { + const { lastDamage } = ensureConcentrationRuntime(); + const player = msg.who.replace(/ \(GM\)$/, ''); + const entry = lastDamage[msg.playerid]; + const dmg = typeof entry === 'object' ? Number(entry.damage) : Number(entry); + if (!entry || !dmg) { + return sendChat('ConcentrationTracker', + `/w "${player}" ⚠️ No previous damage.` + ); + } + const mode = typeof entry === 'object' && entry.mode ? entry.mode : 'normal'; + handleRoll(msg, dmg, mode); + } + + // ─── 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; + + ensureConcentrationRuntime(); + + // 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.onCommand('!ga-conc-status', () => { + const tpl = buildStatusTemplate(); + if (!tpl) { + GameAssist.log('ConcentrationTracker', 'No concentration activity recorded yet.'); + return; + } + sendChat('ConcentrationTracker', `/w gm ${tpl}`); + }, 'ConcentrationTracker', { gmOnly: true }); + + GameAssist.onEvent('chat:message', handler, 'ConcentrationTracker'); + GameAssist.log( + 'ConcentrationTracker', + `Ready: ${[...CMDS, '!ga-conc-status'].join(' & ')}`, + 'INFO', + { startup: true } + ); +}, { + enabled: true, + events: ['chat:message'], + prefixes: ['!concentration','!cc','!ga-conc-status'], + dependsOn: ['TokenMod'], + teardown: () => { + const page = Campaign().get('playerpageid'); + const marker = (GameAssist.getState('ConcentrationTracker')?.config?.marker) || 'Concentrating'; + const resolution = resolveMarkerId(marker); + if (!resolution.ok) { + GameAssist.log( + 'ConcentrationTracker', + `Teardown could not resolve configured marker "${marker}"; no markers were removed.`, + 'WARN' + ); + return; + } + const targets = findObjs({ _type: 'graphic', _pageid: page, layer: 'objects' }) + .filter(t => tokenHasMarker(t, resolution.id)); + const requested = targets.filter(t => + requestTokenModMarker(t, resolution.id, false, 'ConcentrationTracker') + ).length; + if (requested) { + GameAssist.log('ConcentrationTracker', `Requested concentration-marker removal from ${requested} token(s) during teardown; results will be verified.`); + } + } + }); + // --- Notes & Comments --- + // CHOICE: Keep lowercase parsing and aliases exactly as legacy; avoids user retraining. + // Changed (v0.1.4.7): ConcentrationTracker module_version advanced to 0.1.0.6; marker add/remove/teardown now use TokenMod --api-as with delayed result verification while preserving TokenMod observer delivery to StatusInfo. + // Changed (v0.1.4.3): Resolve configured custom marker names to stored tags for mutation/status/teardown and report unrecognized marker configuration. + // Prior notes: + // Maintenance (v0.1.4.1, no semantic change): Routed lastDamage limits and timestamps through POLICY/shared time seams. + // Changed (v0.1.4): Exact marker matching and GM whisper special-casing honor configured marker ids. + // Maintenance (v0.1.3, no semantic change): Sanitized timestamps, deterministic pruning, and runtime normalization. + // Maintenance (v0.1.3, no semantic change): Added complete lastDamage schema defaults and post-toggle self-healing. + // Maintenance (v0.1.3, no semantic change): Added module narrative; behavior and aliases remained untouched. + // Maintenance (v0.1.1.2, no semantic change): Updated MECHSUITS metadata only. + // [GAMEASSIST:MODULES:CONCENTRATIONTRACKER] END + // ============================================================================= + + // ————— NPC HP ROLLER MODULE v0.1.1.0 ————— + // ============================================================================= + // [GAMEASSIST:MODULES:NPCHPROLLER] BEGIN + // Section Title: NPCHPRoller module + // ------------------------------------------------------------------------- + // mechsuit_section: { codename: "GAMEASSIST", area: "MODULES:NPCHPROLLER", title: "NPCHPRoller", + // guarantees: ["Parse NdM±K and set bar1 to rolled HP"], + // last_updated_version: "v0.1.1.2", + // independent_versions: { module_version: "0.1.1.0" } } + // ------------------------------------------------------------------------- + // Narrative + // MODULES:NPCHPROLLER parses `npc_hpformula`, rolls HP, and writes to bar1 value/max + // without altering defaults. It keeps the legacy dice parsing semantics and bar writes + // while surfacing warnings when formulas are invalid. + // ------------------------------------------------------------------------- + GameAssist.register('NPCHPRoller', function() { + const modState = GameAssist.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 resolveNpcContext(token, { logWarnings = true } = {}) { + if (!token) { + if (logWarnings) { + GameAssist.log('NPCHPRoller', 'Token not found', 'WARN'); + } + return null; + } + + const linked = GameAssist.getLinkedCharacter(token); + if (!linked) { + if (logWarnings) { + GameAssist.log('NPCHPRoller', `${token.get('name') || 'Token'} must be linked to a character on the Objects layer.`, 'WARN'); + } + return null; + } + + const displayName = token.get('name') || linked.character.get('name') || 'Token'; + + const npcAttr = findObjs({ + _type: 'attribute', + _characterid: linked.character.id, + name: 'npc' + })[0]; + + if (!npcAttr || npcAttr.get('current') !== '1') { + if (logWarnings) { + GameAssist.log('NPCHPRoller', `${displayName} is not flagged as an NPC.`, 'WARN'); + } + return null; + } + + const hpFormulaAttr = findObjs({ + _type: 'attribute', + _characterid: linked.character.id, + name: 'npc_hpformula' + })[0]; + + if (!hpFormulaAttr) { + if (logWarnings) { + GameAssist.log('NPCHPRoller', `No HP formula found for ${displayName}`, 'WARN'); + } + return null; + } + + const formula = hpFormulaAttr.get('current'); + const diceData = parseDiceString(formula); + + if (!diceData) { + if (logWarnings) { + GameAssist.log('NPCHPRoller', `Invalid HP formula: ${formula}`, 'WARN'); + } + return null; + } + + return { linked, formula, diceData, displayName }; + } + + function rollTokenHP(token, { logWarnings = true, reason = 'manual' } = {}) { + const context = resolveNpcContext(token, { logWarnings }); + if (!context) return false; + + const hp = rollHP(context.diceData); + + token.set('bar1_value', hp); + token.set('bar1_max', hp); + + const suffix = reason === 'auto' ? ' (auto-roll on add)' : ''; + GameAssist.log('NPCHPRoller', `${context.displayName} HP set to ${hp} using [${context.formula}]${suffix}`); + return true; + } + + GameAssist.onCommand('!npc-hp-all', async msg => { + const pageId = Campaign().get('playerpageid'); + const tokens = findObjs({ + _pageid: pageId, + _type: 'graphic', + layer: 'objects' + }); + + const npcTokens = []; + const skipped = []; + + for (const token of tokens) { + const link = GameAssist.getLinkedCharacter(token); + if (!link) { + skipped.push(token.get('name') || '(Unnamed)'); + continue; + } + + const npcAttr = findObjs({ + _type: 'attribute', + _characterid: link.character.id, + 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'); + } + } + + if (skipped.length) { + GameAssist.log('NPCHPRoller', `Skipped ${skipped.length} token(s) without linked characters: ${skipped.join(', ')}`, 'WARN'); + } + }, 'NPCHPRoller', { gmOnly: true }); + + GameAssist.onCommand('!npc-hp-selected', msg => { + if (!msg.selected || msg.selected.length === 0) { + GameAssist.log('NPCHPRoller', 'No tokens selected', 'WARN'); + return; + } + + const skipped = []; + + msg.selected.forEach(sel => { + const token = getObj('graphic', sel._id); + if (!token) { + skipped.push('(Missing Token)'); + return; + } + + if (!GameAssist.getLinkedCharacter(token)) { + skipped.push(token.get('name') || '(Unnamed)'); + return; + } + + try { + rollTokenHP(token); + } catch (err) { + GameAssist.log('NPCHPRoller', `Error processing ${token.get('name')}: ${err.message}`, 'ERROR'); + } + }); + + if (skipped.length) { + GameAssist.log('NPCHPRoller', `Skipped ${skipped.length} token(s): ${skipped.join(', ')}`, 'WARN'); + } + }, 'NPCHPRoller', { gmOnly: true }); + + GameAssist.onEvent('add:graphic', token => { + if (!modState.config.autoRollOnAdd) return; + rollTokenHP(token, { logWarnings: false, reason: 'auto' }); + }, 'NPCHPRoller'); + + GameAssist.log('NPCHPRoller', 'v0.1.1.0 Ready: !npc-hp-all, !npc-hp-selected', 'INFO', { startup: true }); +}, { + enabled: true, + events: ['add:graphic'], + prefixes: ['!npc-hp-all', '!npc-hp-selected'] +}); + // --- Notes & Comments --- + // CHOICE: Use Math.random for simplicity; acceptable for non‑critical HP rolls. + // Maintenance (v0.1.3, no semantic change): Added module narrative and aligned version + // metadata; HP rolling behavior unchanged. + // Prior notes: Maintenance (v0.1.1.2, no semantic change): MECHSUITS metadata updated for compliance. + // [GAMEASSIST:MODULES:NPCHPROLLER] END + // ============================================================================= + + // ————— DEBUG TOOLS MODULE v0.1.0 ————— + // ============================================================================= + // [GAMEASSIST:MODULES:DEBUGTOOLS] BEGIN + // Section Title: DebugTools module + // ------------------------------------------------------------------------- + // mechsuit_section: { codename: "GAMEASSIST", area: "MODULES:DEBUGTOOLS", title: "DebugTools", + // guarantees: ["Dry-run friendly debugging helpers"], + // depends_on: ["[GAMEASSIST:APP:UTILS]","[GAMEASSIST:CORE:OBJECT]"], + // last_updated_version: "v0.1.4.1", + // independent_versions: { module_version: "0.1.0" } } + // ------------------------------------------------------------------------- + // Narrative + // MODULES:DEBUGTOOLS offers optional GM-only diagnostics for damage, markers, and + // saves. It remains disabled by default, defaulting to dry-run behavior until + // explicitly applied. + // ------------------------------------------------------------------------- + GameAssist.register('DebugTools', function() { + const modState = GameAssist.getState('DebugTools'); + Object.assign(modState.config, { + enabled: false, + ...modState.config + }); + + function ensureDebugRuntime() { + return ensureRuntimeObject(modState); + } + + function wantsApply(args) { + if (args.apply === undefined) return false; + if (args.apply === false) return false; + if (typeof args.apply === 'string') { + return args.apply.toLowerCase() !== 'false'; + } + return Boolean(args.apply); + } + + function getTokenFromArgs(msg, args) { + let tokenId = null; + if (typeof args.token === 'string') tokenId = args.token; + else if (Array.isArray(args.token) && args.token.length) tokenId = args.token[0]; + + if (!tokenId && msg.selected?.length) { + tokenId = msg.selected[0]._id; + } + + if (!tokenId) return null; + + const token = getObj('graphic', tokenId); + if (!token) { + GameAssist.log('DebugTools', `Token ${tokenId} not found.`, 'WARN'); + return null; + } + if (token.get('layer') !== 'objects') { + GameAssist.log('DebugTools', 'Token must be on the Objects layer.', 'WARN'); + return null; + } + return token; + } + + function handleDamage(msg, args) { + const token = getTokenFromArgs(msg, args); + if (!token) { + GameAssist.log('DebugTools', 'Select a token or pass --token for damage tests.', 'WARN'); + return; + } + + const amountRaw = args.amount ?? args.damage ?? args.value; + const amount = Number(amountRaw); + if (!Number.isFinite(amount) || amount <= 0) { + GameAssist.log('DebugTools', 'Provide --amount greater than zero.', 'WARN'); + return; + } + + const current = Number(token.get('bar1_value')) || 0; + const next = Math.max(0, current - amount); + const name = _sanitize(token.get('name') || token.id); + const summary = `${name}: HP ${current} → ${next} (-${amount})`; + + if (!wantsApply(args)) { + GameAssist.log('DebugTools', `Dry run — would apply ${summary}. Add --apply to commit.`); + return; + } + + token.set('bar1_value', next); + GameAssist.log('DebugTools', `Applied ${summary}.`); + ensureDebugRuntime().lastAction = { type: 'damage', token: token.id, amount, previous: current }; + } + + function handleMarker(msg, args) { + const token = getTokenFromArgs(msg, args); + if (!token) { + GameAssist.log('DebugTools', 'Select a token or pass --token for marker tests.', 'WARN'); + return; + } + + const markerRaw = args.marker ?? args.status; + const marker = (markerRaw ? String(markerRaw) : 'blue').trim(); + if (!marker) { + GameAssist.log('DebugTools', 'Provide --marker .', 'WARN'); + return; + } + + const modeRaw = args.state ?? args.mode ?? args.action; + const mode = modeRaw ? String(modeRaw).toLowerCase() : 'toggle'; + const markers = (token.get('statusmarkers') || '').split(',').filter(Boolean); + const hasMarker = tokenHasMarker(token, marker); + let finalMarkers = markers.slice(); + let actionDesc; + + if (mode === 'on' || mode === 'add') { + if (!hasMarker) finalMarkers.push(marker); + actionDesc = `add ${marker}`; + } else if (mode === 'off' || mode === 'remove' || mode === 'clear') { + finalMarkers = markers.filter(m => normalizeMarkerId(m) !== normalizeMarkerId(marker)); + actionDesc = `remove ${marker}`; + } else { + if (hasMarker) finalMarkers = markers.filter(m => normalizeMarkerId(m) !== normalizeMarkerId(marker)); + else finalMarkers.push(marker); + actionDesc = `${hasMarker ? 'remove' : 'add'} ${marker}`; + } + + const name = _sanitize(token.get('name') || token.id); + if (!wantsApply(args)) { + GameAssist.log('DebugTools', `Dry run — would ${actionDesc} on ${name}. Add --apply to commit.`); + return; + } + + token.set('statusmarkers', finalMarkers.join(',')); + GameAssist.log('DebugTools', `Marker action: ${actionDesc} on ${name}.`); + ensureDebugRuntime().lastAction = { type: 'marker', token: token.id, marker, mode }; + } + + function handleSave(msg, args) { + const dcRaw = args.dc ?? args.target; + const dc = Number(dcRaw); + if (!Number.isFinite(dc)) { + GameAssist.log('DebugTools', 'Provide --dc for save tests.', 'WARN'); + return; + } + + const bonusRaw = args.bonus ?? args.mod ?? 0; + const bonus = Number(bonusRaw) || 0; + const modeRaw = args.mode ?? args.roll ?? ''; + const mode = typeof modeRaw === 'string' ? modeRaw.toLowerCase() : ''; + let expr = `1d20 + ${bonus}`; + let descriptor = 'normal'; + if (mode.startsWith('adv')) { + expr = `2d20kh1 + ${bonus}`; + descriptor = 'advantage'; + } else if (mode.startsWith('dis')) { + expr = `2d20kl1 + ${bonus}`; + descriptor = 'disadvantage'; + } + + if (!wantsApply(args)) { + GameAssist.log('DebugTools', `Dry run — would roll ${expr} vs DC ${dc} (${descriptor}). Add --apply to execute.`); + return; + } + + const label = args.label ? _sanitize(String(args.label)) : 'Debug Save'; + sendChat('', `[[${expr}]]`, ops => { + const roll = ops?.[0]?.inlinerolls?.[0]; + if (!roll) { + GameAssist.log('DebugTools', 'Save roll failed.', 'WARN'); + return; + } + const total = roll.results.total; + const success = total >= dc; + const outcome = success ? '✅ Success' : '❌ Failure'; + const template = `&{template:default} {{name=${label}}} {{Result=${total} vs DC ${dc}}} {{Outcome=${outcome} (${descriptor})}}`; + sendChat('DebugTools', `/w gm ${template}`); + GameAssist.log('DebugTools', `Rolled ${total} vs DC ${dc} (${descriptor}). ${success ? 'Success' : 'Failure'}.`); + }); + } + + function showHelp() { + GameAssist.log('DebugTools', [ + 'Debug helpers:', + '• !ga-debug damage --amount N [--token TOKENID|select] [--apply]', + '• !ga-debug marker --marker status [--state on|off|toggle] [--token TOKENID|select] [--apply]', + '• !ga-debug save --dc N [--bonus M] [--mode normal|adv|dis] [--label Text] [--apply]' + ].join('\n')); + } + + const HANDLERS = { + damage: handleDamage, + marker: handleMarker, + save: handleSave + }; + + GameAssist.onCommand('!ga-debug', msg => { + const payload = msg.content.replace(/^!ga-debug\s*/i, ''); + if (!payload) { + showHelp(); + return; + } + + const parsed = _parseArgs(payload); + const action = (parsed.cmd || '').toLowerCase(); + const handler = HANDLERS[action]; + if (!handler) { + GameAssist.log('DebugTools', `Unknown debug action: ${_sanitize(action || '(none)')}`, 'WARN'); + showHelp(); + return; + } + handler(msg, parsed.args || {}); + }, 'DebugTools', { gmOnly: true }); + + GameAssist.log('DebugTools', 'Debug module registered. Enable with !ga-enable DebugTools when needed.', 'INFO', { startup: true }); + }, { + enabled: false, + prefixes: ['!ga-debug'] + }); + // --- Notes & Comments --- + // CHOICE: Helpers default to dry-run; --apply required for mutations. + // Changed (v0.1.4.1): Marker diagnostics now use exact shared marker normalization, including counted markers. + // Prior notes: + // Changed (v0.1.3): Ensured runtime self-heals before recording lastAction. + // Maintenance (v0.1.3, no semantic change): Kept dry-run defaults and disabled-by-default posture. + // Maintenance (v0.1.1.2, no semantic change): MECHSUITS metadata refreshed to v1.5.1. + // [GAMEASSIST:MODULES:DEBUGTOOLS] END + // ============================================================================= + + // --- Notes & Comments --- + // Changed (v0.1.4.3): Updated wrapper contract because marker-dependent modules now resolve configured marker identity before read/write operations. + // Prior notes: + // Maintenance (v0.1.3, no semantic change): Added MODULES wrapper to group bundled features under a parent section for MECHSUITS parent/child compliance. + // [GAMEASSIST:MODULES] END + // ============================================================================= + + // ————— BOOTSTRAP ————— + // ============================================================================= + // [GAMEASSIST:BOOTSTRAP] BEGIN + // Section Title: Sandbox ready bootstrap + // ------------------------------------------------------------------------- + // mechsuit_section: { codename: "GAMEASSIST", area: "BOOTSTRAP", title: "Bootstrap", + // guarantees: ["Repair known state, seed defaults, diagnose dependencies, preserve configured intent when dependencies prevent startup, init enabled modules"], + // depends_on: ["[GAMEASSIST:APP:UTILS]","[GAMEASSIST:CORE]","[GAMEASSIST:MODULES]"], + // last_updated_version: "v0.1.4.7", lifecycle: "active" } + // ------------------------------------------------------------------------- + // Narrative + // BOOTSTRAP runs at sandbox ready: repairs known state containers, seeds defaults, + // audits compatibility, diagnoses dependencies, and initializes enabled modules. + // Deliberately disabled modules remain quiet. Missing dependencies skip configured + // modules without rewriting the DM's enabled setting; unverifiable dependencies warn and proceed. + // ------------------------------------------------------------------------- + R20_ON('ready', () => { + if (READY) return; + READY = true; + + const root = ensureStateRoot(); + GameAssist.config = root.config; + if (!root.metrics.sessionStart) { + root.metrics.sessionStart = isoNow(); + } + recordMetric('system', { mod: 'Core', note: 'Sandbox ready' }); + + GameAssist._clearAllListeners(); + auditState(); + seedDefaults(); + GameAssist._dedupePlanned(); + auditCompatibility(); + + const moduleNames = Object.entries(MODULES) + .filter(([, mod]) => !mod.internal) + .map(([name]) => name); + + GameAssist.log('Core', `GameAssist v${VERSION} ready; modules: ${moduleNames.join(', ')}`); + + moduleNames.forEach(name => { + const mod = MODULES[name]; + const cfg = getState(name).config; + + // CHOICE: Honor the DM's disabled setting before dependency checks so optional modules stay quiet. + if (!cfg.enabled) { + mod.initialized = false; + mod.active = false; + return; + } + + const depInfo = GameAssist._checkDependencies(name); + if (depInfo.status === 'missing') { + GameAssist.log('Core', `${name} skipped (missing dependencies: ${depInfo.missing.join(', ')})`, 'WARN'); + // DANGER: Do not set cfg.enabled=false here; that would erase configured intent and hide the startup failure from !ga-status. + mod.initialized = false; + mod.active = false; + return; + } + if (depInfo.status === 'unverifiable') { + GameAssist.log('Core', `${name} dependencies unverifiable (${depInfo.unverifiable.join(', ')}); proceeding without confirmation.`, 'WARN'); + } + + try { + if (!mod.wired) { + mod.initFn(); + mod.wired = true; + } + mod.initialized = true; + mod.active = true; + } catch (e) { + mod.initialized = false; + mod.active = false; + GameAssist.handleError(name, e); + } + }); + + GameAssist._metrics.lastUpdate = isoNow(); + }); + // --- Notes & Comments --- + // CHOICE: Core ready log is never suppressed; mirrors README guidance. + // CHOICE: Repair existing known branches before seeding defaults so valid configuration survives. + // Changed (v0.1.4.7): Startup reports the v0.1.4.7 standalone-interoperability release; lifecycle order and dependency-skip behavior are unchanged. + // Changed (v0.1.4.6): Check configured intent before dependency diagnostics and preserve enabled configuration when a confirmed missing dependency skips startup; this keeps deliberate disables quiet while making skipped modules visible to status reporting. + // Decision log: + // CHOICE: Preserve cfg.enabled for dependency-skipped startup modules - ALT: force-disable config; REJECTED: erased DM intent and caused !ga-status to conceal the missing dependency. + // Prior notes: + // Changed (v0.1.4.2): Repair known state before default seeding and report confirmed/missing/unverifiable dependencies during startup. + // Maintenance (v0.1.4.1, no semantic change): Routed bootstrap timestamps through the wall-clock seam; order unchanged. + // Maintenance (v0.1.3, no semantic change): Added bootstrap narrative; preserved ready flow and dependency checks. + // Maintenance (v0.1.1.2, no semantic change): MECHSUITS metadata fields refreshed for compliance. + // [GAMEASSIST:BOOTSTRAP] END + // ============================================================================= + +})(); diff --git a/GameAssist/README.md b/GameAssist/README.md index 9ac479030b..54405c4968 100644 --- a/GameAssist/README.md +++ b/GameAssist/README.md @@ -1,42 +1,48 @@ +# GameAssist – Modular API Framework for Roll20 -# 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) +**Version 0.1.4.7** | © 2025 Mord Eagle · MIT License
+**Lead Dev:** [@Mord-Eagle](https://github.com/Mord-Eagle) + +> **Release posture:** v0.1.4.7 completes the planned standalone-interoperability pass for TokenMod and StatusInfo. Marker changes still belong to separately installed TokenMod; GameAssist now uses TokenMod's documented script-to-script authorization path, verifies requested results, and reports detected standalone versions in troubleshooting details. --- ## 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. +GameAssist is a **modular Roll20 Mod/API framework**: one script that supplies a small shared kernel and six bundled modules—ConfigUI, CritFumble, ConcentrationTracker, NPCManager, NPCHPRoller, and DebugTools. It provides guarded module lifecycle controls, direct command and event routing, an explicit opt-in queue for work that truly requires serialization, persistent metrics, conservative state self-healing, and best-effort compatibility diagnostics. It is designed to make common campaign automation easier to operate and easier to extend without pretending the Roll20 sandbox can cancel running JavaScript, perfectly discover every installed Mod, or automatically repair every possible state problem. --- ## 1 · TL;DR Cheat Sheet | 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. | - -> **Required Roll-Tables:** CF-Melee, CF-Ranged, CF-Thrown, CF-Spell, CF-Natural, Confirm-Crit-Martial, Confirm-Crit-Magic - +| --- | --- | +| Core Lift | Guarded modules, conservative state repair, explicit queue API, session metrics, dependency diagnostics, and GM health reporting. | +| Quick Install | ① Install **GameAssist** from Roll20 One-Click, or paste **GameAssist-v0.1.4.7.js** manually ② Install **TokenMod** for marker modules ③ Optionally install **StatusInfo** for condition descriptions ④ Add the seven CritFumble roll-tables ⑤ Save/reload and run `!ga-status`. | +| Flagship Player Commands | `!concentration`, `!cc`, `!critfumble-`. | +| Flagship GM Commands | `!critfumble`, `!critfumble help`, `!critfumble menu`, `!critfail`, `!npc-hp-all`, `!npc-hp-selected`, `!npc-death-report --help`, `!npc-death-buckets`, `!NPC-WR`, `!npc-death-audit`, `!npc-death-arc`, `!ga-conc-status`, `!ga-config ui`. | +| Admin Controls | `!ga-config list|get|set|modules|cleanup|ui`, `!ga-enable`, `!ga-disable`, `!ga-status`, `!ga-metrics`, and `!ga-debug`. | +| Queue Model | Normal commands/events run directly. Only `GameAssist.enqueue(...)` work and module transitions use the serialized queue. | +| Watchdog Limit | A timeout releases the explicit queue; it **cannot** terminate underlying JavaScript, `sendChat()`, or Roll20 operations. | +| State Safety | Repairs malformed known module containers while preserving valid config; unexpected branches warn until the GM explicitly runs cleanup. | +| Dependency Safety | Reports dependencies as `confirmed`, `missing`, or `unverifiable`; detection is best-effort. | +| Backup Utility | `!ga-config list` writes a versioned **configuration-only** snapshot. It is not a full-state backup and cannot yet be imported. | + +> `!ga-debug` requires `!ga-enable DebugTools`. DebugTools is GM-only, disabled by default, and dry-run by default. + +> **Required CritFumble Roll-Tables:** `CF-Melee`, `CF-Ranged`, `CF-Thrown`, `CF-Spell`, `CF-Natural`, `Confirm-Crit-Martial`, and `Confirm-Crit-Magic`. --- ## 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) +> 3. [Overview](#3-overview) 4. [Quick Start](#4-quick-start) 5. [Deep-Dive Architecture](#5-deep-dive-architecture) 6. [Module Guides](#6-module-guides) -> 11. [Roll-Table Cookbook](#11-roll-table-cookbook) 12. [Macro Recipes](#12-macro-recipes) 13. [Performance Benchmarks](#13-performance-benchmarks) +> 7. [Installation](#7-installation) 8. [Command Matrix](#8-command-matrix) 9. [Configuration Keys](#9-configuration-keys) 10. [Developer API](#10-developer-api) -> 14. [Troubleshooting](#14-troubleshooting) 15. [Upgrade Paths](#15-upgrade-paths) 16. [Contributing](#16-contributing) +> 11. [Roll-Table Cookbook](#11-roll-table-cookbook) 12. [Macro Recipes](#12-macro-recipes) 13. [Performance Benchmarks](#13-performance-benchmarks) + +> 14. [Troubleshooting](#14-troubleshooting) 15. [Upgrade Paths](#15-upgrade-paths) 16. [Contributing](#16-contributing) > 17. [Roadmap](#17-roadmap) 18. [Changelog](#18-changelog) 19. [Glossary](#19-glossary) @@ -44,44 +50,162 @@ GameAssist is a **Roll20 API modular Framework**: one script that drops into you ## 3 · Overview -GameAssist’s micro-kernel wraps the Roll20 event bus and exposes: +GameAssist’s kernel and bundled modules expose: -* **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. +* **Direct Event & Command Routing** – normal Roll20 events and API commands execute directly through guarded handlers. GameAssist captures Roll20’s native `on` function once and does not replace global `on` or `off`. +* **Explicit Task Queue** – future modules may submit selected work through `GameAssist.enqueue(...)` when serialized execution is genuinely useful. +* **Queue Watchdog** – observes the explicit queue and releases it after stalled jobs time out. It cannot kill the timed-out operation itself. +* **State Manager** – stores namespaced module data under `state.GameAssist.` and repairs missing or malformed known `config` and `runtime` containers. +* **State Auditor** – warns about unknown branches without deleting them automatically. The GM chooses whether to remove them with `!ga-config cleanup`. +* **Metrics Board** – records command, event, queue, error, toggle, and audit activity. View current health with `!ga-status` and persisted session details with `!ga-metrics`. +* **Guarded Module Toggles** – `!ga-enable` and `!ga-disable` control module activity without depending on a Roll20 `off()` API. +* **Compatibility Audit** – optional, debug-only overlap hints for popular scripts such as TokenMod, ScriptCards, and APILogic. +* **Dependency Diagnostics** – module dependencies are reported as confirmed, missing, or unverifiable instead of being presented as guaranteed discoveries. +* **Standalone Interoperability** – TokenMod's public API contract and version metadata can confirm its presence even when Roll20's internal script list is unavailable. Marker requests use TokenMod's documented `--api-as` path and are checked after dispatch. +* **MECHSUITS Structure** – the executable script uses the literal codename `GAMEASSIST`, framed sections, file-scoped canonical tree metadata, and per-section change notes. -Design goal: **zero GM downtime**. +**Design goal:** useful, inspectable campaign automation that reports failures clearly and can be upgraded incrementally. --- ## 4 · Quick Start ```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 -```` +📥 1 Install GameAssist from One-Click, or copy GameAssist-v0.1.4.7.js into Roll20 Mod/API Scripts +🛠 2 Install TokenMod 0.8.88 (supported baseline) for NPCManager or ConcentrationTracker markers +ℹ️ 3 Optionally install StatusInfo 0.3.11 for condition descriptions and menus +📜 4 Create 7 CritFumble roll-tables (see §11: Roll-Table Cookbook) +🔄 5 Save/reload the sandbox and wait for the core ready whisper +🩺 6 Run !ga-status and !ga-config modules +🎲 7 Test !critfumble menu, !concentration --status, and !npc-hp-selected +``` + +`GameAssist.flags.QUIET_STARTUP` defaults to `true`. Expect the core ready whisper, but not one ready message from every module. + +### 4.1 Minimum Smoke Test + +Run these commands after every update: + +```roll20chat +!ga-status +!ga-config modules +!ga-config list +!ga-metrics +!critfumble menu +!concentration --status +!npc-death-help +!npc-death-report +!npc-death-buckets +!npc-death-audit +!npc-hp-selected +``` + +Then perform four real actions: + +1. Drop a linked NPC below 1 HP and verify the death marker appears. +2. Raise that NPC above 0 HP and verify the marker clears. +3. Run a real concentration check. +4. Disable and re-enable one module. --- ## 5 · Deep-Dive Architecture -### 5.1 Event Pipeline +### 5.1 Runtime Pipeline + +Normal Roll20 traffic follows a direct, guarded route: + +```text +Roll20 event or API chat command + ↓ +GameAssist command/event wrapper + ↓ +Module initialized + active guard + ↓ +ACL / GM-only / command-boundary checks + ↓ +Module handler executes directly + ↓ +Metrics and error reporting +``` + +Serialized work is separate and explicit: -Every inbound Roll20 event is wrapped so the kernel can tally metrics, enforce ACLs and guarantee FIFO execution with a timeout watchdog. +```text +Module calls GameAssist.enqueue(task, options) + ↓ +Priority-sorted explicit queue + ↓ +Task runs until complete or timeout + ↓ +Queue advances to the next task +``` -### 5.2 Fail-Safe Scenarios +Module enable/disable transitions also use the internal queue to prevent overlapping lifecycle changes. -| 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. | +### 5.2 Why Normal Events Are Not Queued + +Roll20 event handlers often perform small, immediate checks. Automatically routing every event through one queue would add latency, increase coupling, and create a single congestion point. In v0.1.4.x, ordinary handlers remain direct; modules opt into serialization only when their own work requires it. + +### 5.3 Fail-Safe Scenarios + +| Scenario | GameAssist Response | Important Limit | +| --- | --- | --- | +| Uncaught exception in a guarded module handler | Records an error and whispers the GM. Other handlers can continue. | It cannot repair arbitrary module logic. | +| Explicit queued task exceeds its timeout | Logs the timeout and releases the queue for later work. | It cannot cancel the underlying JavaScript or Roll20 operation. | +| Explicit queue remains busy beyond watchdog threshold | Watchdog releases the busy queue state and records a warning. | The original operation may still finish later. | +| Known module branch lacks valid `config` or `runtime` containers | Repairs the malformed containers while preserving valid configuration values. | It does not infer arbitrary missing custom values. | +| Unknown `state.GameAssist` branch is found | Warns and leaves it untouched. | Removal requires `!ga-config cleanup`. | +| Required external dependency is confirmed missing | Skips startup, preserves the DM's enabled setting, and reports the configured module as needing attention; a later manual enable is refused without changing the setting, while `!ga-disable` can still turn off the inactive module. | Discovery depends on metadata Roll20 exposes. | +| Dependency cannot be verified | Warns and proceeds without confirmation. | The GM must confirm the dependency manually. | + +### 5.4 Persistent State Shape + +```text +state.GameAssist +├─ config +├─ flags +├─ metrics +├─ ConfigUI +│ ├─ config +│ └─ runtime +├─ CritFumble +│ ├─ config +│ └─ runtime +├─ NPCManager +│ ├─ config +│ └─ runtime +├─ ConcentrationTracker +│ ├─ config +│ └─ runtime +├─ NPCHPRoller +│ ├─ config +│ └─ runtime +└─ DebugTools + ├─ config + └─ runtime +``` + +Module configuration belongs under `state.GameAssist..config`. Runtime caches belong under the matching module’s `runtime` object. + +### 5.5 Configuration Snapshot Shape + +`!ga-config list` writes a `GameAssist Config` handout containing: + +```json +{ + "format": "gameassist-config-snapshot", + "schemaVersion": 1, + "scope": "configuration-only", + "generatedAt": "", + "version": "0.1.4.7", + "flags": {}, + "globalConfig": {}, + "modules": {} +} +``` + +The snapshot excludes runtime caches and metrics. v0.1.4.7 does not import or restore snapshots. --- @@ -89,473 +213,1097 @@ Every inbound Roll20 event is wrapped so the kernel can tally metrics, enforce A ### 6.1 CritFumble -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 watches common attack and damage roll templates for a natural 1 and offers a player-targeted fumble menu. Calling `!critfumble menu` opens the guided Natural 1 dialogue; `!critfail` opens the direct GM-facing player picker. -* Helper commands: `!critfumble help`, `!critfumble-`, `!critfumblemenu-`, `!Confirm-Crit-Martial`, `!Confirm-Crit-Magic`. -* Config: `debug`, `useEmojis`, `rollDelayMs`. +Recognized templates include: -### 6.2 Concentration Tracker -*(Requires TokenMod API for automated marker/status integration.)* +```text +atk, atkdmg, npcatk, npcfullatk, npcaction, spell, simple, dmg, default +``` -`!concentration` or the alias `!cc` opens buttons for normal/adv/dis rolls or takes flags: +Commands: +* `!critfumble` / `!critfumble help` → Whisper a quick reference with setup table names and a button to open the guided menu. +* `!critfumble menu` → Whisper the guided Natural 1 dialogue with player-picker, direct-roll, and confirm-roll buttons. +* `!critfail` → Open the direct manual player picker. +* `!critfumble-melee|ranged|thrown|spell|natural` → Roll the selected fumble table. +* `!confirm-crit-martial` / `!confirm-crit-magic` → Roll the matching confirmation table. + +Internal player-targeted button syntax: + +```text +!critfumblemenu --pid ``` - - "• --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" -``` + +Config keys: `debug`, `useEmojis`, `rollDelayMs`. + +### 6.2 Concentration Tracker + +> **Dependency:** Standalone TokenMod is required for automated marker changes. v0.1.4.7 is tested against TokenMod `0.8.88`; StatusInfo `0.3.11` is optional. + +`!concentration` or `!cc` opens buttons for normal, advantage, or disadvantage rolls and accepts: + +* `--help` → Whisper the help panel. +* `--damage N` → Roll against DC `max(10, floor(N / 2))`. +* `--mode normal|adv|dis` → Choose roll mode. +* `--last` → Repeat the player’s last recorded check. +* `--off` → Remove the configured marker from selected tokens. +* `--status` → List tokens currently carrying the configured marker. +* `--config randomize on|off` → Toggle emote randomization. +* `!ga-conc-status` → GM-only snapshot of the most recent concentration DC and damage per player. + +The tracker reads `constitution_save_bonus` from a token’s represented character. Runtime `lastDamage` data self-heals and accepts legacy number entries. + +In v0.1.4.3, built-in marker ids, custom marker display names, and exact custom tags resolve to the marker identity Roll20 stores on tokens. If the configured marker cannot be recognized, `!concentration --status` gives an actionable warning instead of silently reporting an incorrect empty result. + +In v0.1.4.7, concentration add/remove/teardown requests use TokenMod's documented `--api-as` path and are checked after TokenMod runs. The GameAssist request therefore does not depend on TokenMod's player-facing `players-can-ids` setting. If the marker still does not reach the requested state, the GM receives a direct TokenMod command to try on the selected token. GameAssist continues using TokenMod for the mutation so standalone StatusInfo can observe the same change. Config keys: `marker`, `randomize`. ### 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`. +> **Dependency:** Standalone TokenMod is required for automated death-marker changes. Death-history recording and handout updates still run even if a marker write cannot be confirmed. + +> **Module version:** NPCManager `1.1.1` in GameAssist v0.1.4.7. NPCManager `1.0.0` introduced the four-level history model; `1.1.0` added curated Arc management, hierarchical clearing, date rollover, and the report writer; `1.1.1` hardens standalone TokenMod marker requests, result verification, and new-token HP initialization. + +NPCManager watches `change:graphic:bar1_value` for linked NPC characters with `npc=1`. + +* HP below 1 → record the NPC death into the active Campaign, Chapter, Section, and Session buckets, then request the configured `deadMarker`. +* HP above 0 → annotate the matching death entry as revived and request removal of the configured `deadMarker`. +* `autoHide=true` → move newly dead NPC tokens to `hideLayer`. + +When NPCHPRoller `autoRollOnAdd=true`, NPCManager treats the short placeholder-HP interval on a newly added token as setup rather than combat. Blank or unknown starting HP is not accepted as evidence that a living NPC crossed below 1 HP. The automatic roll therefore does not flash the death marker or add a false death/revival pair to history; later known-positive-to-zero changes remain ordinary tracked deaths. + +Commands: + +* `!npc-death-report` → Show the active Session bucket summary. +* `!npc-death-report --scope campaign|chapter|section|session` → View a different active bucket. +* `!npc-death-report --recent` → Show the newest recorded death events for the selected bucket. +* `!npc-death-report --page N` → Page through older recorded death events for the selected bucket. +* `!npc-death-report --write` → Open the report writer without immediately changing a handout. +* `!npc-death-report --help` or `!npc-death-help` → Open the central NPCManager guide for setup, reports, clearing, audits, and Arcs. +* `!npc-death-buckets` → Show active bucket names, counts, report buttons, and rename buttons. +* `!npc-death-buckets --campaign "Name" --chapter "Name" --section "Name" --session "Name"` → Set retained active bucket names. +* `!npc-death-clear --scope session` → Ask for confirmation before clearing the selected active bucket. Defaults to Session. +* `!npc-death-clear --scope session --confirm` → Clear only that active bucket. +* `!npc-death-clear --scope section --nested --confirm` → Clear the active Section and Session while retaining Chapter and Campaign. The same rule applies to other parent levels. +* `!NPC-WR` or `!npc-death-write` → Open the report writer. +* `!npc-death-write --all` → Update all four active handouts. +* `!npc-death-write --scope section` → Update one active handout. +* `!npc-death-write --newSection "Name"` → Start/resume a Section and seed it with only missing deaths from the current Session. +* `!npc-death-audit` → Check the current player page for HP/death-marker mismatches and update the `GameAssist NPC Death Audit` handout. +* `!npc-death-arc` → Show arc bucket help and current arc counts. +* `!npc-death-arc --name "Arc Name"` → Add selected linked PC/NPC tokens to that arc handout. +* `!npc-death-arc --name "Arc Name" --session` → Append current Session bucket deaths to that arc handout. +* `!npc-death-arc --name "Arc Name" --manage` → Open removal, selected-token removal, undo, and Session-import controls. +* `!npc-death-arc --name "Arc Name" --session --allowDuplicates` → Intentionally add repeated entries; ordinary additions deduplicate by creature. + +`!npc-death-report` is a history report. It opens with totals, the latest death, most frequent names, recent entries, and buttons for common next steps. Every new death is written to all four active buckets. A clear confirmation offers either the selected bucket alone or that level and its descendants; for example, clearing Section and below clears Section and Session while retaining Chapter and Campaign. Each bucket has its own handout named like `GameAssist Deaths - Session - 2026-07-17`. Revivals are annotated on the matching entry instead of silently deleting the death. Current entries are matched by token ID, so separate tokens with the same name remain separate records. + +The default Session name follows the sandbox's UTC date. Before any NPCManager command or tracked NPC HP change, GameAssist checks the date and moves a date-managed Session to the new `YYYY-MM-DD` bucket. No death processed after that check is written into yesterday's Session. If the DM explicitly names the Session, that custom name remains active across date changes; **Reset Session Date** restores automatic date-managed rollover. A DM-configurable timezone is tracked separately because v0.1.4.7 does not yet reinterpret stored timestamps or date boundaries. + +Arc handouts are curated rosters, not another hierarchy level. A linked creature appears once per Arc by default, so adding selected NPCs and later importing the full Session does not repeat those creatures. The Session import can enrich an existing selected entry with its death record. The management menu can remove one entry, remove all selected tokens, or undo the most recent Arc addition. `--allowDuplicates` is an explicit override for deliberate repetition. Selected-token Arc entries remain general story notes; revival annotations apply only after an entry is linked to Session death history. + +`!npc-death-audit` is the mismatch checker. Chat shows a summary plus bounded, token-specific **Add Death Marker** and **Remove Death Marker** groups. The complete list is written to the `GameAssist NPC Death Audit` handout. The audit checks linked NPC tokens on the current player page; player characters are not included. A clean audit means linked NPC tokens have death markers that match their HP. The audit may also note ignored unlinked page items such as party markers, scenery, labels, or props. + +Disabling NPCManager stops its automation and requests removal of its configured marker from qualifying current-page tokens. Saved Campaign, Chapter, Section, Session, and Arc records remain available after the module is enabled again. Use the NPCManager clear and Arc-management controls when history should actually be removed. + +Config keys: `autoTrackDeath`, `deadMarker`, `autoHide`, `hideLayer`. ### 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). +> **Dependency:** NPCHPRoller does **not** require TokenMod. + +NPCHPRoller reads `npc=1` and `npc_hpformula` from linked characters, parses `NdM+K` or `NdM-K`, and writes the result to token `bar1_value` and `bar1_max`. + +* `!npc-hp-selected` → Roll HP for qualifying selected NPC tokens. +* `!npc-hp-all` → Roll HP for qualifying NPC tokens on the current player page. +* `autoRollOnAdd=true` → Quietly attempt HP rolling when a qualifying NPC token is added. + +Invalid, unlinked, and PC tokens are skipped. + +Config key: `autoRollOnAdd`. + +### 6.5 Config UI + +`!ga-config ui` or `!ga-config-ui` whispers a GM-only chat control panel. Each module card can show: + +* Current enabled/disabled status with a one-click toggle. +* Boolean configuration keys as chat buttons. +* A brief configuration summary. +* Previous, refresh, and next pagination controls. + +Config keys: `pageSize`, `showSummaries`. + +Disable ConfigUI if you prefer command-only administration. + +### 6.6 Debug Tools *(GM-only)* + +DebugTools is disabled by default and remains dry-run unless `--apply` is present: + +* `!ga-debug damage --amount 12 [--token TOKENID] [--apply]` +* `!ga-debug marker --marker statusname [--state on|off|toggle] [--token TOKENID] [--apply]` +* `!ga-debug save --dc 15 [--bonus 3] [--mode adv|dis|normal] [--label "Text"] [--apply]` + +To act on the currently selected token, omit `--token`. Literal `--token select` is not supported. + +Typical session: + +```roll20chat +!ga-enable DebugTools +!ga-debug marker --marker dead --state toggle +!ga-debug marker --marker dead --state toggle --apply +!ga-disable DebugTools +``` --- ## 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: +I. **Open the Roll20 Mod/API Editor** + +1. Open your game’s **Settings**. +2. Open **Mod (API) Scripts**. +3. Create or select the GameAssist script entry. + +II. **Install GameAssist** + +1. Paste the complete contents of `GameAssist-v0.1.4.7.js`. +2. Keep the script as one complete file; do not paste only individual MECHSUITS sections into Roll20. +3. Save the script. + +III. **Install Dependencies** + +Install **TokenMod** from the Mod Library if you want NPCManager and ConcentrationTracker to change markers automatically. TokenMod `0.8.88` is the supported v0.1.4.7 baseline. GameAssist uses TokenMod's documented `--api-as` script path, so `players-can-ids` may remain off. + +Install **StatusInfo** only if the campaign wants condition descriptions and `!condition` menus. StatusInfo `0.3.11` is the supported optional baseline. It remains a separate script and observes marker changes through TokenMod's public observer contract. + +NPCHPRoller, CritFumble, ConfigUI, and DebugTools do not require either standalone script. + +IV. **Create the Seven CritFumble Tables** + +Create these exact rollable-table names: + +```text +CF-Melee +CF-Ranged +CF-Thrown +CF-Spell +CF-Natural +Confirm-Crit-Martial +Confirm-Crit-Magic +``` + +V. **Reload and Inspect** + +1. Save/reload the API sandbox. +2. Expect one core ready whisper. +3. Run: + +```roll20chat +!ga-status +!ga-config modules +``` + +Because `QUIET_STARTUP` defaults to `true`, individual module-ready whispers are normally suppressed. + +VI. **Run the Smoke Test** -> (From GameAssist): ℹ️ [10:53:56 PM] [Core] GameAssist v0.1.1.1 ready; modules: CritFumble, NPCManager, ConcentrationTracker, NPCHPRoller +Use the checklist in [§4.1 Minimum Smoke Test](#41-minimum-smoke-test) before trusting the release in a live session. -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. +### 7.1 Official Roll20 API Repository Readiness + +Before opening or updating a pull request against `Roll20/roll20-api-scripts`, confirm: + +* The official repository folder is `GameAssist`, matching `script.json.name`. +* `script.json` points at the uniform executable filename: `"script": "GameAssist.js"`. +* The current release is stored at `GameAssist/0.1.4.7/GameAssist.js`. +* `previousversions` preserves the physically packaged official releases `0.1.1.2`, `0.1.1.1`, and `0.1.1.0`. Intermediate project releases remain in `CHANGELOG.md` but are not advertised as One-Click rollback versions because matching official folders do not exist. +* `script.json` includes the current version, detailed description, authorship, Roll20 user id, dependencies, object/property access, and named conflicts in Roll20's accepted schema shape. +* The top script header includes name, version, last updated date, description, syntax/commands, dependency notes, and configuration pointers. +* The submitted executable uses the required `.js` extension, and the package contains only accepted JavaScript, Markdown, and JSON file types. +* README and smoke test describe the current version and do not promise unverified sandbox behavior. +* GameAssist is MIT licensed; the project license and executable notice agree with the Roll20 repository's MIT contribution terms. +* After the Roll20 repo PR is accepted, update the Roll20 Community Wiki API Script Index if appropriate. --- ## 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 | +Commands are generally matched case-insensitively with token boundaries. Preserve documented spelling and spacing for predictable results. + +`!concentration --config randomize on|off` changes the shared module setting and is part of the current player-accessible concentration command surface. + +| Scope | Command | Parameters / Flags | Purpose | +| --- | --- | --- | --- | +| **Admin** | `!ga-status` | `[--details]` | Show a plain-language system check; `--details` adds session activity, queue, timestamp, and internal event-hook diagnostics. | +| | `!ga-metrics` | `[reset]` | Show persisted session totals/history or reset metrics. | +| | `!ga-config list` | — | Write a versioned configuration-only snapshot handout. | +| | `!ga-config get [key]` | — | Whisper one config value or the module’s full config. | +| | `!ga-config set =` | — | Persist a module config value; unsafe keys are refused. | +| | `!ga-config modules` | — | Show per-module configured/runtime/dependency status. | +| | `!ga-config cleanup` | — | Explicitly remove unknown/orphaned state branches. | +| | `!ga-config ui` / `!ga-config-ui` | `[--page N]` | Open the GM Config UI. | +| | `!ga-enable ` / `!ga-disable ` | — | Enable or disable a module. | +| **GM** | `!npc-hp-all` | — | Roll and set HP for qualifying NPC tokens on the current page. | +| | `!npc-hp-selected` | — | Roll and set HP for qualifying selected NPC tokens. | +| | `!npc-death-help` | — | Open the same central NPCManager guide as `!npc-death-report --help`. | +| | `!npc-death-report` | `[--scope campaign\|chapter\|section\|session] [--recent] [--page N] [--write] [--help]` | Show bucket history; `--help` opens the central guide and `--write` opens the report writer. | +| | `!npc-death-buckets` | `[--campaign "Name"] [--chapter "Name"] [--section "Name"] [--session "Name"] [--resetSession]` | View or rename the active death-history buckets. | +| | `!npc-death-clear` | `[--scope session] [--nested] [--confirm]` | Clear only the selected bucket, or add `--nested` to clear that level and its descendants. | +| | `!NPC-WR` / `!npc-death-write` | `[--all] [--scope ] [--newSection "Name"]` | Open the report writer, update selected handouts, or seed a new Section from the current Session. | +| | `!npc-death-audit` | — | Summarize current HP/death-marker mismatches and update the audit handout. | +| | `!npc-death-arc` | `[--name "Arc"] [--session] [--note "Text"] [--manage] [--allowDuplicates]` | Maintain a deduplicated Arc roster from selected tokens or the current Session; manage removal and undo in chat. | +| | `!ga-conc-status` | — | Show recent concentration DC/damage data per player. | +| **Player / GM** | `!critfumble` / `!critfumble help` | — | Whisper the CritFumble quick reference. | +| | `!critfumble menu` | — | Whisper the guided Natural 1 dialogue. | +| | `!critfail` | — | Open the direct GM-facing manual fumble prompt. Intended for GM use, but not currently GM-gated. | +| **Debug** | `!ga-debug damage` | `--amount N [--token ID] [--apply]` | Preview or apply bar1 damage. | +| | `!ga-debug marker` | `--marker NAME [--state on|off|toggle] [--token ID] [--apply]` | Preview or apply a status marker change. | +| | `!ga-debug save` | `--dc N [--bonus N] [--mode normal|adv|dis] [--label "Text"] [--apply]` | Preview or roll a save. | +| **Player / GM** | `!critfumble-` | `melee|ranged|thrown|spell|natural` | Roll the selected fumble table. | +| | `!confirm-crit-martial` / `!confirm-crit-magic` | — | Roll the matching confirmation table. | +| | `!concentration` / `!cc` | `--damage N`, `--mode normal|adv|dis`, `--last`, `--off`, `--status`, `--config randomize on|off`, `--help` | Open or perform a concentration workflow. | + +### 8.1 Configuration Safety + +These keys are refused: + +```text +__proto__ +prototype +constructor +``` + +Setting `enabled=true` or `enabled=false` routes through module lifecycle controls rather than directly mutating the stored value. --- ## 9 · Configuration Keys -| 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) | +| Module | Key | Type | Default | Purpose | +| --- | --- | --- | --- | --- | +| **ConfigUI** | `enabled` | bool | `true` | Enable the ConfigUI module. | +| | `pageSize` | number | `3` | Modules displayed per UI page. | +| | `showSummaries` | bool | `true` | Show config summaries on module cards. | +| **CritFumble** | `enabled` | bool | `true` | Enable automatic and manual fumble handling. | +| | `debug` | bool | `false` | Enable CritFumble-specific debug messages. | +| | `useEmojis` | bool | `true` | Use emoji styling in CritFumble output. | +| | `rollDelayMs` | number | `200` | Delay between applicable table-roll actions. | +| **ConcentrationTracker** | `enabled` | bool | `true` | Enable concentration commands and tracking. | +| | `marker` | string | `"Concentrating"` | Marker name used for status checks and removal. | +| | `randomize` | bool | `true` | Randomize concentration emote flavor. | +| **NPCManager** | `enabled` | bool | `true` | Enable NPC death tracking. | +| | `autoTrackDeath` | bool | `true` | Automatically add/remove the death marker. | +| | `deadMarker` | string | `"dead"` | Marker used for death state. | +| | `autoHide` | bool | `false` | Move newly dead NPC tokens to another layer. | +| | `hideLayer` | string | `"gmlayer"` | Target layer used by `autoHide`. | +| **NPCHPRoller** | `enabled` | bool | `true` | Enable NPC HP commands. | +| | `autoRollOnAdd` | bool | `false` | Attempt HP rolling when qualifying tokens are added. | +| **DebugTools** | `enabled` | bool | `false` | Enable GM-only dry-run/apply debug commands. | + +Examples: +```roll20chat +!ga-config get NPCManager +!ga-config get NPCManager deadMarker +!ga-config set NPCManager autoHide=true +!ga-config set NPCManager hideLayer=gmlayer +!ga-config set NPCHPRoller autoRollOnAdd=true +!ga-config set CritFumble debug=false +``` --- ## 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` | +### 10.1 Public API Summary + +| Category | Method | Description | +| --- | --- | --- | +| **Module Registration** | `GameAssist.register(name, initFn, options)` | Register a module before Roll20’s `ready` event. | +| **Command Handling** | `GameAssist.onCommand(prefix, handler, moduleName, opts)` | Register a guarded API-command handler. | +| **Event Handling** | `GameAssist.onEvent(eventName, handler, moduleName)` | Register a guarded Roll20 event handler. | +| **Explicit Queue** | `GameAssist.enqueue(task, options)` | Explicitly submit serialized work; returns `true` if accepted. | +| **Listener Bookkeeping** | `GameAssist.offCommands(moduleName)` / `GameAssist.offEvents(moduleName)` | Clear GameAssist’s internal registry entries; cannot detach Roll20 handlers. | +| **Module Control** | `GameAssist.enableModule(name)` / `GameAssist.disableModule(name)` | Run guarded module lifecycle transitions. | +| **State Management** | `GameAssist.getState(name)` / `saveState(name, data)` / `clearState(name)` | Read, merge, or reset a module-owned state branch. | +| **Token Helper** | `GameAssist.getLinkedCharacter(token)` | Return `{ token, character }` for a valid linked object-layer token, otherwise `null`. | +| **Chat Helpers** | `GameAssist.createButton(label, command)` / `GameAssist.rollTable(tableName)` | Create safe chat buttons or roll a sanitized table name. | +| **Config UI** | `GameAssist.renderConfigUI(playerId, options)` | Open the ConfigUI when that module is active. | +| **Metrics** | `GameAssist.getMetricsStore()` / `GameAssist.recordMetric(type, opts)` | Inspect or record metrics. | +| **Logging** | `GameAssist.log(mod, message, level, opts)` / `GameAssist.handleError(mod, error)` | Whisper safe logs and record errors. | + +### 10.2 Module Registration + +```js +GameAssist.register('MyModule', function initMyModule() { + GameAssist.onCommand('!mymod', msg => { + GameAssist.log('MyModule', `Hello, ${msg.who}`); + }, 'MyModule'); +}, { + enabled: true, + events: ['chat:message'], + prefixes: ['!mymod'], + teardown: null, + dependsOn: ['TokenMod'], + preserveRuntimeOnDisable: false +}); +``` -> **Note:** `DEFAULT_TIMEOUT` and `WATCHDOG_INTERVAL` are internal constants and not part of the public API. +Important contracts: + +* Registration must happen before Roll20’s `ready` event. +* `events`, `prefixes`, and `dependsOn` are metadata; they do **not** wire handlers automatically. +* Modules still call `GameAssist.onEvent(...)` and/or `GameAssist.onCommand(...)`. +* A module should persist only inside `state.GameAssist.`. +* Dependencies may be reported as unverifiable if Roll20 does not expose script metadata. +* Runtime is cleared on disable by default. Set `preserveRuntimeOnDisable: true` only when the module deliberately stores durable records there; NPCManager uses this for death-history buckets and Arc records. + +### 10.3 Command Matching + +```js +GameAssist.onCommand('!mymod', handler, 'MyModule', { + gmOnly: false, + acl: [], + match: { + caseInsensitive: true, + mode: 'token' + } +}); +``` +| Option | Meaning | +| --- | --- | +| `gmOnly` | Refuse non-GM callers when `true`. | +| `acl` | Optional allowed player-ID list. | +| `match.caseInsensitive` | Match command case-insensitively when `true`. | +| `match.mode: 'token'` | Require a whitespace/end boundary after the command. Recommended for ordinary commands. | +| `match.mode: 'prefix'` | Intentionally match any content beginning with the prefix. Use sparingly. | + +### 10.4 Events and Lifecycle Guards + +```js +GameAssist.onEvent('change:graphic:bar1_value', (token, previous) => { + // Handle the event directly. +}, 'MyModule'); +``` + +Normal handlers execute directly and return early unless their module is initialized and active. `offCommands()` and `offEvents()` clear GameAssist’s internal bookkeeping but cannot unregister callbacks from Roll20’s event bus. + +### 10.5 Explicit Queue + +Use the queue only when order or non-overlap matters: + +```js +GameAssist.enqueue(() => { + // Synchronous serialized work. +}); + +GameAssist.enqueue(() => new Promise(resolve => { + sendChat('', '[[1d20]]', results => { + // Process results, then settle the queued portion. + resolve(); + }); +}), { + priority: 0, + timeout: 30000 +}); +``` + +Queue rules: + +* `GameAssist.enqueue(task, options)` returns `true` when accepted and `false` when `task` is invalid. +* Async queued work must return a Promise. +* Higher numeric priority runs first; equal-priority tasks preserve enqueue order. +* A timeout advances/releases the queue but cannot cancel the underlying operation. +* Never use the queue merely because an event exists. + +### 10.6 MECHSUITS Contribution Contract + +The executable file follows MECHSUITS v1.5.2 conventions: + +* Preserve literal codename and tags: `GAMEASSIST`. +* Keep the file-scoped `canonical_tree` synchronized with actual tags. +* Maintain proper parent/child nesting and paired `BEGIN`/`END` tags. +* Update the narrowest complete framed section whose behavior or contract changes. +* Apply the Meaningful Change Rule to `last_updated_version` and the section footer. +* Preserve prior notes instead of silently deleting project history. +* Do not claim full MECHSUITS compliance without checking the complete v1.5.2 checklist. --- ## 11 · Roll-Table Cookbook -Sample **CF-Melee** roll table: +CritFumble expects these exact Roll20 rollable-table names: + +| Table | Intended Use | +| --- | --- | +| `CF-Melee` | Melee weapon fumbles. | +| `CF-Ranged` | Ranged weapon fumbles. | +| `CF-Thrown` | Thrown weapon fumbles. | +| `CF-Spell` | Spell attack fumbles. | +| `CF-Natural` | Natural weapon/unarmed fumbles. | +| `Confirm-Crit-Martial` | Martial critical confirmation/flavor. | +| `Confirm-Crit-Magic` | Magic critical confirmation/flavor. | + +Table names must match exactly. GameAssist supplies the roll; you own the entries, weights, and campaign tone. + +### 11.1 Sample `CF-Melee` Table + +| Entry | Weight | Example Effect | +| --- | ---: | --- | +| **Sweaty Grip** | 1 | Disadvantage on your next attack. | +| **Weapon Twists** | 3 | The attack deals half damage. | +| **Off-Balance** | 2 | You fall prone. | +| **Lost Grip** | 1 | Your weapon falls at the opponent’s feet. | +| **Double Trouble** | 1 | Roll twice; both effects apply. | + +### 11.2 Sample Confirmation Tables -| 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) | +| Table | Example Entry | Weight | +| --- | --- | ---: | +| `Confirm-Crit-Martial` | “Perfect opening—describe the decisive strike.” | 1 | +| `Confirm-Crit-Magic` | “Arcane resonance—describe how the spell intensifies.” | 1 | +> **Content note:** Sample effects are suggestions, not enforced mechanics. Adjust them for your system, tone, and player expectations. --- ## 12 · Macro Recipes -### 12.1 GM Panic – disable every module +### 12.1 GM Health Dashboard ```roll20chat +!ga-status +!ga-config modules +!ga-metrics +``` + +### 12.2 GM Panic – Disable Every Bundled Module + +```roll20chat +!ga-disable ConfigUI !ga-disable CritFumble !ga-disable ConcentrationTracker !ga-disable NPCManager !ga-disable NPCHPRoller +!ga-disable DebugTools +``` + +Core admin commands remain available because the core is not a toggleable bundled module. +NPCManager's configured marker may be cleared from current-page tokens, but its saved death-history and Arc records are retained. + +### 12.3 Restore Normal Bundled Modules + +```roll20chat +!ga-enable ConfigUI +!ga-enable CritFumble +!ga-enable ConcentrationTracker +!ga-enable NPCManager +!ga-enable NPCHPRoller ``` +Leave DebugTools disabled until needed. + +### 12.4 Concentration Check Prompt + +```roll20chat +!concentration --damage ?{Damage Taken|10} --mode ?{Mode|normal|adv|dis} +``` + +### 12.5 NPC Death Controls + +```roll20chat +!npc-death-report +!npc-death-report --scope campaign +!npc-death-buckets +!NPC-WR +!npc-death-audit +!npc-death-report --recent +!npc-death-clear --scope session +!npc-death-clear --scope session --confirm +!npc-death-clear --scope section --nested --confirm +!npc-death-arc +``` + +### 12.6 NPC HP Setup + +```roll20chat +!npc-hp-selected +``` + +Select the desired linked NPC tokens before running the macro. + +### 12.7 Safe Marker Debug + +```roll20chat +!ga-enable DebugTools +!ga-debug marker --marker dead --state toggle +``` + +The first run is a dry run. Add `--apply` only after checking the preview. + --- ## 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. +> **Historical reference only:** The following numbers were recorded for an earlier v0.1.3-era build and have **not** been revalidated for v0.1.4.7. Roll20 sandbox load, campaign size, browser state, network conditions, token formulas, and other Mods can materially change results. Do not treat this table as a current performance guarantee. -| 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 | +| Environment Item | Historical Test Environment | +| --- | --- | +| CPU / RAM | Ryzen 7 7735HS @ 3.2 GHz · 16 GB DDR5-4800 | +| OS / Browser | Windows 11 Home 24H2 · Chrome 137 | +| Roll20 sandbox | Experimental channel, April 2025-era build | +| Dataset | 25 NPC tokens on one page | -**Timing results (`!npc-hp-all`, 25 tokens)** +**Historical `!npc-hp-all` timing** -| 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 | +| Run Group | Samples | Mean | Median | Standard Deviation | Min–Max | +| --- | ---: | ---: | ---: | ---: | ---: | +| Warm sandbox | 24 | 280 ms | 268 ms | 24 ms | 253–337 ms | +| Fresh sandbox | 10 | 355 ms | 350 ms | 18 ms | 330–387 ms | +| **Combined** | **34** | **298 ms** | **300 ms** | **39 ms** | **253–387 ms** | + +### 13.1 Repeatable Benchmarking for v0.1.4.7 + +1. Duplicate the campaign or use a test game. +2. Record token count, active Mods, formulas, and sandbox channel. +3. Run both fresh-sandbox and warm-sandbox samples. +4. Test visible user behavior, not only queue metrics. +5. Remember that `!ga-metrics` queue durations describe explicit queued work; direct event-handler work is not automatically represented as a queue duration. --- ## 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. +### 14.1 GameAssist Appears Unresponsive + +Run: + +```roll20chat +!ga-status +!ga-config modules +!ga-metrics +``` + +Start with the default `!ga-status` system check. A separate **GameAssist Actions** whisper immediately below the table provides **Troubleshooting Details**, **Module List**, and **Open Settings** buttons. The detailed view uses a separate **Troubleshooting Actions** strip for **Refresh Details**, **Simple View**, **Module List**, and **Metrics**. The details table keeps session counters, queue information, the last recorded activity, and GameAssist's internal event-hook count separate from the health result. + +### 14.2 A Module Is Configured but Not Running + +Use: + +```roll20chat +!ga-config modules +``` + +The output distinguishes: + +* **Configured** – stored `enabled` preference. +* **Running** – initialized and active in the current sandbox. +* **Dependency-skipped** – not running because a dependency is confirmed missing. +* **Unverifiable dependency** – GameAssist could not confirm the dependency and proceeded with a warning. + +Then try: + +```roll20chat +!ga-enable +``` + +### 14.3 TokenMod Is Missing or Unverifiable + +NPCManager and ConcentrationTracker require TokenMod for marker changes. + +* `confirmed` means GameAssist found TokenMod through its public observer contract, its `API_Meta` version record, or Roll20's visible script list. +* `missing` means GameAssist found enough script-list evidence to conclude TokenMod is absent. +* `unverifiable` means neither the public contract nor Roll20's script metadata was available. It is not proof that TokenMod failed. +* `!ga-status --details` reports the detected TokenMod version when upstream metadata is available. + +GameAssist v0.1.4.7 sends internal marker requests through TokenMod's documented `--api-as ` path. TokenMod's `players-can-ids` option controls player-facing `--ids` use and does not need to be enabled for GameAssist. + +If GameAssist warns that TokenMod did not add or remove a marker, select the named token and run the exact direct command shown in the warning. For example: + +```roll20chat +!token-mod --ids @{selected|token_id} --set statusmarkers|+dead +!token-mod --ids @{selected|token_id} --set statusmarkers|-dead +``` + +If the direct command also fails, troubleshoot TokenMod or the selected token. If the direct command works, include the GameAssist warning and `!ga-status --details` output in the issue report. + +#### Optional StatusInfo Observation + +StatusInfo is not required by GameAssist. When StatusInfo `0.3.11` is installed, `!ga-status --details` reports its detected version and whether condition descriptions are enabled. GameAssist deliberately leaves marker mutation inside TokenMod so StatusInfo can receive TokenMod's observer notification. + +The final check is live behavior: add a StatusInfo condition whose icon matches the tested marker, perform one GameAssist marker change, and confirm StatusInfo shows the configured description once. Detection alone cannot prove that a campaign-specific condition is correctly configured. + +### 14.4 Startup Messages Are Missing + +This is normally expected. `GameAssist.flags.QUIET_STARTUP` defaults to `true`, suppressing module-specific startup whispers. The core ready message remains visible. + +Use `!ga-status` and `!ga-config modules` instead of relying on one whisper per module. + +### 14.5 State Repair or Unknown-Branch Warnings + +Known module branches with malformed/missing `config` or `runtime` containers are repaired conservatively at startup. Valid existing config is preserved. + +Unknown branches are not deleted automatically. Review the warning, then explicitly remove orphaned branches only when you are certain: + +```roll20chat +!ga-config cleanup +``` + +### 14.6 `!ga-config list` Is Not a Full Backup + +The `GameAssist Config` handout contains flags, global config, and module config only. It excludes runtime caches, metrics, and unknown state branches. v0.1.4.7 cannot import the snapshot. + +Use it for configuration review and upgrade comparison—not as a full restore mechanism. + +### 14.7 CritFumble Menu or Table Roll Fails + +Confirm all seven table names exist exactly: + +```text +CF-Melee +CF-Ranged +CF-Thrown +CF-Spell +CF-Natural +Confirm-Crit-Martial +Confirm-Crit-Magic +``` + +Then run: + +```roll20chat +!critfumble menu +!critfumble help +!critfumble-melee +!confirm-crit-martial +``` + +### 14.8 NPC Death Marker Does Not Match HP + +Run: + +```roll20chat +!ga-config get NPCManager deadMarker +!npc-death-audit +``` + +Confirm the token: + +* is on the Objects layer, +* represents a character, +* has character attribute `npc=1`, +* uses `bar1_value` for HP, +* and has a valid configured marker. + +`!npc-death-audit` whispers a bounded list of the specific tokens needing a marker added or removed, and writes the complete mismatch list to the `GameAssist NPC Death Audit` handout. Player characters are intentionally excluded from this audit. + +`!npc-death-report` shows recorded bucket history in summary/detail views; it does not audit the page. + +### 14.9 Concentration Marker Does Not Clear + +Select the affected token and run: + +```roll20chat +!ga-config get ConcentrationTracker marker +!concentration --off +!concentration --status +``` + +Confirm TokenMod is installed or manually verified when dependency status is `unverifiable`. Check `!ga-status --details` for TokenMod and StatusInfo evidence. + +`!concentration --status` reads markers directly and should still respond when TokenMod detection is `unverifiable`. If it reports that the configured marker cannot be recognized, run: + +```roll20chat +!token-mod --help-statusmarkers +!ga-config set ConcentrationTracker marker= +``` + +### 14.10 NPC HP Does Not Roll + +Confirm the token: + +* is linked to a character, +* represents an NPC with `npc=1`, +* has a valid `npc_hpformula` such as `4d8+8`, +* and is on the correct page or selected for the command. + +NPCHPRoller does not require TokenMod. + +### 14.11 Debug Command Does Nothing + +Enable DebugTools first: + +```roll20chat +!ga-enable DebugTools +``` + +DebugTools performs a dry run unless `--apply` is supplied. To use selected tokens, omit `--token`; do not write `--token select`. + +### 14.12 Compatibility Hints + +Compatibility scanning is debug-only: + +```js +GameAssist.flags.DEBUG_COMPAT = true; +``` + +Reload, inspect the output, then return it to `false` to avoid noise. Do not run standalone versions of CritFumble, Concentration, NPC Death Tracker, or NPC HP Roller alongside their integrated GameAssist modules. + +### 14.13 Still Stuck? + +Capture: + +1. Exact GameAssist version. +2. `!ga-status` output. +3. `!ga-config modules` output. +4. Exact command/action that failed. +5. Exact API sandbox error text. +6. Whether TokenMod was confirmed, missing, or unverifiable. + +That evidence is far more useful than “it stopped working.” --- ## 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).** +### 15.1 Recommended Upgrade: v0.1.4.6 → v0.1.4.7 + +I. **Freeze the Current Working Script** + +1. Keep a copy of the currently working GameAssist script. +2. Record its exact version. +3. Run `!ga-config list` for a configuration-only comparison snapshot. + +> The snapshot is not a full-state backup and cannot be imported automatically. + +II. **Replace the Script** + +1. Replace the Roll20 script contents with the complete `GameAssist-v0.1.4.7.js`. +2. Save/reload the API sandbox. +3. Do not combine partial sections from multiple releases unless you are deliberately performing a MECHSUITS whole-section update and have reviewed the ancestor contracts. + +III. **Verify Core Health** + +```roll20chat +!ga-status +!ga-config modules +!ga-metrics +``` + +Review all dependency warnings. An `unverifiable` dependency is not proof of failure; it is a prompt for manual confirmation. + +The default status panel explains that uncertainty directly. Use `!ga-status --details` for session counters, queue state, the last recorded activity, internal event-hook information, and detected standalone TokenMod/StatusInfo versions. + +IV. **Verify Configuration** + +```roll20chat +!ga-config get ConfigUI +!ga-config get CritFumble +!ga-config get NPCManager +!ga-config get ConcentrationTracker +!ga-config get NPCHPRoller +!ga-config get DebugTools +``` + +v0.1.4.7 retains the known-container repairs from v0.1.4.2, marker recognition from v0.1.4.3, DM-facing module help from v0.1.4.4, NPCManager history/reporting from v0.1.4.5, and the system-health presentation from v0.1.4.6. Its focused runtime changes are contract-aware TokenMod detection, documented `--api-as` authorization, delayed marker-result verification, optional StatusInfo evidence, NPCManager `1.1.1`, and ConcentrationTracker `0.1.0.6`. + +V. **Run the Smoke Test** + +Use [§4.1 Minimum Smoke Test](#41-minimum-smoke-test), including real HP, concentration, marker, and enable/disable checks. + +### 15.2 Rollback + +If v0.1.4.7 fails its smoke test: + +1. Replace it with your complete previous working script. +2. Save/reload. +3. Run `!ga-status` and the smallest relevant module checks. +4. Remember that rolling back code does not automatically roll back persistent state. +5. Do not attempt manual state import unless you have a separately validated process. + +### 15.3 Upgrade Discipline + +> **Copy → Save → Inspect → Smoke Test → Keep or Roll Back** + +Do not make a live-session release decision from syntax checks alone. The Roll20 API sandbox remains the final compatibility test. --- ## 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! +Thank you for helping improve GameAssist. Contributions should remain narrow, testable, and explicit about Roll20 limitations. + +### 16.1 Reporting Issues + +Include: + +1. A clear title and exact GameAssist version. +2. Reproduction steps in a minimal test game when possible. +3. Relevant commands, token setup, and character attributes. +4. Exact API sandbox errors and GameAssist whispers. +5. `!ga-status` and `!ga-config modules` results. +6. Whether dependencies were confirmed, missing, or unverifiable. + +### 16.2 Coding Style + +* Use the existing JavaScript style and Roll20-compatible runtime features. +* Preserve literal identifiers, public commands, module names, tags, and codename `GAMEASSIST`. +* Prefer shared helpers when behavior is genuinely shared. +* Validate and normalize at input edges. +* Keep ordinary handlers direct; use `GameAssist.enqueue(...)` only for work that requires serialization. +* Do not override Roll20’s global `on` or invent an `off` lifecycle that Roll20 does not provide. +* Never claim that a timeout cancels an underlying Roll20 operation. + +### 16.3 MECHSUITS Update Workflow + +For executable code changes: + +1. Identify the narrowest framed section whose code or contract changes. +2. Return or replace the complete `BEGIN` through `END` section. +3. Replace ancestors only when their declared contract becomes inaccurate. +4. Keep the canonical tree synchronized if tags change. +5. Apply the Meaningful Change Rule: + * meaningful behavior/contract/operational change → update `last_updated_version` and add `Changed (...)`; + * comment-only or proven behavior-preserving change → keep `last_updated_version` and add `Maintenance (...)`. +6. Preserve prior commentary under `Prior notes`. +7. Verify the full v1.5.2 checklist before calling the file MECHSUITS-compliant. + +### 16.4 Testing Expectations + +At minimum: + +* Run a JavaScript syntax check. +* Audit MECHSUITS tag pairing, nesting, tree consistency, section metadata, and footers. +* Run the Roll20 smoke test. +* Test each changed command or event with real Roll20 objects. +* Test dependency states affected by the change. +* Test module disable/re-enable when lifecycle behavior changes. +* Confirm no unrelated module behavior changed. + +### 16.5 Documentation Expectations + +Update the relevant README surfaces whenever you change: + +* commands → Command Matrix and Module Guide; +* configuration → Configuration Keys; +* roll-table names → Roll-Table Cookbook; +* public helpers → Developer API; +* operational limitations → Architecture and Troubleshooting; +* release behavior → Changelog and Upgrade Paths. --- ## 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. +The roadmap is directional, not a promise. Items are labeled so implemented features are not mistaken for future work and future ideas are not mistaken for current behavior. + +### 17.1 Current Status + +| Item | Status in v0.1.4.7 | Notes | +| --- | --- | --- | +| Auto HP roll on NPC token add | **Implemented, opt-in** | `NPCHPRoller.autoRollOnAdd`, default `false`. | +| Session metrics and logging | **Implemented, basic** | `!ga-status` gives a simple system check; `!ga-status --details` and `!ga-metrics` expose troubleshooting counters. Not a full profiler. | +| Configuration export | **Implemented, partial** | Versioned configuration-only snapshot; no import/restore. | +| State self-healing | **Implemented, conservative** | Repairs known containers; does not auto-delete unknown branches. | +| Dependency diagnostics | **Implemented, best-effort** | TokenMod public-contract/version evidence plus confirmed/missing/unverifiable fallback; live mutation remains the final proof. | +| Public queue API | **Implemented, opt-in** | Does not route every event through the queue. | +| NPC death history | **Implemented and sandbox-verified** | NPCManager `1.1.1`; four-level handouts, Arc roster management, report writer, date-managed Sessions, and verified TokenMod requests. | +| Native Mord character-sheet support | **Deferred** | Begin after the agreed GameAssist architecture foundation is stable; track sequencing in `ROADMAP.md`. | + +### 17.2 Near-Term Candidate: Compatibility-First Bridge Character Sheet + +After the GameAssist architecture foundation is confirmed stable in Roll20, the recommended character-sheet project is a bridge sheet that: -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. +* preserves existing GameAssist command behavior, +* exposes reliable attributes for linked-token modules, +* defines clear NPC, HP-formula, save-bonus, and roll-template contracts, +* avoids requiring another broad GameAssist kernel rewrite. -1. **Auto HP Roll on NPC Token Add** - - Automatically roll and assign HP whenever a new NPC token is placed on the map. +This is a separate project and is not implemented in v0.1.4.7. -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. +### 17.3 Deferred GameAssist Features -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. +1. **Spell-Specific Concentration Integration** + * Detect concentration spell casts. + * Track spell name, duration, expiration, and optional reminders. + * Clear concentration under explicitly defined conditions. -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. +2. **Expanded Module Suite** + * Cooldown tracker. + * Encounter assistant. + * Resource tracker. + * Condition automator. + * Rest and recovery tools. + * Dynamic location/AoE helpers. -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. +3. **Plugin Registry and Discovery** + * A validated extension contract for third-party modules. + * No promise of filesystem-style “drop-in folders,” because Roll20’s sandbox does not expose a normal plugin directory. -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. +4. **Configuration and State Restore** + * Validated snapshot import. + * Migration rules and preview/dry-run behavior. + * Explicit handling for runtime caches, metrics, and unknown branches. -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. +5. **Rollable-Table Import/Export** + * Shareable table formats with validation and collision behavior. -> **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. +6. **Verbose Diagnostics** + * Runtime-controlled detail without leaking unsafe or excessively noisy data. +7. **Documentation and Community Resources** + * More macro recipes. + * Additional table examples. + * Campaign-tested compatibility notes. + +### 17.4 Explicit Non-Goals for v0.1.4.x + +* No implicit queueing of every command or event. +* No claim that the watchdog can kill running work. +* No automatic deletion of unexpected state branches. +* No guaranteed external dependency discovery. +* No complete state import/restore. +* No plugin loader, Rest Manager, encounter suite, or native Mord-sheet implementation. --- ## 18 · Changelog -See `CHANGELOG.md`. +### v0.1.4.7 – Standalone TokenMod and StatusInfo Interoperability + +* Added contract-aware TokenMod detection using its public observer interface and `API_Meta` version record before falling back to Roll20's script list. +* Routed NPCManager and ConcentrationTracker marker requests through TokenMod's documented `--api-as` path, removing any GameAssist requirement for `players-can-ids`. +* Added delayed marker-result verification with an actionable direct TokenMod command when the requested state is not reached. +* Preserved mutation through standalone TokenMod so StatusInfo continues receiving TokenMod observer notifications. +* Added TokenMod and optional StatusInfo version/configuration evidence to `!ga-status --details`. +* Advanced NPCManager to `1.1.1` and ConcentrationTracker to `0.1.0.6`. +* Prevented NPCHPRoller auto-roll-on-add token setup from creating a false NPC death/revival pair while preserving later genuine HP transitions. + +### v0.1.4.6 – DM-Readable System Status + +* Rebuilt `!ga-status` around overall health, enabled-module posture, current-sandbox errors, and plain-language dependency guidance. +* Added `!ga-status --details` for session counters, queue state, average queued-task time, last activity, and the qualified internal event-hook count. +* Removed the malformed `N/Ams` duration display; unavailable duration now appears as `N/A` with an explanation. +* Added direct buttons for troubleshooting details, module status, metrics, and settings. +* Kept `unverifiable` dependencies non-fatal and explained the appropriate manual marker check. + +### v0.1.4.5 – NPCManager Death History and Report Management + +* Added Campaign, Chapter, Section, and Session death-history buckets with one handout per named bucket. +* Advanced NPCManager to `1.1.0` with default Arc deduplication, deliberate duplicate override, removal controls, and last-addition undo. +* Added selected-only and nested hierarchical clear choices. +* Added date-managed Session rollover before NPCManager activity. +* Added the `!NPC-WR` report writer and “new Section from current Session” workflow. +* Rebuilt `!npc-death-report --help` as the central NPCManager guide. + +### v0.1.4.4 – DM-Facing Help and Audit Readability + +* Separated the CritFumble quick reference, guided Natural 1 menu, and player picker. +* Grouped NPC death-audit results, stated audit scope and PC exclusion, and moved detailed mismatch rows to a handout. + +### v0.1.4.3 – Concentration Marker Recognition + +* Resolved custom marker display names to the exact tags Roll20 stores on tokens. +* Preserved literal lowercase built-in marker ids such as `dead`. +* Made `!concentration --status` report unrecognized marker configuration clearly. +* Sent resolved marker tags to TokenMod for concentration add/remove/teardown requests. +* Preserved standalone TokenMod as the v0.1.4.x marker-mutation dependency. +* Added focused concentration-marker checks to `Smoketest.md`. + +### v0.1.4.2 – Diagnostic and Migration Readiness + +* Added conservative state self-healing for known module branches. +* Preserved valid existing configuration during repairs. +* Kept unknown state branches warning-only; added explicit `!ga-config cleanup`. +* Added public opt-in `GameAssist.enqueue(task, options)`. +* Clarified queue timeout and watchdog limits. +* Added confirmed/missing/unverifiable dependency reporting. +* Added versioned configuration-only snapshots through `!ga-config list`. +* Expanded `!ga-status` with configured/running/skipped counts and dependency warnings. +* Documented `!npc-death-clear`, `!npc-death-audit`, `autoHide`, `hideLayer`, `dependsOn`, and command matching options. +* Preserved normal direct event execution and the six bundled module implementations. + +### v0.1.4.1 – MECHSUITS and Stability Foundation + +* Established v0.1.4 as the behavioral baseline for the stability release. +* Incorporated selected fixes from unreleased v0.1.5 development. +* Hardened shared utilities, marker handling, timestamps, state/runtime helpers, and lifecycle behavior. +* Preserved Roll20’s captured native `on` strategy. +* Structured the executable file around MECHSUITS v1.5.2 requirements. + +For the current verification checklist, see `Smoketest.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 `!` that a Roll20 Mod/API script can handle, such as `!ga-status`. -- **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 Boundary** + The rule that a command must end or be followed by whitespace. It prevents `!ga-status-extra` from accidentally matching `!ga-status`. -- **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. +* **Command Handler** + A function registered through `GameAssist.onCommand(...)` to respond to an API command. -- **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. +* **Configured Module** + A module whose stored `enabled` configuration is not false. It may still be stopped if initialization failed or a dependency is missing. -- **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. +* **Confirmed Dependency** + A dependency GameAssist could positively identify as available. -- **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. +* **Configuration-Only Snapshot** + The versioned handout produced by `!ga-config list`. It excludes runtime caches and metrics and cannot currently be imported. -- **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. +* **Direct Handler** + A normal command/event handler that runs immediately rather than being placed on the explicit queue. -- **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. +* **Event Handler** + A function registered through `GameAssist.onEvent(...)` that responds to a Roll20 event, such as a token HP change. -- **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. +* **Explicit Queue** + The serialized task queue used only when code calls `GameAssist.enqueue(...)` or performs a module lifecycle transition. -- **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. +* **Kernel** + The shared GameAssist core that manages registration, lifecycle controls, metrics, state helpers, dependency diagnostics, logging, and the explicit queue. -- **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. +* **Marker** + A Roll20 token status icon or named status entry, such as `dead` or `Concentrating`. -- **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. +* **MECHSUITS** + The project’s human-readable, assistant-ready code-structure standard. It governs banners, framed sections, nesting, contracts, update notes, and whole-section replacement. -- **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. +* **Missing Dependency** + A dependency GameAssist could confirm is absent. Dependent modules are skipped or refused enablement. -- **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. +* **Module** + A self-contained GameAssist feature registered with a unique name, initializer, metadata, and optional teardown. -- **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. +* **Persistent State** + Data under `state.GameAssist` that survives API sandbox reloads. -> **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. +* **Roll-Table / Rollable Table** + A Roll20 table containing weighted outcomes. CritFumble rolls named tables to produce results. -``` +* **Running Module** + A module that is initialized and active in the current sandbox. ---- +* **Runtime Cache** + Module-owned operational data stored under `state.GameAssist..runtime`. Runtime data is excluded from configuration snapshots. + +* **State Self-Healing** + Conservative repair of missing or malformed containers for known module branches. It does not delete unknown branches or infer arbitrary data. + +* **Teardown Function** + An optional function called during module disablement to perform module-specific cleanup. + +* **Unverifiable Dependency** + A dependency whose presence GameAssist could not confirm because Roll20 did not expose enough metadata. GameAssist warns and proceeds. + +* **Watchdog** + A periodic observer for the explicit queue. It can release stalled queue state but cannot terminate running JavaScript or Roll20 operations. + +> **Tip:** When behavior in a campaign differs from this README, treat the current script and a Roll20 smoke test as the final source of truth, then correct the documentation. diff --git a/GameAssist/script.json b/GameAssist/script.json index 09db558caa..63e4ccf8cb 100644 --- a/GameAssist/script.json +++ b/GameAssist/script.json @@ -1,26 +1,50 @@ { "name": "GameAssist", "script": "GameAssist.js", - "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.", + "version": "0.1.4.7", + "previousversions": [ + "0.1.1.2", + "0.1.1.1", + "0.1.1.0" + ], + "description": "# GameAssist\n\nGameAssist is a modular Roll20 Mod/API automation suite for the D&D 5E (2014) character sheet. It combines critical-fumble tools, concentration checks, NPC death tracking and report handouts, and NPC hit-point rolling in one configurable script.\n\n## Quick Start\n\n1. Install GameAssist and its required TokenMod dependency.\n2. Create the seven CritFumble rollable tables listed below.\n3. Save or restart the Mod sandbox.\n4. Run `!ga-status` to check the installation and `!ga-config ui` to open settings.\n\n## Main Commands\n\n- `!ga-status` -- Show a short system check. Use `!ga-status --details` for troubleshooting.\n- `!ga-config ui` -- Open the GM configuration menu.\n- `!critfumble help` -- Open the CritFumble quick-reference guide.\n- `!critfail` -- Open the GM player picker for a manual critical miss.\n- `!concentration` or `!cc` -- Open concentration-check controls.\n- `!npc-death-report --help` -- Open the NPC death-tracking guide and report controls.\n- `!npc-death-audit` -- Check current-page NPC HP against death markers.\n- `!npc-hp-selected` -- Roll HP for selected linked NPC tokens.\n\n## Requirements\n\n- TokenMod 0.8.88 is the supported standalone baseline for NPCManager and ConcentrationTracker marker changes.\n- StatusInfo 0.3.11 is optional and can provide condition descriptions and menus.\n- CritFumble uses these rollable tables: `CF-Melee`, `CF-Ranged`, `CF-Thrown`, `CF-Spell`, `CF-Natural`, `Confirm-Crit-Martial`, and `Confirm-Crit-Magic`.\n\nGameAssist does not create or populate those campaign-specific rollable tables automatically. Features unrelated to a missing optional setup item remain available.\n\n## Documentation\n\nSee the [GameAssist README](https://github.com/Mord-Eagle/GameAssist#readme) for complete setup, commands, examples, module settings, upgrade guidance, and troubleshooting.", "authors": "Mord Eagle", "roll20userid": "10646976", - "modifies": [], - "conflicts": [ - "Other scripts that modify NPC HP or use bar1_value" - ], + "patreon": "", + "tipeee": "", "useroptions": [], - "commands": [ - "!ga-enable", - "!ga-disable", - "!ga-config", - "!npc-hp-all", - "!npc-hp-selected", - "!npc-death-report", - "!concentration", - "!cc", - "!critfail", - "!critfumble" + "dependencies": [ + "TokenMod" + ], + "modifies": { + "Campaign.playerpageid": "read", + "Campaign._token_markers": "read", + "graphic._pageid": "read", + "graphic.bar1_value": "read,write", + "graphic.bar1_max": "read,write", + "graphic.statusmarkers": "read,write", + "graphic.represents": "read", + "graphic.name": "read", + "graphic.layer": "read,write", + "graphic.controlledby": "read", + "character.name": "read", + "character.controlledby": "read", + "attribute._characterid": "read", + "attribute.name": "read", + "attribute.current": "read", + "rollabletable.name": "read", + "tableitem._rollabletableid": "read", + "tableitem.name": "read", + "tableitem.weight": "read", + "handout.name": "read,write", + "handout.notes": "read,write", + "handout.inplayerjournals": "read,write", + "handout.controlledby": "read,write", + "player._id": "read", + "player._displayname": "read" + }, + "conflicts": [ + "Concentration", + "DeathTracker" ] }