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/CHANGELOG.md b/GameAssist/CHANGELOG.md index 2274a581aa..14323e9ac1 100644 --- a/GameAssist/CHANGELOG.md +++ b/GameAssist/CHANGELOG.md @@ -1,6 +1,960 @@ # Changelog -All notable changes to this project will be documented in this file. +All notable changes to GameAssist are documented in this file. + +This changelog is intentionally detailed. It records not only visible features, but also implementation locations, replaced behavior, design rationale, compatibility boundaries, state/migration effects, verification evidence, exclusions, and rollback posture. Line references describe the named release artifact and may move in later revisions; MECHSUITS section names are the more stable long-term locator. + +--- + +## Release Ledger + +| Revision | Status | Role | +| --- | --- | --- | +| **v0.1.4.7** | Release candidate; automated and Roll20 sandbox verification passed, merge pending | Standalone TokenMod and StatusInfo interoperability | +| **v0.1.4.6** | Merged release | DM-readable system health and troubleshooting status | +| **v0.1.4.5** | Merged release | NPC death-history buckets, handouts, and arc notes | +| **v0.1.4.4** | Merged release | DM-facing CritFumble help and NPC death-audit readability update | +| **v0.1.4.2** | Release candidate; automated verification complete, Roll20 smoke confirmation pending | Diagnostic and migration-readiness release | +| **v0.1.4.1** | Preserved rollback baseline | Stability-first repair of the uploaded v0.1.4 baseline | +| **v0.1.4** | Uploaded stable-but-limping baseline | Source used to build v0.1.4.1 | +| **Attempted v0.1.5** | Failed upgrade; never released | Review source for selected fixes only | +| **v0.1.3** | Prior development milestone; supplied notes retained below | Core lifecycle, metrics, helper, and module-hardening work | +| **v0.1.2** | Historical release | Roll20 packaging and initial MECHSUITS structural wrap | +| **v0.1.1.2** | Historical release | CritFumble natural-1 bugfix | +| **v0.1.1.1** | Historical release | Quiet startup and logging improvements | +| **v0.1.1.0** | Initial public release | Original four-module framework | + +### Release-history notes + +- v0.1.4.2 requires Roll20 API sandbox smoke confirmation before it should be used as a confirmed table build. +- v0.1.4.1 remains available as the rollback script during v0.1.4.2 confirmation. +- The attempted v0.1.5 file was not imported wholesale. Its unsafe or structurally unreliable changes were rejected; only isolated reviewed ideas were ported. +- Older supplied notes used “Unreleased” and “Staging” labels for v0.1.3–v0.1.5 work. Those records are retained below as historical development evidence rather than silently discarded. +- Where the supplied historical record did not establish a release date, this changelog does not invent one. + +--- + +## [Unreleased] + +### Immediate release work + +- Complete the real Roll20 smoke-test checklist for v0.1.4.2. + - Confirm the API sandbox saves and reloads without a red console exception. + - Confirm the core ready whisper reports `0.1.4.2`. + - Confirm `!ga-status`, `!ga-config modules`, `!ga-config list`, and `!ga-metrics`. + - Confirm TokenMod-dependent modules report either `confirmed` or the expected `unverifiable` warning. + - Confirm a real natural-1 attack, concentration workflow, NPC death/revival marker cycle, NPC HP roll, and module disable/re-enable cycle. +- Keep `GameAssist-v0.1.4.1.js` unchanged as the rollback baseline until the v0.1.4.2 smoke test is complete. +- Freeze broad GameAssist core development after v0.1.4.2 is confirmed so the compatibility-first bridge character-sheet project can begin on a stable foundation. + +### Deferred work + +- Validated configuration/state snapshot import and restoration. + - Import requires schema validation, migration rules, preview/dry-run behavior, unknown-branch handling, and rollback semantics. + - v0.1.4.2 intentionally exports configuration only and provides no import command. +- Native Mord character-sheet support. + - This belongs to the bridge character-sheet project, not the v0.1.4.2 core release. +- Plugin loader or automatic third-party module discovery. + - Roll20 does not expose a normal filesystem-style plugin directory. + - Any future extension contract must be explicit and validated. +- Spell-specific concentration detection, spell names, duration tracking, expiration, and reminders. +- Cooldown, encounter, resource, condition, rest/recovery, and location/AoE modules. +- Rollable-table import/export. +- Expanded verbose-mode diagnostics. + +### Explicitly not planned as “fixes” + +- Do not route every command and event through the queue. +- Do not claim that a watchdog or timeout can terminate running JavaScript or Roll20 operations. +- Do not automatically delete unexpected state branches. +- Do not claim guaranteed external dependency discovery when Roll20 metadata may be unavailable. + +--- + +## [0.1.4.2] – 2026-06-09 + +### Release definition + +v0.1.4.2 is a **diagnostic and migration-readiness release**. It adds conservative state self-healing, an explicit public queue API, three-state dependency diagnostics, versioned configuration-only snapshots, and more truthful health reporting without changing normal bundled-module event execution. + +The release was deliberately kept narrow: + +- normal command and event handlers remain direct; +- gameplay-module implementations remain byte-for-byte aligned with the v0.1.4.1 module bodies; +- bootstrap changes are limited to state repair and dependency-status handling; +- no roadmap gameplay modules or character-sheet integration were added. + +### Release artifacts + +| Artifact | Purpose | SHA-256 | +| --- | --- | --- | +| `GameAssist-v0.1.4.2.js` | Release-candidate script | `AB5E2EC627E9BD969997B9FEA0563ED6A9690BD958DD433A38C83FF7F8A3CB35` | +| `GameAssist-v0.1.4.1.js` | Rollback baseline | `E4072A73BECD73EFF4D185F4F30B4A4594DA21DFF85F2C9319F0DA1A55EB08B5` | +| `README-GameAssist-v0.1.4.2.md` | Long-form user/developer handbook | `180AFC8C12E1BFEEA11A9F1EDBC0FD83C115E1DA47867BD4DC1390DD8184289A` | +| `GameAssist-v0.1.4.2-release-notes-and-smoke-test.md` | Release-specific Roll20 verification checklist | Included with release outputs | + +### Version and MECHSUITS metadata + +- Advanced the Roll20 header version, MECHSUITS banner `project_version`, and runtime `VERSION` constant to `0.1.4.2`. + - Roll20 header: `GameAssist-v0.1.4.2.js` line 4. + - Banner `project_version`: line 83. + - Runtime `const VERSION = '0.1.4.2'`: line 574. +- Updated only the MECHSUITS sections that received meaningful behavior or contract changes: + - `[GAMEASSIST:POLICY]` + - `[GAMEASSIST:APP:UTILS]` + - `[GAMEASSIST:CORE]` + - `[GAMEASSIST:CORE:QUEUE]` + - `[GAMEASSIST:CORE:OBJECT]` + - `[GAMEASSIST:INTERFACES:COMMANDS]` + - `[GAMEASSIST:BOOTSTRAP]` +- Preserved literal codename `GAMEASSIST`; no identifier normalization or tag renaming was performed. +- Preserved prior section commentary under `Prior notes` and added `Changed (v0.1.4.2)` records to the changed sections. +- Completed a structural audit for tag pairing, parent nesting, ancestor-only overlap, canonical-tree agreement, metadata presence, and required section footers. + +### Added – State self-healing and audit safety + +- Added conservative repair for known module state branches in `[GAMEASSIST:APP:UTILS]`. + - `ensureStateRoot()` begins at line 276 and guarantees the core `state.GameAssist` containers exist. + - Known module-branch repair logic records whether the missing/malformed item was the branch, `config`, or `runtime` at lines 333, 337, and 341. + - `auditState()` begins at line 388. + - `seedDefaults()` begins at line 423 and runs after repair. +- Known branch behavior: + - If a known module branch is missing or malformed, GameAssist rebuilds a safe branch container. + - If `config` is missing or malformed, GameAssist restores a valid object. + - If `runtime` is missing or malformed, GameAssist restores a valid object. + - Valid existing configuration values are preserved. + - Repairs are logged and recorded through `recordMetric('state_repair', ...)` at line 414. +- Unknown branch behavior: + - Unexpected keys are collected and warned about at line 399. + - Unknown branches are left untouched during startup. + - No automatic destructive cleanup occurs. +- Rationale: + - Known GameAssist-owned shapes can be repaired safely. + - Unknown branches may contain user data, abandoned module data, or future-extension data; deleting them automatically would be unsafe. + - This state posture is important before character-sheet development because upgrades and new modules will increase persistent-state complexity. + +### Added – Explicit state cleanup command + +- Added GM-only `!ga-config cleanup` in `[GAMEASSIST:INTERFACES:COMMANDS]` at line 1448. +- Cleanup behavior: + - Preserves known modules and core branches. + - Deletes only unknown/orphaned branches after the GM explicitly invokes the command. + - Reports the removed branch names or confirms that no orphaned branches were found. +- Safety boundary: + - Cleanup is intentionally not executed during startup. + - Documentation warns the GM to review state warnings before using cleanup. + +### Added – Public opt-in queue API + +- Exposed `GameAssist.enqueue(task, options)` in `[GAMEASSIST:CORE:OBJECT]` at line 1096. +- Public API behavior: + - Requires `task` to be a function. + - Returns `false` and warns when the task is invalid. + - Returns `true` when accepted. + - Accepts optional numeric `priority` and positive `timeout`. + - Falls back to the established default timeout when no valid timeout is supplied. + - Higher numeric priority runs first; equal-priority jobs preserve enqueue order. +- Existing queue implementation remains in `[GAMEASSIST:CORE:QUEUE]`: + - `_enqueue(...)` begins at line 618. + - The job-id stale-completion guard remains intact. + - The queue continues to serialize only explicitly submitted work and module lifecycle transitions. +- Async contract: + - Asynchronous queued work must return a Promise that settles when the queued portion is complete. + - Merely starting `sendChat()` without returning a Promise would allow the queued task to appear complete too early. +- Timeout contract: + - A timeout releases queue ownership so later jobs can proceed. + - A timeout cannot cancel the underlying JavaScript callback, `sendChat()` call, or Roll20 operation. + - The underlying work may finish later; the stale-job guard prevents that late completion from advancing the queue again. +- Rationale: + - Future modules gain a supported serialization seam without imposing queue latency or coupling on every Roll20 event. + +### Changed – Queue and watchdog truthfulness + +- Updated `[GAMEASSIST:CORE:QUEUE]` narrative and footer to state the actual operational limit: + - queue timeouts release the queue; + - watchdog recovery releases a stuck busy state; + - neither mechanism kills running JavaScript or Roll20 work. +- Preserved ordinary command/event execution as direct. +- Added `Queue Mode: explicit opt-in; normal event handlers execute directly` to `!ga-status` at line 1507. +- Rejected the old documentation claim that every inbound Roll20 event was queued and watchdog-controlled. +- Rationale: + - Roll20 offers no general cancellation primitive for a running callback or pending external operation. + - Accurate documentation is part of operational safety. + +### Added – Three-state dependency diagnostics + +- Reworked dependency reporting in `[GAMEASSIST:CORE:OBJECT]` through `_checkDependencies(name)` at line 1033. +- Dependency states: + - `confirmed`: the dependency is positively known to be available. + - `missing`: the dependency is positively known to be absent. + - `unverifiable`: Roll20 did not expose enough script metadata to determine presence. +- `_checkDependencies(...)` returns: + - `status` + - `missing` + - `confirmed` + - `unverifiable` + - `verified` +- Runtime behavior: + - Missing dependencies prevent module enablement or cause startup skip. + - Unverifiable dependencies produce a warning and allow the module to proceed. + - Modules with no declared dependencies report confirmed. +- Current declared external dependencies: + - NPCManager → TokenMod (`dependsOn: ['TokenMod']`, line 2300). + - ConcentrationTracker → TokenMod (`dependsOn: ['TokenMod']`, line 2817). + - NPCHPRoller does not require TokenMod. +- Rationale: + - Earlier binary dependency reporting treated unavailable Roll20 metadata as proof of absence. + - The tri-state model distinguishes evidence from uncertainty and avoids falsely disabling useful modules. + +### Added – Versioned configuration-only snapshots + +- Added snapshot identifiers to `[GAMEASSIST:POLICY]`: + - `configFormat: 'gameassist-config-snapshot'` + - `configSchemaVersion: 1` + - Snapshot policy group begins at line 201. +- Updated `!ga-config list` in `[GAMEASSIST:INTERFACES:COMMANDS]` beginning at line 1350. +- Snapshot includes: + + ```json + { + "format": "gameassist-config-snapshot", + "schemaVersion": 1, + "scope": "configuration-only", + "generatedAt": "", + "version": "0.1.4.2", + "flags": {}, + "globalConfig": {}, + "modules": {} + } + ``` + +- Snapshot includes every bundled module configuration. +- Snapshot excludes: + - runtime caches; + - metrics; + - unknown/orphaned branches; + - arbitrary full `state.GameAssist` data. +- The `GameAssist Config` handout explicitly labels the snapshot scope as configuration-only. +- No import or automatic restore command was added. +- Rationale: + - Versioning the export now creates a stable future validation boundary. + - Import/restore is deferred because unsafe state import could be more damaging than a missing feature. + +### Added – Health reporting + +- Added `getModuleHealth()` in `[GAMEASSIST:INTERFACES:COMMANDS]` at line 1323. +- Added `formatDependencyStatus()` at line 1337. +- Expanded `!ga-status` at line 1487 to report: + - command count; + - event/message count; + - errors; + - average explicit queue-task duration; + - queue length; + - explicit queue mode statement; + - last update; + - total modules; + - configured modules; + - running modules; + - dependency-skipped modules; + - active listeners; + - dependency warnings. +- Expanded `!ga-config modules` to show, per module: + - stored configured state; + - current runtime state; + - dependency status. +- Rationale: + - “Configured” and “running” are not interchangeable. + - A module can be configured on but skipped, failed, or waiting on dependencies. + - Health output should reveal that distinction without requiring state-console inspection. + +### Changed – Bootstrap order and startup diagnostics + +- Updated `[GAMEASSIST:BOOTSTRAP]`, beginning at line 3305. +- Startup order now: + 1. establish core state; + 2. initialize metrics/session timestamp; + 3. clear GameAssist’s internal listener registries; + 4. audit and repair known state; + 5. seed defaults; + 6. deduplicate planned metadata; + 7. run optional compatibility audit; + 8. report core ready; + 9. diagnose dependencies and initialize enabled modules. +- Dependency behavior during startup: + - missing → warn, disable stored enabled state, leave module inactive; + - unverifiable → warn and proceed; + - confirmed → proceed normally. +- Preserved `GameAssist.flags.QUIET_STARTUP = true` default: + - individual module-ready messages remain suppressed by default; + - the core-ready message remains visible. + +### Documented – Previously hidden commands and configuration + +- Documented commands that existed in code but were absent or unclear in older README revisions: + - `!npc-death-clear` at script line 2237; + - `!npc-death-audit` at script line 2244; + - `!ga-config cleanup` at script line 1448; + - `!ga-metrics [reset]`. +- Documented NPCManager configuration: + - `autoHide: false` at line 2154; + - `hideLayer: 'gmlayer'` at line 2155. +- Documented developer metadata and command matching: + - `dependsOn` + - `match.caseInsensitive` + - `match.mode: 'token'` + - `match.mode: 'prefix'` +- Clarified command behavior: + - `!npc-death-report` shows recorded deaths; + - `!npc-death-audit` checks current HP/marker mismatches; + - `!npc-death-clear` clears the recorded report log; + - `!critfumblemenu --pid ` is the internal player-targeted syntax; + - selected DebugTools tokens are used by omitting `--token`; literal `--token select` is not implemented by the parser; + - commands are generally case-insensitive; lowercase is not a universal requirement. + +### Documentation – Full README reconstruction + +- Rebuilt `README-GameAssist-v0.1.4.2.md` as a long-form handbook after the first accurate-but-overly-compact rewrite omitted the original layout and teaching material. +- Restored: + - numbered sections and table of contents; + - TL;DR table; + - architecture explanation and fail-safe table; + - all six module guides; + - installation guide; + - command matrix; + - configuration reference; + - developer API; + - Roll-Table Cookbook; + - macro recipes; + - historical benchmark section; + - detailed troubleshooting; + - upgrade and rollback paths; + - contribution guidance; + - roadmap status; + - changelog summary; + - glossary. +- Corrected old claims that described unsuitable, impossible, or unimplemented behavior: + - removed “zero silent failures” guarantee; + - removed claim that every event is queued; + - removed claim that watchdog kills running tasks; + - removed claim that state audit deletes and reseeds unknown branches; + - removed guaranteed dependency-discovery claim; + - corrected `!ga-config list` from full-state backup language to configuration-only snapshot language; + - corrected startup-message expectations under `QUIET_STARTUP`; + - corrected defaults, syntax, command purpose, and dependency statements. +- Preserved the old benchmark numbers only as explicitly labeled historical v0.1.3-era evidence, not a v0.1.4.2 performance guarantee. + +### Compatibility and behavior boundaries + +| Area | v0.1.4.2 Contract | +| --- | --- | +| Roll20 event hooks | Captures native `on` once through `R20_ON`; does not override global `on` or `off`. | +| Normal handlers | Execute directly through initialized/active guards. | +| Queue | Explicit opt-in plus module lifecycle transitions only. | +| Queue timeout | Releases queue; cannot cancel underlying work. | +| Dependency discovery | Best-effort, tri-state, never guaranteed. | +| Unknown state | Warn and preserve until explicit cleanup. | +| Config export | Versioned configuration-only snapshot; no import. | +| Gameplay modules | Preserved from v0.1.4.1; no new gameplay behavior in this release. | +| Character sheet | Not implemented. | + +### State and migration impact + +- Existing valid module configuration is preserved. +- Known malformed/missing `config` and `runtime` containers are repaired at startup. +- Unknown branches remain intact unless the GM runs `!ga-config cleanup`. +- `!ga-config list` creates a new versioned snapshot shape but does not mutate state. +- No automatic migration removes unknown data. +- No import/restore migration exists. +- Rollback warning: + - replacing the script with v0.1.4.1 rolls back code; + - it does not automatically reverse persistent state changes made while v0.1.4.2 was active. + +### Explicitly not included + +- No implicit queueing of commands/events. +- No cancellation of running JavaScript or Roll20 operations. +- No automatic deletion of unknown state. +- No guaranteed external-script discovery. +- No full-state export/import or snapshot restoration. +- No plugin loader. +- No Rest Manager, encounter tools, cooldown tools, resource tools, condition tools, or location tools. +- No native Mord character-sheet support. +- No new public gameplay command set. + +### Automated verification evidence + +| Verification | Result | What it established | +| --- | --- | --- | +| JavaScript syntax validation | Passed | Release artifact parses successfully. | +| MECHSUITS structural audit | Passed | No missing parents, invalid overlaps, canonical-tree drift, metadata gaps, or missing footers were detected. | +| Existing command simulation | Passed | Core and bundled command surfaces remained callable without simulation exceptions. | +| Confirmed dependency simulation | Passed | Confirmed dependencies report and initialize correctly. | +| Missing dependency simulation | Passed | Missing dependencies skip/refuse the dependent module. | +| Unverifiable dependency simulation | Passed | Unverifiable dependencies warn and proceed. | +| Known-state repair simulation | Passed | Malformed known `config`/`runtime` containers repair. | +| Valid-config preservation | Passed | Existing valid values survive repair. | +| Unknown-state preservation | Passed | Unknown branches remain untouched at startup. | +| Configuration snapshot generation | Passed | Format, schema version, scope, module configs, and exclusions are correct. | +| Health/status reporting | Passed | Configured/running/skipped counts and warnings appear. | +| Explicit queue serialization | Passed | Explicitly enqueued tasks serialize. | +| Queue-timeout release | Passed | Later task proceeds while underlying timed-out work may finish later. | +| Exact marker/lifecycle regression | Passed | Marker and module lifecycle behavior remained intact. | +| Global event-hook audit | Passed | No global `on`/`off` overrides introduced. | + +### Roll20 verification still required + +- Paste `GameAssist-v0.1.4.2.js` into a disposable or controlled Roll20 API sandbox. +- Confirm the API sandbox reloads cleanly. +- Run the release smoke test in `GameAssist-v0.1.4.2-release-notes-and-smoke-test.md`. +- Do not retire the v0.1.4.1 rollback baseline until those tests pass. + +--- + +## [0.1.4.1] – 2026-06-08 + +### Release definition + +v0.1.4.1 is a stability-first update built from the uploaded, stable-but-limping v0.1.4 baseline. It preserves v0.1.4 command and bootstrap behavior, incorporates only isolated safe ideas from the failed attempted v0.1.5 upgrade, and aligns the single-file structure with MECHSUITS v1.5.2 requirements without performing a wholesale rewrite. + +### Development strategy + +- Treated uploaded v0.1.4 as the behavioral baseline. +- Preserved the six-module shape: + - ConfigUI + - CritFumble + - NPCManager + - ConcentrationTracker + - NPCHPRoller + - DebugTools +- Avoided broad bootstrap and interface/event lifecycle restructuring. +- Preserved captured Roll20 `R20_ON` behavior. +- Preserved direct normal handler execution. +- Applied changes at narrow MECHSUITS section granularity. +- Kept a separate rollback copy of v0.1.4. + +### Version and MECHSUITS metadata + +- Advanced Roll20 header, MECHSUITS `project_version`, and runtime `VERSION` to `0.1.4.1`. + - Header: `GameAssist-v0.1.4.1.js` line 4. + - Banner: line 74. + - Runtime version: line 520. +- Preserved literal codename `GAMEASSIST`. +- Audited: + - banner order and prose; + - file-scoped canonical tree; + - section tags and physical nesting; + - `mechsuit_section.codename`; + - section `area`; + - `last_updated_version`; + - Changed/Maintenance footer discipline; + - prior-note preservation; + - required Notes & Comments footers. +- Did not claim that comment-only inherited sections were meaningfully changed. + +### Added – Centralized POLICY ownership + +- Added/expanded `[GAMEASSIST:POLICY]` beginning at line 163. +- Centralized existing values without changing defaults: + - queue default timeout; + - watchdog interval and multiplier; + - metrics history/duration limits; + - runtime cache limits; + - timestamp sanity window; + - ConfigUI page size; + - CritFumble roll delay; + - unsafe configuration keys. +- Used shallow frozen policy groups to reduce accidental runtime mutation. +- Rationale: + - Shared knobs previously lived in multiple sections. + - Central policy ownership makes later changes reviewable and rollbackable. + - Existing values were preserved to avoid changing runtime behavior during the stability release. + +### Added – Time seams and timestamp hardening + +- Added shared wall-clock helper `now()` at line 227. +- Added monotonic duration helper `monotonic()` at line 231. +- Routed human-facing local-time formatting through `localTime(...)` at line 246. +- Added `sanitizeTimestamp(raw, fallback)` at line 443. +- Routed queue duration measurement through `monotonic()`. +- Routed stored timestamps through the shared wall-clock seam and timestamp sanitizer. +- Rationale: + - Wall-clock time is appropriate for human and persistent timestamps. + - Monotonic time is appropriate for durations and timeout measurement. + - Sanitization prevents malformed/future timestamps from corrupting ordered runtime caches. + +### Added – Shared marker helpers + +- Added `normalizeMarkerId(marker)` at line 331. +- Added `tokenHasMarker(token, marker)` at line 335. +- Exact-marker behavior: + - recognizes a configured marker; + - recognizes counted Roll20 marker values such as `dead@2`; + - does not match unrelated marker names such as `deadly`. +- Rationale: + - Substring-style matching can remove or misreport the wrong marker. + - Shared top-level helpers are visible to module teardown functions and avoid the ConcentrationTracker scoping failure identified in the attempted upgrade. + +### Added – Shared runtime self-healing helpers + +- Added `ensureRuntimeObject(modState)` at line 411. +- Added `ensureRuntimeKey(runtime, key, kind)` at line 425. +- Added `ensureModRuntimeKey(modState, key, kind)` at line 438. +- Used shared helpers to keep module runtime caches usable after malformed state or older persisted shapes. +- Rationale: + - Runtime caches are operational and can be safely repaired to known container types. + - Shared helpers reduce duplicated and inconsistent repair code. + +### Changed – Queue timing and stale-completion protection + +- Preserved `_enqueue(...)` in `[GAMEASSIST:CORE:QUEUE]`, beginning at line 564. +- Preserved job-id guards at lines 581 and 593: + + ```js + if (myId !== _jobId) return; + ``` + +- Preserved the timed-out completion guard: + + ```js + if (timedOut) return; + ``` + +- Routed queue timing through POLICY and `monotonic()`. +- Emitted the declared metric name through `recordMetric(POLICY.metrics.queueDurationName, ...)` at line 603. +- Rationale: + - A timed-out job may still finish later. + - Without the job-id and timed-out guards, late completion could advance the queue twice or corrupt busy-state accounting. + +### Changed – Configuration safety and snapshot completeness + +- Updated `!ga-config list` beginning at line 1211 to include: + - runtime version; + - global flags; + - root/global configuration; + - every bundled module configuration. +- Clarified scope: + - “complete” means complete configuration snapshot; + - it does not mean full state, runtime cache, or metrics backup. +- Preserved unsafe-key refusal: + - `BAD_KEYS` sourced from POLICY at line 1242; + - refusal check at line 1248. +- Preserved `enabled=true|false` routing through module lifecycle methods rather than directly changing state. +- Rationale: + - Older snapshot behavior omitted module configs. + - Config export needed to be useful before versioned import could be considered. + - Prototype-related keys remain unsafe and are explicitly rejected. + +### Changed – Shared linked-character validation + +- Preserved/exported `GameAssist.getLinkedCharacter` at line 1139. +- Updated modules to use shared linked-token validation at lines including: + - NPCManager: lines 2008 and 2096; + - ConcentrationTracker: lines 2476, 2555, and 2743; + - NPCHPRoller: lines 2818 and 2865. +- Rationale: + - Modules should not independently assume that tokens are linked, on the Objects layer, or backed by a valid character. + - Shared validation keeps invalid/unlinked/PC token handling consistent. + +### Changed – NPCManager exact marker behavior + +- Updated NPCManager to use exact shared marker matching. +- Preserved: + - `deadMarker: 'dead'`; + - TokenMod calls; + - death log behavior; + - death audit/report commands; + - configured-marker teardown. +- Teardown begins near line 2142 and clears only the configured marker from eligible current-page tokens. +- Counted marker values such as `dead@2` are recognized. +- Unrelated values such as `deadly` are preserved. +- Rationale: + - Disable/teardown should clean up the marker GameAssist owns without damaging unrelated status information. + +### Changed – ConcentrationTracker lifecycle and runtime safety + +- Preserved configured-marker teardown beginning near line 2659. +- Preserved shared marker matching and runtime self-healing. +- Preserved structured `lastDamage` metadata while remaining compatible with legacy numeric values. +- Preserved existing command language: + - `!concentration` + - `!cc` + - `--damage` + - `--mode` + - `--off` + - `--status` + - `--last` + - `--config randomize on|off` + - `!ga-conc-status` +- Rationale: + - The attempted upgrade exposed a serious helper-scope risk in teardown. + - Shared helpers ensure teardown can access the configured-marker logic safely. + +### Changed – DebugTools exact marker behavior + +- Updated DebugTools marker diagnostics to use shared exact marker normalization. +- Marker diagnostics now understand counted markers. +- Preserved: + - disabled-by-default posture; + - dry-run-by-default posture; + - `--apply` requirement for mutations. +- Section change note appears near line 3116. + +### Changed – Captured Roll20 event hooks + +- Preserved captured native Roll20 handler: + + ```js + const R20_ON = ... + ``` + + at line 147. +- Preserved use of `R20_ON` for command/event wrappers and `ready`. +- Did not override global `on` or `off`. +- Rationale: + - Global event-function overrides introduce script-order-dependent interoperability failures with scripts loaded after GameAssist. + - Roll20 does not provide a dependable general-purpose `off()` contract for these handlers. + +### Intentionally excluded from the attempted v0.1.5 upgrade + +- Duplicate trailing script fragment. + - Rejected because duplicated bootstrap/module code could double-register handlers or fail parsing/execution. +- Changed or normalized codename. + - Rejected because MECHSUITS v1.5.2 requires literal owner-authoritative identifier preservation. +- Global `on`/`off` overrides. + - Rejected because they create order-dependent cross-script behavior. +- Weakened queue stale-completion guard. + - Rejected because late completion after timeout could advance the queue twice. +- Broader command matching. + - Rejected because it could make neighboring command names accidentally trigger. +- Wholesale bootstrap/dependency/interface restructuring. + - Rejected because the failed whole-file upgrade did not justify expanding the release’s blast radius. + +### Verification evidence + +| Verification | Result | +| --- | --- | +| JavaScript syntax validation | Passed | +| MECHSUITS structural audit | Passed; no missing parents, invalid overlaps, canonical-tree drift, metadata issues, or missing footers detected | +| Simulated Roll20 startup | Passed with enabled modules wired | +| Core command simulation | Passed without exceptions | +| Unsafe `__proto__` config write | Refused | +| NPC death-marker add/remove simulation | Passed | +| Exact counted marker handling | Passed | +| Unrelated marker preservation | Passed | +| Module disable/enable simulation | Passed | +| Captured native event-hook strategy | Preserved | + +### Rollback posture + +- v0.1.4.1 is preserved unchanged as the rollback baseline for v0.1.4.2. +- Rollback requires replacing the script and re-running health/smoke tests. +- Script rollback does not automatically restore persistent state. + +--- + +## [0.1.4] – Uploaded Stable-But-Limping Baseline + +### Baseline role + +- Served as the source baseline for v0.1.4.1. +- Was treated as stable enough to preserve but not “known-good.” +- Included the six-module structure: + - ConfigUI + - CritFumble + - ConcentrationTracker + - NPCManager + - NPCHPRoller + - DebugTools +- Retained TokenMod-based status changes for NPCManager and ConcentrationTracker. +- Preserved captured `R20_ON` behavior and avoided global `on`/`off` overrides. + +### Added + +- New **ConfigUI** module providing a GM-only chat control panel: + - module enable/disable buttons; + - boolean config toggles; + - pagination; + - `!ga-config ui`; + - `!ga-config-ui`. +- New **DebugTools** module: + - disabled by default; + - GM-only; + - dry-run by default; + - `!ga-debug damage|marker|save`; + - mutations require `--apply`. +- Public `GameAssist.renderConfigUI(playerId, options)` helper. + +### Changed + +- Updated README TL;DR, module guides, command matrix, macro recipes, and configuration reference for ConfigUI and DebugTools. +- Advanced runtime version to 0.1.4. +- Retained queue-guarded module lifecycle hooks. + +### Known baseline limitations carried into the repair line + +- State repair and dependency diagnostics needed clearer, safer semantics. +- Config snapshot behavior and documentation needed correction. +- Marker matching required exact shared helpers. +- Some commands/configuration existed but were not fully documented. +- README described queue/watchdog/state/dependency guarantees more strongly than the Roll20 environment could support. + +> No authoritative shipment date was recorded in the supplied changelog. This entry records baseline provenance. + +--- + +## [0.1.3] – Detailed Historical Development Record + +This section preserves the supplied granular v0.1.3-era development record, including implementation locations and replaced behavior. The referenced line numbers belong to the historical artifact described by those notes and are retained for audit value. + +### Added – Persisted session metrics + +- Added persisted session metrics with GM summary/reset command. + - Added `GameAssist` lines 288-420 for the metrics store helpers: + - `createMetricsStore` + - `ensureStateRoot` + - `recordMetric` + - Instrumented wrappers at lines 683-742, 796-844, and 1049-1119 to log: + - commands; + - events; + - queue tasks; + - module toggles; + - errors. + - Exposed `!ga-metrics`. + - Updated the task queue finalizer at lines 197-214 to retain only the latest durations and feed the metrics ring buffer. + - Exported `getMetricsStore` and `recordMetric` on the public API at lines 927-932. +- Documentation: + - README TL;DR, Command Matrix, and Troubleshooting sections documented `!ga-metrics`. + - `script.json` listed the new command and staged version bump. + +### Added – Shared token-to-character resolver + +- Introduced a shared resolver so modules validate Roll20 objects before use. + - Added `GameAssist` lines 343-353: + + ```js + function getLinkedCharacter(token) { ... return { token, character }; } + ``` + + - Added public export at line 836: + + ```js + GameAssist.getLinkedCharacter = getLinkedCharacter; + ``` + + - Added module usage at historical lines 1213, 1261, 1540, 1611, 1783, 1839, and 1886 so NPCManager, ConcentrationTracker, and NPCHPRoller consistently gate work on verified tokens. + - Removed duplicated per-module inline checks from the prior implementation: + - pre-update line 1156: `const charId = token.get('represents');` + - pre-update line 1168: `const character = getObj('character', charId);` + - pre-update line 1362: `const charId = token.get('represents');` + +### Added – NPCHPRoller auto-roll on token add + +- Added opt-in automatic HP rolling for newly created NPC tokens through `autoRollOnAdd`. + - Added historical lines 1801-1869 to reuse a shared NPC context resolver. + - New-token handling silently skips non-NPC or invalid tokens. + - Automatic rolls are annotated in logs. + - Added `add:graphic` listener metadata at historical lines 1948-1956. + - Updated README module/config documentation. +- Default remained `false` to avoid event noise and unintended token mutation. + +### Changed – Guard-based module handler lifecycle + +- Core handler lifecycle moved from physical `off()` calls to module guard flags. + - Added historical lines 609-620 to store: + - `initialized` + - `active` + - `dependsOn` + - `wired` + - `internal` + - Added handler guards at historical lines 627 and 646-647: + + ```js + if (!MODULES[mod]?.initialized || !MODULES[mod]?.active) return; + ``` + + - Preserved the READY gate for normal event handling. + - Removed prior minimal registration: + + ```js + MODULES[name] = { initFn, teardown, enabled, initialized: false, events, prefixes }; + ``` + + - Removed attempted physical unbinding: + + ```js + (this._commandHandlers[mod] || []).forEach(h => off(h.event, h.fn)); + (this._listeners[mod] || []).forEach(h => off(h.event, h.fn)); + ``` + + - `offCommands` and `offEvents` became logical registry clearing rather than claims of Roll20 listener detachment. + +### Changed – Serialized module enable/disable with dependency guards + +- Added `_transitioning` checks and queued lifecycle execution across historical lines 718-807. +- Added rollback on initialization failure at historical lines 745-755. +- Added dependency verification helper at historical lines 673-701. +- Added dependency checks inside `enableModule` and bootstrap at historical lines 723-731 and 1945-1955. +- Removed the old eager teardown/clear path: + - pre-update line 502: `this.offEvents(name);` + - pre-update line 503: `this.offCommands(name);` + - pre-update line 504: `clearState(name);` + - pre-update line 505: `getState(name).config.enabled = true;` + - removed the analogous disable block at pre-update lines 520-524. + +### Changed – State audits became non-destructive + +- Added whitelist behavior across historical lines 305-321 so unexpected keys warn without destructive deletion. +- Removed pre-update lines 294-301 that executed: + + ```js + delete root[k]; + ``` + + for unknown or malformed branches. +- This established the safety rule later expanded in v0.1.4.2: + - known shapes may be repaired; + - unknown branches are preserved unless explicitly cleaned. + +### Changed – State helpers exposed through public API + +- Added historical lines 833-835: + - `GameAssist.getState` + - `GameAssist.saveState` + - `GameAssist.clearState` +- Updated module initializers at historical lines 958, 1203, 1324, and 1741 to call `GameAssist.getState(...)`. +- Removed direct internal accessor usage from the previous revision: + - pre-update line 648: `const modState = getState('CritFumble');` + - pre-update line 893: `const modState = getState('NPCManager');` + - pre-update line 996: `const modState = getState('ConcentrationTracker');` + - pre-update line 1325: `const modState = getState('NPCHPRoller');` + +### Changed – Compatibility audit scoring + +- Added signature catalog and scoring routine across historical lines 377-518. +- Added summary rows and hints for known/unknown scripts. +- Replaced earlier summary-only logging at pre-update lines 354-357, which reported only known/unknown lists and planned hooks. +- Compatibility output remained gated by `GameAssist.flags.DEBUG_COMPAT`. + +### Changed – ConcentrationTracker structured runtime data + +- Added structured storage at historical lines 1559-1569. +- Added skipped-token reporting in `handleClear` at historical lines 1600-1624. +- Added `!ga-conc-status` wiring at historical lines 1691-1698. +- Removed previous single-number storage: + + ```js + modState.runtime.lastDamage[msg.playerid] = damage; + ``` + +- Removed silent marker clearing: + + ```js + if (t) toggleMarker(t, false); + ``` + +- Structured metadata included damage, DC, mode, token/character IDs, bonus, player, and timestamp. + +### Changed – NPCManager configured-marker teardown + +- Added teardown across historical lines 1308-1331. +- Teardown removes the configured marker through TokenMod and reports the number cleared. +- Replaced behavior where disabling NPCManager could leave stale GameAssist-owned death markers on tokens. + +### Changed – Chat sanitization and planning utilities + +- Added quote escaping at historical line 340: + + ```js + .replace(/"/g, '"'); + ``` + +- Added `_dedupePlanned` guard at historical line 667: + + ```js + if (this._deduped) return; + ``` + +- Replaced unconditional deduplication from pre-update lines 493-495. +- Rationale: + - quoted text should not break Roll20 whisper HTML; + - planned metadata should not grow repeatedly across reload-like flows. + +### Changed – Bootstrap dependency and failure handling + +- Added dependency checks and active-flag management at historical lines 1945-1973. +- Set `initialized` and `active` based on actual startup success. +- Removed unconditional initialization loop from pre-update lines 1491-1499: + + ```js + Object.entries(MODULES).forEach(...) + ``` + +- Failed or dependency-blocked modules remain inert instead of appearing active. + +### Added – Staged v0.1.3 helper and dependency work + +- Added compatibility audit scoring with signature-based hints for: + - TokenMod + - ScriptCards + - APILogic +- Added shared helpers: + - `GameAssist.createButton(label, command)` + - `GameAssist.rollTable(tableName)` +- Added GM command `!ga-conc-status`. +- Added declared `dependsOn` checks for module enablement. +- Added structured ConcentrationTracker metadata used by the status report. + +### Documentation + +- Clarified state-auditor commentary so unexpected branches are documented as warning-only. +- Updated README for: + - metrics; + - developer helpers; + - compatibility scoring; + - dependency guardrails; + - `!ga-conc-status`; + - NPCHPRoller `autoRollOnAdd`. + +> No authoritative shipment date was recorded in the supplied development notes. + +--- + +## [0.1.2] – 2025-09-16 + +### Packaging & Repository Compliance (Roll20 API Repo) + +- **Standard Header Added:** Inserted the Roll20-required top-of-file comment containing: + - name; + - version; + - last-updated date; + - description; + - syntax/configuration pointers. +- **One-Click Artifacts:** Added: + - `script.json`; + - repository-focused `README.md`; + - `GameAssist/` folder layout suitable for a `roll20-api-scripts` pull request. +- **Dependencies & Tables:** Declared TokenMod usage and documented the exact CritFumble rollable-table names: + - `CF-Melee` + - `CF-Ranged` + - `CF-Thrown` + - `CF-Spell` + - `CF-Natural` + - `Confirm-Crit-Martial` + - `Confirm-Crit-Magic` + +### MECHSUITS v1.5 Structural Wrap (No Runtime Changes) + +- **Framing Only:** Introduced: + - MECHSUITS YAML banner; + - canonical tree; + - `[CODENAME:AREA] BEGIN/END` section frames; + - section notes for maintainability and reviewability. +- **Behavior Parity:** No functional changes; commands and modules remained behaviorally aligned with v0.1.1.2. + +### Version & Metadata + +- **Version Bump:** Updated version to `0.1.2`. +- **State/Migration:** No migration; `state.GameAssist` structure remained unchanged. --- @@ -9,10 +963,10 @@ All notable changes to this project will be documented in this file. ### CritFumble Module - **Natural 1 Detection Bugfix:** - Refactored the `hasNaturalOne` function to robustly detect natural 1s on all d20 attack rolls, regardless of template complexity or non-standard inline roll formats. This eliminates `"Cannot read properties of undefined (reading 'r')"` errors and ensures that all valid attack rolls are properly checked for critical fumbles. + Refactored `hasNaturalOne` to robustly detect natural 1s on d20 attack rolls across template complexity and non-standard inline-roll shapes. This removed `"Cannot read properties of undefined (reading 'r')"` failures and ensured valid attack rolls could be checked without assuming every result contains `.r`. - **GM Visibility Improvement:** - The ❓ **Confirm Critical Miss** confirmation menu is now whispered to both the GM and the player, not just the player. This makes GM oversight consistent across all critical miss event prompts. + Whispered the **Confirm Critical Miss** confirmation menu to both the GM and the player, rather than only the player, so GM oversight remains consistent. --- @@ -21,36 +975,818 @@ All notable changes to this project will be documented in this file. ### Core Framework - **Quiet Startup Option:** - Added `flags.QUIET_STARTUP` (default: `true`). GMs can now silence per-module “Ready” chat lines during sandbox boot, except for the single Core summary line. + Added `flags.QUIET_STARTUP`, default `true`. Per-module “Ready” chat lines may be suppressed while the core summary remains visible. - **Logging Improvements:** - - Re-implemented `GameAssist.log` for better output and log hygiene. - - Logs now automatically escape user text, split multiline output into properly prefixed `/w gm` blocks, and preserve message order and formatting. - - `log()` accepts `{ startup: true }` metadata, so modules can control which messages are suppressed by QUIET_STARTUP. + - Re-implemented `GameAssist.log` for clearer output and log hygiene. + - Escaped user text. + - Split multiline output into properly formatted GM whisper content. + - Preserved message order and formatting. + - Added `{ startup: true }` metadata so modules can mark suppressible ready messages. - **Core-Ready Announcement:** - - The core “ready” message is never suppressed, even in QUIET_STARTUP mode. + The core ready message remains unsuppressed even when quiet startup is enabled. - **Status Command Update:** - - `!ga-status` now uses real newline characters for clearer output. - - Output remains grouped in a single whisper for better readability. + - `!ga-status` uses real newline characters. + - Output remains grouped into one GM whisper. - **Module Announcements:** - - All bundled modules (CritFumble, NPCManager, ConcentrationTracker, NPCHPRoller) now mark their “Ready” lines with `{ startup:true }` so they respect QUIET_STARTUP. - - NPCHPRoller conforms to this output pattern. + - CritFumble, NPCManager, ConcentrationTracker, and NPCHPRoller marked their ready messages with `{ startup: true }`. + - NPCHPRoller adopted the shared startup-output pattern. - **Summary:** - No functional changes to gameplay. All updates focus on GM chat output quality-of-life, reducing log clutter, and clarifying startup diagnostics. + No intended gameplay changes. Work focused on GM chat quality, reduced startup noise, and clearer diagnostics. --- ## [0.1.1.0] – 2025-05-29 - Initial public release of GameAssist. -- Bundled the core loader with four modules: CritFumble, NPCManager, ConcentrationTracker, and NPCHPRoller. -- Laid foundation for future modular expansion and customization. +- Bundled the core loader with four modules: + - CritFumble + - NPCManager + - ConcentrationTracker + - NPCHPRoller +- Established the foundation for later modular expansion and customization. + +--- + +## Historical Staging Labels Preserved + +The supplied predecessor changelog used these labels before the v0.1.4.1/v0.1.4.2 repair line was created: + +- `[Unreleased]` for much of the detailed v0.1.3-era lifecycle, metrics, state, compatibility, and module work. +- `[Staging] 0.1.4 (blocked by 0.1.3 compliance)` for ConfigUI and DebugTools. +- `[Staging] 0.1.3 (MECHSUITS compliance gate)` for compatibility hints, helpers, dependency checks, and structured concentration data. + +Those labels are no longer the current release-status statement, but their detailed implementation records have been retained in the relevant sections above. The attempted v0.1.5 upgrade remains explicitly unshipped. + +--- + +*This changelog records implementation history, rationale, limitations, verification, and release posture. Roadmap ideas and failed upgrade attempts are never presented as shipped features.* + +--- + +## Append-Only Maintenance Policy — Adopted 2026-06-10 + +This section governs changelog entries added after v0.1.4.2. + +- Published entries are preserved as historical records and are not silently rewritten. +- Each new release entry is a curated record of changes since the preceding release entry. +- Corrections to an older entry are appended as a dated correction record that identifies the superseded claim. +- Roadmap work belongs in `ROADMAP.md`; installation and troubleshooting procedures belong in `Smoketest.md`. +- An older entry may receive an appended summary after it is at least three major-version releases behind the current release. The original detailed entry remains preserved. +- Release status, verification results, and artifact names are stated as repository facts rather than internal work narration or editorial judgments. + +### Corrections to v0.1.3.x–v0.1.4.2 records + +The following corrections supersede inaccurate or temporary wording in the preserved entries above: + +| Preserved wording or reference | Corrected record | +| --- | --- | +| `GameAssist-v0.1.4.2.js` | The tracked artifact is `GameAssist-v0.1.4.2`. Its SHA-256 is `038B07B292E09981BD56564D83F5900353BDC1BDA0D39FDD4CB63A1DBE80CAC4`. | +| `GameAssist-v0.1.4.1.js` | No v0.1.4.1 script is retained in the repository. Its historical checksum cannot be verified from the repository contents. | +| `README-GameAssist-v0.1.4.2.md` | The tracked handbook is `README.md`. No version-named v0.1.4.2 README is retained. | +| `GameAssist-v0.1.4.2-release-notes-and-smoke-test.md` | The tracked installation and troubleshooting checklist is `Smoketest.md`. The named release-specific file is not retained. | +| “Mocked Roll20 sandbox” | These checks were simulated Roll20-environment checks, not executions inside the Roll20 API sandbox. | +| “Release candidate,” “stable-but-limping,” “attempted upgrade,” and similar labels | These phrases describe development context recorded at the time. They are not current release-status classifications. | +| “Truthful” or “honest” reporting claims | The durable contract is the specific behavior documented by the corresponding entry, such as configured/running/dependency-skipped counts or three-state dependency reporting. | +| v0.1.4.2 Roll20 confirmation language | The recorded v0.1.4.2 sandbox pass exposed the custom concentration-marker recognition failure addressed by v0.1.4.3. No complete passing v0.1.4.2 sandbox result is recorded. | + +### Current release index + +| Revision | Status | Repository role | +| --- | --- | --- | +| **v0.1.4.5** | Pre-release; automated verification complete, Roll20 smoke confirmation pending | NPC death-history buckets, handouts, and arc notes | +| **v0.1.4.4** | Previous complete script | DM-facing CritFumble help and NPC death-audit readability update | +| **v0.1.4.3** | Previous complete script | Concentration custom-marker recognition and standalone TokenMod interoperability update | +| **v0.1.4.2** | Previous complete script | Diagnostic and migration-readiness release with a known concentration custom-marker limitation | +| **v0.1.4.1** | Historical release; script not retained | Stability-focused repair based on v0.1.4 | +| **v0.1.4** | Historical baseline | Preserved as `GameAssist v0.1.4` | +| **Unreleased v0.1.5 prototype** | Not released | Review source for selected fixes and architecture planning | + +--- + +## [0.1.4.3] – 2026-06-10 + +### Release definition + +v0.1.4.3 improves standalone TokenMod interoperability by resolving configured marker names to the exact marker identities Roll20 stores on tokens. The update focuses on ConcentrationTracker status reporting and marker lifecycle requests while preserving the v0.1.4.x external dependency model. + +MarkerService and integrated TokenMod remain assigned to the v0.1.5.x roadmap. + +### Release artifacts + +| Artifact | Purpose | SHA-256 | +| --- | --- | --- | +| `GameAssist-v0.1.4.3` | Versioned v0.1.4.3 script | `4C95BB9408A86BE45E7F5AC4A2726B932A6D408ADF834E90D5B7A1E161C48971` | +| `GameAssist` | Current repository script; identical to `GameAssist-v0.1.4.3` | `4C95BB9408A86BE45E7F5AC4A2726B932A6D408ADF834E90D5B7A1E161C48971` | +| `GameAssist-v0.1.4.2` | Previous complete script | `038B07B292E09981BD56564D83F5900353BDC1BDA0D39FDD4CB63A1DBE80CAC4` | + +### Root cause addressed + +- Roll20 stores a custom marker display name such as `Concentrating` as a token marker tag such as `Concentrating::7191835`. +- v0.1.4.2 compared the configured display name directly with the stored tag. +- A token visibly carrying the configured custom marker could therefore be omitted from `!concentration --status`. +- `deps unverifiable (TokenMod)` was not the cause of the status-read failure. Status reporting reads token markers directly; TokenMod is used when GameAssist requests marker mutation. + +### Changed — Shared marker identity resolution + +- Added a cached reader for Roll20's campaign custom-marker registry in `[GAMEASSIST:APP:UTILS]`. +- Added structured marker resolution for: + - lowercase built-in marker ids such as `dead`; + - custom marker display names such as `Concentrating`; + - exact stored custom marker tags such as `Concentrating::7191835`; + - counted marker values such as `Concentrating::7191835@3`. +- Preserved lowercase built-in-marker precedence so a custom marker named `dead` does not replace NPCManager's built-in default. +- Allowed a colliding custom marker to be selected by its complete stored tag. +- Updated `tokenHasMarker(...)` to compare exact resolved marker identities. +- Added fast paths for already-resolved custom tags and literal built-in ids. +- Exact stored custom marker tags now resolve before campaign-registry access, so a valid configured tag such as `Concentrating::7191835` remains usable when Roll20's marker registry is unavailable or malformed. +- Simple matching quote pairs are stripped before marker resolution, so chat-configured values such as `"red"` and `'red'` resolve as `red`. +- Returned explicit resolution failures for unrecognized configured markers. + +### Changed — ConcentrationTracker status and lifecycle diagnostics + +- `!concentration --status` now: + - lists current-page tokens carrying the resolved configured marker; + - returns `No tokens concentrating.` when no matching tokens are present; + - reports when the current player page cannot be determined; + - reports an unrecognized configured marker and provides configuration repair syntax; + - logs a warning when a display name matches multiple custom markers. +- Concentration marker add, remove, and teardown requests now send TokenMod the resolved stored marker tag. +- `!concentration --off` reports that marker removal was requested rather than claiming the asynchronous TokenMod operation completed. +- Teardown stops and logs a warning when the configured marker cannot be resolved. + +### Changed — NPCManager death-marker mutation + +- NPCManager now resolves the configured death marker before TokenMod add, remove, and teardown requests. +- The default built-in `dead` marker still emits `statusmarkers|+dead` and `statusmarkers|-dead`. +- Built-in color markers such as `red` work even when the saved configuration contains simple wrapping quotes. +- A configured custom display name now emits the exact stored custom marker tag when Roll20's marker registry can resolve it. +- A configured exact custom tag, such as `Dead Custom::abc123`, remains usable even when Roll20's marker registry is unavailable or malformed. +- If the configured death marker cannot be resolved, NPCManager warns instead of sending a misleading TokenMod request or logging a death as completed. + +### Documentation and changelog maintenance + +- Updated `README.md`, `ROADMAP.md`, and `Smoketest.md` for v0.1.4.3 behavior and validation. +- Added focused concentration-marker checks to `Smoketest.md`. +- Separated release records, roadmap plans, and installation/troubleshooting procedures. +- Adopted the append-only changelog policy above. +- Added appended corrections for inaccurate artifact names and temporary development terminology in preserved v0.1.3.x–v0.1.4.2 records. + +### MECHSUITS changes + +- Advanced the file header, banner `project_version`, prose guarantee, visual version, and runtime `VERSION` to `0.1.4.3`. +- Applied the Meaningful Change Rule to: + - `[GAMEASSIST:APP]` + - `[GAMEASSIST:APP:UTILS]` + - `[GAMEASSIST:CORE]` + - `[GAMEASSIST:MODULES]` + - `[GAMEASSIST:MODULES:NPCMANAGER]` + - `[GAMEASSIST:MODULES:CONCENTRATIONTRACKER]` +- Recorded maintenance-only commentary updates in: + - `[GAMEASSIST:POLICY]` + - `[GAMEASSIST:INTERFACES:COMMANDS]` + - `[GAMEASSIST:MODULES:CRITFUMBLE]` +- Preserved literal codename `GAMEASSIST`, existing section tags, and prior notes. +- Confirmed paired tags, proper nesting, and canonical-tree agreement. + +### Compatibility and state impact + +| Area | v0.1.4.3 behavior | +| --- | --- | +| TokenMod | Remains a separately installed dependency responsible for requested marker mutations. | +| Marker reads/writes | GameAssist reads token markers directly and resolves built-in ids, custom display names, and stored custom tags before marker-dependent read/write decisions. | +| MarkerService | Not included. | +| Integrated TokenMod | Not included. | +| Public commands | Existing v0.1.4.2 command language is preserved. | +| Persistent state | No migration is required; existing ConcentrationTracker configuration remains valid. | +| Rollback | Replacing the script with `GameAssist-v0.1.4.2` restores the previous marker-name comparison behavior. | + +### Verification results + +| Verification | Result | Coverage | +| --- | --- | --- | +| JavaScript syntax validation | Passed | The current script parses successfully. | +| Current/versioned script identity | Passed | `GameAssist` and `GameAssist-v0.1.4.3` are byte-identical. | +| MECHSUITS structural audit | Passed | Section pairing, nesting, metadata, footers, and canonical tree agree. | +| Simulated Roll20-environment checks | Passed | Empty status, custom and counted markers, built-in markers, exact custom tags, exact custom tags with registry failures, NPC death-marker add command generation, invalid-marker diagnostics, disabled-module diagnostics, and TokenMod teardown command generation. | +| Roll20 API sandbox | Not recorded | Installation and module validation procedures are documented in `Smoketest.md`. | + +### Exclusions + +- No MarkerService module. +- No integrated TokenMod or StatusInfo module. +- No change to the v0.1.4.x dependency model. +- No configuration import or full-state restore. +- No new gameplay modules. +- No native Mord character-sheet support. + +--- + +## [0.1.4.4] – 2026-07-17 + +### Release definition + +v0.1.4.4 is a small DM-facing readability release. It preserves the v0.1.4.3 marker-recognition and standalone TokenMod interoperability fixes while improving two chat outputs identified in Issue #21. + +### Issue addressed + +- [#21](https://github.com/Mord-Eagle/GameAssist/issues/21) — Make CritFumble help and NPC death-audit output human-readable. + +### Changed — DM-facing command output + +- Revised `!critfumble help` from a command list into a quick reference with an `Open Natural 1 Menu` button, common commands, attack types, and exact setup table names. +- Added `!critfumble menu` as the public CritFumble-family command for opening the guided Natural 1 dialogue. +- Added bare `!critfumble` as a help alias, so entering the feature command without a subcommand opens the guide instead of silently doing nothing. +- Preserved `!critfail` as the direct GM player-picker command. +- Preserved existing CritFumble command syntax: + - `!critfail` + - `!critfumble` + - `!critfumble help` + - `!critfumble menu` + - `!critfumble-TYPE` + - `!confirm-crit-martial` + - `!confirm-crit-magic` +- Changed `!npc-death-audit` from multiple line-by-line log messages into one grouped GM report. +- Added an audit `Scope` row stating that linked NPC tokens are checked and player characters are not included. +- Grouped audit mismatches by action: `Add Marker` for dead NPCs missing the configured marker, and `Clear Marker` for living NPCs still carrying it. +- Preserved useful mismatch details: NPC name, HP, current markers, and token ID. +- Bounded each audit mismatch detail group while preserving total counts, so crowded pages do not produce one oversized Roll20 chat payload. +- Kept unrelated unlinked-page-item notes as informational context, so party markers, scenery, labels, and props do not read like errors. + +### Documentation + +- Updated `Smoketest.md` so DMs no longer need to reinterpret the old audit success message. +- Updated the CritFumble smoke test to check the quick-reference help panel, the bare `!critfumble` help alias, the `!critfumble menu` guided dialogue, and the unchanged `!critfail` player picker. +- Updated `README.md` to describe the quick reference, guided menu, direct player picker, and the meaning of a clean NPC death audit. +- Updated `ROADMAP.md` so #21, #22, and #23 precede the #24 standalone-interoperability umbrella, with #32 explicitly deferred after the existing issue queue. + +### Review fixes + +- Hardened `!critfumble help` and `!critfumble menu` matching so extra internal whitespace is accepted. +- Hardened direct fumble rolls so mixed-case commands such as `!CritFumble-melee` resolve the intended fumble type. +- Added a POLICY-owned `npcAuditDetailLimit` cap for grouped NPC death-audit reports. +- Corrected `script.json` so `script` points to the repository's actual `GameAssist` artifact instead of nonexistent `GameAssist.js`. +- Added `!critfumble help` and `!critfumble menu` to the script metadata command list. + +### MECHSUITS records + +- Updated `[GAMEASSIST:MODULES:CRITFUMBLE]` because the public help output changed. +- Updated `[GAMEASSIST:MODULES:NPCMANAGER]` because the public audit success output changed. +- Updated `[GAMEASSIST:POLICY]` because the NPC death-audit detail cap is a runtime behavior knob. +- Updated `[GAMEASSIST:CORE]` because the runtime `VERSION` constant advanced. +- Preserved existing section tags, codename `GAMEASSIST`, and command names. + +### Release artifacts + +The v0.1.4.3 artifact remains preserved. The current repository script and the new v0.1.4.4 versioned artifact share: + +| Artifact | SHA-256 | +| --- | --- | +| `GameAssist` | `DB68D6467D698FEF25D416394FFD07F6F44EF2E2361D9DBA9F198A0DAA41D091` | +| `GameAssist-v0.1.4.4` | `DB68D6467D698FEF25D416394FFD07F6F44EF2E2361D9DBA9F198A0DAA41D091` | + +### Verification + +| Check | Result | +| --- | --- | +| `node --check .\GameAssist` | Passed | +| `GameAssist-v0.1.4.4` syntax via stdin check | Passed | +| `GameAssist` versus `GameAssist-v0.1.4.4` byte identity | Passed | +| `git diff --check` | Passed | + +Roll20 API sandbox confirmation is still required for the final release gate. + +--- + +## [0.1.4.5] – 2026-07-17 + +### Release definition + +v0.1.4.5 is an NPCManager usability and campaign-notes release for Issue #22. It keeps the `v0.1.4.x` standalone TokenMod architecture, but changes death history from a single chat-oriented log into hierarchical DM-facing handout buckets: Campaign, Chapter, Section, and Session. It also adds a report writer and curated Arc rosters that sit outside that hierarchy. NPCManager advances to module version `1.1.0` because its state model, report workflow, clear behavior, date handling, and Arc-management surface changed substantially even though the GameAssist project version remains a patch release. + +### Issue addressed + +- [#22](https://github.com/Mord-Eagle/GameAssist/issues/22) — Add summarized and named NPC death-log pools. + +### Changed — scoped NPC death buckets + +- Advanced `[GAMEASSIST:MODULES:NPCMANAGER]` independent `module_version` from `0.1.1.0` to `1.0.0` for the bucket-state redesign, then to `1.1.0` for report writing, hierarchical clearing, date rollover, and curated Arc management. +- Added active death-history bucket names under NPCManager config: + - `campaign` + - `chapter` + - `section` + - `session` +- Default bucket names are intentionally simple: + - Campaign: `Campaign` + - Chapter: `Chapter` + - Section: `Section` + - Session: current date in `YYYY-MM-DD` form when no saved session name exists. +- Added `!npc-death-buckets` as the GM-facing bucket control panel. +- Added bucket rename support: + - `!npc-death-buckets --campaign "Name"` + - `!npc-death-buckets --chapter "Name"` + - `!npc-death-buckets --section "Name"` + - `!npc-death-buckets --session "Name"` + - `!npc-death-buckets --resetSession` +- Changing a bucket name starts or resumes that named bucket. Existing bucket records and handouts are retained instead of being deleted. +- Every newly recorded NPC death is copied into all four active buckets so Session history can be cleared while Chapter, Section, and Campaign history remain available. +- Added a lazy date-boundary check before every NPCManager command and tracked NPC HP event. A date-managed Session moves to the current sandbox/UTC `YYYY-MM-DD` name before new activity is processed; prior dated buckets and handouts remain available. +- Explicitly named Sessions remain active across date changes. `!npc-death-buckets --resetSession` restores the current UTC date and re-enables automatic date-managed rollover. +- Tracked DM-configurable timezone formatting and date boundaries separately in [Issue #35](https://github.com/Mord-Eagle/GameAssist/issues/35). v0.1.4.5 does not reinterpret historical timestamps. + +### Changed — death recording and revival handling + +- Death recording no longer depends on TokenMod marker-write success. +- When a linked NPC drops below 1 HP: + - GameAssist records the death in the active buckets; + - GameAssist requests the configured death marker through TokenMod when the marker is not already present; + - `autoHide` behavior is preserved. +- Duplicate open death entries are avoided while an NPC already has an unrevived record. +- When a linked NPC rises above 0 HP: + - GameAssist annotates the most recent matching unrevived death entry; + - the annotation is applied across stored buckets and arc entries where a matching entry exists; + - GameAssist requests marker removal through TokenMod when the marker is present. +- Revival annotations are saved even when an invalid marker configuration or failed TokenMod request prevents visual marker removal. +- Revival annotations preserve history instead of silently deleting the death entry. + +### Changed — death reports and handouts + +- `!npc-death-report` now opens the active Session bucket by default. +- Added bucket scope selection: + - `!npc-death-report --scope campaign` + - `!npc-death-report --scope chapter` + - `!npc-death-report --scope section` + - `!npc-death-report --scope session` +- Preserved bounded detail views: + - `!npc-death-report --recent` + - `!npc-death-report --page N` + - `!npc-death-report --help` +- Changed `!npc-death-report --write` to open the report writer before any handout is changed. +- Added `!npc-death-write` and the case-insensitive shorthand `!NPC-WR`. +- Added report-writer actions: + - `!npc-death-write --all` updates all four active bucket handouts; + - `!npc-death-write --scope campaign|chapter|section|session` updates one active handout; + - `!npc-death-write --newSection "Name"` starts or resumes a Section, copies only missing current-Session deaths into it, and updates the Section and Session handouts without rewriting Campaign or Chapter. +- Each bucket writes to one handout named by scope and bucket name, for example: + - `GameAssist Deaths - Campaign - Campaign` + - `GameAssist Deaths - Chapter - Chapter` + - `GameAssist Deaths - Section - Section` + - `GameAssist Deaths - Session - 2026-07-17` +- Existing legacy `runtime.deathLog` entries are backfilled into active buckets when the bucket system first reads old state and no bucket entries exist. +- The legacy `deathLog` array is retained for compatibility and duplicate/open-death detection. + +### Changed — audit output + +- `!npc-death-audit` remains the current-page HP/marker mismatch checker. +- Chat output is now a short summary with: + - mismatch count; + - explicit scope statement; + - configured marker; + - count of NPCs needing a marker; + - count of NPCs needing marker removal; + - count of ignored unlinked page items. +- Complete audit rows write to the `GameAssist NPC Death Audit` handout. +- Chat retains bounded, grouped token details under `Add Death Marker` and `Remove Death Marker`, including each affected token's HP, current markers, and token ID. +- The audit continues to check linked NPC tokens only; player characters are intentionally excluded. + +### Added — manual arc handouts + +- Added `!npc-death-arc` as the GM-facing arc help/list panel, keeping the command within NPCManager's `!npc-death-*` naming family. +- Added manual selected-token capture: + - `!npc-death-arc --name "Arc Name"` + - selected linked PC and NPC tokens are appended to `GameAssist Arc - Arc Name`. +- Added session import: + - `!npc-death-arc --name "Arc Name" --session` + - current Session bucket deaths are appended without duplicating entries already imported into that arc. +- Added optional note support for selected-token entries: + - `!npc-death-arc --name "Arc Name" --note "Text"` +- Arc buckets are independent story-note handouts; they do not sit inside Campaign, Chapter, Section, or Session. +- Arc entries now deduplicate by linked creature by default. A selected token that later appears in a full Session import remains one entry and may be enriched with the matching death record. +- Added `--allowDuplicates` as an explicit override when repeated Arc entries are intentional. +- Added `!npc-death-arc --name "Arc Name" --manage` with paged entry controls. +- Added one-entry Remove buttons, `--removeSelected` for selected-token cleanup, and `--undo` for the most recent Arc addition or merge. +- Arc corrections change only the Arc roster and handout; they do not alter Campaign, Chapter, Section, or Session history. + +### Changed — review hardening + +- Removed the unused init-time Session entry from `DEFAULT_BUCKET_NAMES`. Session defaults continue to call `currentSessionDateKey()` when needed, preserving date rollover while avoiding a misleading frozen-date fallback. +- Added opt-in `preserveRuntimeOnDisable` module-registration metadata. Existing modules continue clearing disposable runtime caches by default. +- NPCManager enables runtime retention so disabling marker automation no longer erases saved Campaign, Chapter, Section, Session, or Arc records; configured-marker teardown still runs. +- Open-death deduplication now requires an exact token ID. Legacy name-only entries remain available in reports but cannot suppress a new death for a different same-named NPC. +- Persisted the legacy death-log migration completion flag in NPCManager runtime state so the migration does not repeat on every command. +- Batched legacy migration handout writes to one update per scope after all retained entries are copied. +- Matched current death records by token ID before using the name-only fallback reserved for legacy entries without token IDs. This keeps separate same-named NPC tokens from sharing one death or revival record. +- Preserved open-death detection across retained buckets after a scoped Session clear, preventing a still-dead NPC from being recorded again in Campaign, Chapter, or Section after another below-zero HP edit. +- Preserved an exact selected-token HP value of `0` in arc state instead of treating it as absent. +- Limited arc revival annotations to entries imported from death history. Ordinary selected-token story notes remain unchanged during later positive-HP edits. + +### Added — NPCManager start-here help + +- Added `!npc-death-help` as the top-level NPCManager help menu. +- The help menu shows: + - start-here steps for DMs; + - a plain-language explanation of Campaign, Chapter, Section, and Session; + - active bucket names and counts; + - direct buttons for reading, writing, clearing, audit, and Arc workflows; + - compact expert command examples. +- Rebuilt `!npc-death-report --help` as the central NPCManager guide and made `!npc-death-help` open the same guide. +- The guide explains the four-level hierarchy, active names and counts, report writing, selected-only versus nested clearing, Arc management, and audit scope with direct action buttons. + +### Changed — safer bucket clearing + +- The default clear target remains Session. +- Every clear confirmation offers the selected bucket alone. +- Campaign, Chapter, and Section confirmations also offer `--nested`, which clears the selected level and every descendant: + - Campaign and below: Campaign, Chapter, Section, Session; + - Chapter and below: Chapter, Section, Session; + - Section and below: Section, Session. +- Parent levels above the selected level are retained. +- Clearing any set that includes Session also clears the retained legacy `deathLog` mirror. +- Adjacent boolean switches now parse independently, so displayed combinations such as `--nested --confirm` and `--session --allowDuplicates` execute as written. + +### Documentation + +- Updated `README.md` with NPCManager `1.1.0`, bucket hierarchy, handout names, central help guide, report writer, nested clear choices, date rollover, Arc deduplication, and recovery controls. +- Added a Roll20 API repository readiness checklist to `README.md` covering folder/script naming, `script.json`, header, file types, smoke-test language, license, post-merge wiki upkeep, and the extensionless `GameAssist` versus `.js` submission-artifact check. +- Updated `Smoketest.md` so the in-depth NPCManager checks exercise Campaign, Chapter, Section, and Session naming, the report writer, “new Section from Session,” selected-only and nested clearing, Arc deduplication/override/removal/undo, date-boundary expectations, audit output, and handout refreshes. +- Updated `ROADMAP.md` to record Issue #22 as scoped death-history buckets and handouts rather than a summary-only report pass. +- Updated `script.json` to version `0.1.4.5`, add `0.1.4.4` to `previousversions`, identify NPCManager `1.1.0`, and list the report-writer, nested-clear, and Arc-management command surface. + +### MECHSUITS records + +- Updated `[GAMEASSIST:POLICY]` because the death-report summary/detail caps are runtime behavior knobs. +- Updated `[GAMEASSIST:APP:UTILS]` because adjacent boolean flags no longer consume one another as values. +- Updated `[GAMEASSIST:CORE]` because the runtime `VERSION` constant advanced. +- Updated `[GAMEASSIST:MODULES:NPCMANAGER]` because the public death-report, writer, help, bucket, audit, Arc, revival, rollover, and hierarchical-clear behavior changed. +- Preserved existing section tags, codename `GAMEASSIST`, and command names. + +### Release artifacts + +The v0.1.4.4 artifact remains preserved. The current repository script and the new v0.1.4.5 versioned artifact share this repository blob SHA-256: + +| Artifact | SHA-256 | +| --- | --- | +| `GameAssist` | `F6D9AAE9906C4ACF5494861032F8CCB50709B3FCA06454EC4116CD6D03577578` | +| `GameAssist-v0.1.4.5` | `F6D9AAE9906C4ACF5494861032F8CCB50709B3FCA06454EC4116CD6D03577578` | + +Local Roll20 test copy: + +| Artifact | SHA-256 | +| --- | --- | +| `outputs/GameAssist-v0.1.4.5-pr34-test.js` | `95487BA6297884561CE3F51667E84B13990246151B924D9F9638742AD11C0950` | + +### Verification + +| Check | Result | +| --- | --- | +| `node --check .\GameAssist` | Passed | +| `script.json` JSON parse | Passed | +| `GameAssist` versus `GameAssist-v0.1.4.5` byte identity | Passed | +| NPCManager review regression checks for migration, same-name tokens, scoped clearing, arc revival eligibility, and zero HP | Passed | +| NPCManager behavioral harness: Arc deduplication/override/removal/undo, selected-only and nested clearing, Section seeding, and simulated date rollover | Passed (22 assertions) | +| Copilot follow-up harness: custom Session retention, date-mode reset/rollover, and revival annotation despite marker-resolution failure | Passed (9 assertions) | +| NPC death-audit chat/handout regression: two named mismatches with HP, markers, and token IDs in chat plus complete handout details | Passed (12 assertions) | +| Five-revision preservation audit: commands, defaults, state repair, queue, dependencies, marker identity, module UX, HP rolling, and NPCManager lifecycle | Passed after correcting NPCManager disable retention | +| Legacy name-only deduplication regression: a retained legacy name does not block a new token-ID-bearing death | Passed | +| MECHSUITS section pairing, nesting, metadata, footers, and canonical-tree agreement | Passed (19 sections) | +| `git diff --cached --check` | Passed | +| Roll20 API sandbox acceptance checklist | Pending DM smoke test | + +The Roll20 sandbox checklist remains the release acceptance test for live marker changes, chat rendering, and handout behavior. --- -*This changelog will track all future updates, enhancements, and bug fixes.* +## [0.1.4.6] – 2026-07-17 + +### Release definition + +v0.1.4.6 is a focused GameAssist status-readability release for Issue #23. It replaces the flat technical `!ga-status` list with a short, action-oriented system check for DMs and retains volatile counters and internal diagnostics in an optional troubleshooting view. It also preserves an enabled module's configured intent when a confirmed missing dependency prevents startup, allowing the status panels to report the skip accurately. It does not change gameplay-module commands, marker behavior, TokenMod communication, StatusInfo interoperability, queue execution, runtime caches, or NPCManager history. + +### Issue addressed + +- [#23](https://github.com/Mord-Eagle/GameAssist/issues/23) — Make `!ga-status` output easier for DMs to interpret. + +### Changed — default system check + +- Rebuilt `!ga-status` as a Roll20 default-template panel titled `GameAssist 0.1.4.6 System Check`. +- The first panel now presents four decisions in reading order: + - overall health and whether the DM needs to act; + - how many enabled modules are running and how many modules are turned off; + - errors recorded during the current Roll20 sandbox session; + - dependency evidence with a plain-language next action. +- Added a separate `GameAssist Actions` whisper immediately below the simple status table with direct buttons for: + - Troubleshooting Details; + - Module List; + - Open Settings. +- Deliberately disabled modules are reported as turned off rather than failures. +- Dependency warnings from disabled modules are excluded from the active health decision. +- Modules skipped during startup for a confirmed missing dependency remain configured, appear as needing attention, and contribute to the dependency-skipped count. + +### Added — troubleshooting details + +- Added `!ga-status --details`; `!ga-status details` is accepted as an equivalent readable form. +- The details panel preserves the prior diagnostic surface with clearer labels: + - registered, enabled, running, and dependency-skipped module counts; + - commands, chat messages, and errors recorded in the current sandbox session; + - explicit queue length and the reminder that normal Roll20 events execute directly; + - average duration for explicitly queued tasks; + - last recorded activity in sandbox-local display time; + - GameAssist's internally tracked event-hook count. +- The event-hook count explicitly states that it is troubleshooting information rather than a health or pass/fail test. +- Added buttons to refresh details, return to the simple view, open the module list, and view metrics. +- Moved those detailed-view buttons into a separate `Troubleshooting Actions` whisper immediately below the details table. + +### Changed — health interpretation + +- `Ready - GameAssist is responding and every enabled module is running.` appears when enabled modules are active, no current-sandbox error is recorded, and enabled-module dependencies are confirmed. +- `Ready - enabled modules are running. A marker check is recommended.` appears when Roll20 cannot confirm a dependency but enabled modules are otherwise active. +- `Attention needed - review the items below.` appears when GameAssist has recorded an error, an enabled module is stopped, or an enabled module has a confirmed missing dependency. +- `unverifiable` remains non-fatal. The panel explains that Roll20 may not expose enough metadata and recommends one real death or concentration marker test. +- Confirmed missing dependencies identify the dependency and affected enabled modules, then recommend installing/enabling the dependency or turning off the affected module. +- Startup now checks whether a module was deliberately disabled before diagnosing its dependencies. This prevents optional disabled modules from producing startup dependency warnings. +- Startup no longer rewrites `config.enabled` to `false` when a confirmed missing dependency skips a configured module. Preserving that setting distinguishes the skipped module from one the DM intentionally disabled. +- Manual `!ga-enable ` retries with confirmed missing dependencies now refuse activation without changing the module's existing configuration. Configured-and-skipped modules remain visible as needing attention, while deliberately disabled modules remain disabled. +- `disableModule()` now considers both persistent configuration and runtime state before reporting that a module is already disabled. A configured-but-inactive dependency skip can therefore be turned off through `!ga-disable` or `!ga-config set enabled=false`, clearing the corresponding status warning. + +### Corrected — duration and terminology + +- Corrected a live Roll20 rendering failure in which button-only rows inside the default template were omitted. Status navigation now uses ordinary GM whispers while the health information remains in the default-template table. +- Removed the malformed `Avg Task Duration: N/Ams` output. +- When no queued duration exists, the details panel now reads `N/A - no queued task duration has been recorded.` +- Numeric averages use a spaced unit, for example `15.00 ms`. +- Replaced the ambiguous `Active Listeners` label with `GameAssist Event Hooks` and qualified its limited diagnostic meaning. +- Replaced the raw `Last Update` value with `Last Recorded Activity` and a human-readable sandbox-time display. +- Separated health results from session activity counters so changing command/message counts no longer look like fixed installation expectations. + +### Compatibility boundaries + +- Preserved the six bundled modules and all prior command literals. +- Kept TokenMod as the standalone marker-mutation dependency for the `v0.1.4.x` line. +- Did not implement any of Issue #24's remaining TokenMod or StatusInfo interoperability work. +- Preserved `!ga-config modules` as the detailed per-module configured/runtime/dependency view. +- Preserved `!ga-metrics` as the longer persisted activity history. +- Preserved captured `R20_ON` routing and the refusal to replace Roll20 global `on` or `off`. + +### Version and MECHSUITS records + +- Advanced the script header, MECHSUITS banner `project_version`, prose guarantee, visible license banner, runtime `VERSION`, README, smoke-test target, and `script.json` to `0.1.4.6`. +- Added `0.1.4.5` to `script.json.previousversions`. +- Updated `[GAMEASSIST:CORE]` because the runtime version changed. +- Updated `[GAMEASSIST:CORE:OBJECT]` because failed dependency enable attempts now preserve the module's existing configured intent. +- Updated `[GAMEASSIST:INTERFACES:COMMANDS]` because `!ga-status` output, health interpretation, dependency scoping, and public `--details` behavior changed. +- Updated `[GAMEASSIST:BOOTSTRAP]` because startup now preserves configured intent for dependency-skipped modules and checks deliberate disables before dependency diagnostics. +- Left POLICY, APP utilities, queue, compatibility, state, event interface, and all six gameplay-module sections unchanged. +- Preserved the literal `GAMEASSIST` codename, all section tags, and the file-scoped canonical tree. + +### Documentation + +- Updated `README.md` with the simple/details status split, command syntax, health interpretation, troubleshooting workflow, upgrade path, current release posture, and release summary. +- Updated `Smoketest.md` with DM-readable expected output, button checks, details-panel checks, variable-counter guidance, dependency interpretation, and the corrected unavailable-duration display. +- Updated `ROADMAP.md` to move Issue #23 to sandbox verification while leaving Issue #24 planned separately. +- Added a standalone Issue #23 Roll20 test script and concise acceptance checklist outside the repository working tree. + +### Release artifacts + +The current repository script and versioned artifact share this Git-normalized SHA-256: + +| Artifact | SHA-256 | +| --- | --- | +| `GameAssist` | `859FE5A08516EEBE42D7BB2C05733AE28E5DD49E5954045C4A9D4CA7EC44EAEF` | +| `GameAssist-v0.1.4.6` | `859FE5A08516EEBE42D7BB2C05733AE28E5DD49E5954045C4A9D4CA7EC44EAEF` | + +Local Roll20 test copy: + +| Artifact | SHA-256 | +| --- | --- | +| `outputs/GameAssist-v0.1.4.6-issue23-test.js` | `233C20EB6AC6011E8BF26AE8828738C6C8B85E8BBDCD072759F2369786FFEF01` | + +### Verification + +| Check | Result | +| --- | --- | +| JavaScript syntax for repository, versioned, local Roll20, and harness artifacts | Passed | +| `script.json` parse and version/previous-version metadata | Passed | +| Default, detailed, action-strip, confirmed, unverifiable, missing, startup-skipped, refused-enable, skipped-disable, deliberately-disabled, recorded-error, numeric-duration, and command-boundary status scenarios | Passed (57 assertions) | +| `GameAssist`, `GameAssist-v0.1.4.6`, and local Roll20 test copy normalized identity | Passed | +| Unchanged v0.1.4.5 leaf sections: POLICY, APP utilities, queue, compatibility, state, event interface, and all modules | Passed (12 sections) | +| Prior command-literal preservation | Passed | +| MECHSUITS section pairing and proper nesting | Passed (19 sections) | +| Global Roll20 `on`/`off` non-override contract | Passed | +| `git diff --check` | Passed | +| Roll20 chat rendering and button acceptance | Pending DM smoke test | + +The Roll20 API sandbox remains the final acceptance environment for default-template rendering and clickable chat buttons. + +--- + +## [0.1.4.7] – 2026-07-17 + +### Release definition + +v0.1.4.7 is the focused standalone-interoperability release for Issue #24 and the final planned implementation pass in the `v0.1.4.x` line. It keeps TokenMod and StatusInfo as separately installed Roll20 Mod/API scripts while strengthening the way GameAssist detects, authorizes, verifies, and explains marker operations. + +The release does not embed either upstream script, introduce the future MarkerService architecture, or replace TokenMod marker mutation with direct `token.set('statusmarkers', ...)` calls. NPCManager and ConcentrationTracker continue sending marker work through TokenMod so StatusInfo can receive TokenMod's observer notifications. + +### Issue addressed + +- [#24](https://github.com/Mord-Eagle/GameAssist/issues/24) — Stabilize standalone TokenMod and StatusInfo interoperability across the `v0.1.4.x` release line. + +### Supported standalone baselines + +| Script | Supplied baseline | Contract used by GameAssist | +| --- | --- | --- | +| TokenMod | `0.8.88` | `TokenMod.ObserveTokenChange`, `API_Meta.TokenMod.version`, `--api-as`, `--ids`, and `--set statusmarkers` | +| StatusInfo | `0.3.11` | Optional `StatusInfo` public object/version plus its existing subscription to `TokenMod.ObserveTokenChange` | + +These versions remain independently authored and licensed upstream. GameAssist does not copy or modify their source in this release. + +### Diagnosed — script-originated TokenMod authorization + +- GameAssist previously generated commands in this form: + + ```text + !token-mod --ids --set statusmarkers|+dead + ``` + +- A command generated through `sendChat('api', ...)` reaches TokenMod with the API pseudo-player rather than a normal GM player id. +- TokenMod `0.8.88` discards `--ids` targets for a non-GM caller unless either: + - TokenMod's player-facing `players-can-ids` option is enabled; or + - the script-originated command uses TokenMod's documented `--api-as ` option to select a GM identity. +- GameAssist now discovers a campaign GM player id and uses the documented `--api-as` path. Internal marker automation therefore no longer depends on enabling `players-can-ids` for players. +- If no GM identity can be found, GameAssist falls back only when TokenMod explicitly reports `playersCanUse_ids=true`; otherwise it refuses the request and gives the GM an actionable warning. + +### Added — standalone contract evidence + +- Added shared TokenMod evidence collection in `[GAMEASSIST:APP:UTILS]`: + - confirms the public `ObserveTokenChange` contract when visible; + - reads the upstream version from `API_Meta.TokenMod.version` when available; + - reads the TokenMod `playersCanUse_ids` state only as fallback authorization evidence; + - does not treat stale persistent TokenMod state by itself as proof that the script is currently installed. +- Added optional StatusInfo evidence: + - confirms the public StatusInfo object and observer contract when visible; + - reports `StatusInfo.version`; + - reports whether `state.STATUSINFO.config.showDescOnStatusChange` is enabled, disabled, or unavailable. +- Updated core dependency checks to use confirmed public TokenMod evidence before falling back to Roll20's sometimes-unavailable internal script list. +- Preserved the three dependency states `confirmed`, `missing`, and `unverifiable`; absence of public evidence remains non-fatal when Roll20 cannot expose a definitive script list. + +### Added — marker-result verification + +- Added one shared standalone TokenMod request helper for NPCManager and ConcentrationTracker. +- Each request: + - validates the token and resolved stored marker id; + - avoids sending work when the token already has the requested state; + - sends the exact built-in id or custom stored tag through TokenMod; + - uses `--api-as ` whenever a GM identity is available; + - waits one second, then rereads the token's actual `statusmarkers` value; + - remains quiet when TokenMod reached the requested state; + - warns the GM when the state did not change as requested. +- Failure warnings name the token, distinguish add from remove failures, and provide a direct command such as: + + ```text + !token-mod --ids @{selected|token_id} --set statusmarkers|+dead + ``` + +- Pending verification is keyed by token and marker. A newer request supersedes an older pending check so a rapid add/remove sequence does not report the stale request as a failure. +- Verification is delayed with `POLICY.standaloneInterop.markerVerificationDelayMs`; normal Roll20 event execution and the explicit queue model are unchanged. + +### Changed — NPCManager 1.1.1 + +- Advanced NPCManager's independent module version from `1.1.0` to `1.1.1`. +- Routed death-marker add, revival-marker removal, and module-teardown removal through the shared verified TokenMod request helper. +- Corrected the NPCHPRoller auto-roll-on-add initialization race: + - Roll20 can expose blank or zero placeholder HP while a newly added token is still being initialized; + - NPCManager now opens a two-second, POLICY-owned setup window when NPCHPRoller `autoRollOnAdd=true`; + - placeholder HP changes during that window do not add the death marker or create death/revival history; + - an unknown or blank previous HP value is not treated as proof that a living NPC crossed below 1 HP; + - later known-positive-to-zero and zero-to-positive changes remain ordinary tracked deaths and revivals. +- Preserved death-history behavior independently of visual marker success: + - a qualifying death is still recorded in Campaign, Chapter, Section, and Session buckets; + - revival annotations remain independent of marker removal; + - Arc and handout behavior is unchanged. +- Teardown no longer reports that markers were already cleared immediately after asynchronous TokenMod commands. It now reports how many removals were requested and states that results will be verified. +- The default built-in death marker remains the literal `dead` id. + +### Changed — ConcentrationTracker 0.1.0.6 + +- Advanced ConcentrationTracker's independent module version from `0.1.0.5` to `0.1.0.6`. +- Routed concentration marker add, `--off` removal, failed-save removal, and module-teardown removal through the shared verified TokenMod request helper. +- Preserved exact custom marker behavior from v0.1.4.3: + - configured display names resolve through the campaign registry; + - exact stored tags remain usable without registry access; + - built-in ids remain literal and exact; + - `!concentration --status` continues reading token markers directly rather than depending on TokenMod. +- Teardown now reports requested removals without claiming completion before marker verification. + +### Preserved — StatusInfo observation path + +- Token mutation remains inside standalone TokenMod. +- TokenMod continues calling its registered `ObserveTokenChange` handlers after applying token changes. +- StatusInfo `0.3.11` already registers a TokenMod observer and routes those notifications through its status-marker change handler. +- GameAssist does not emit a second condition description or call StatusInfo internals directly. +- `!ga-status --details` reports StatusInfo evidence, but live condition-description behavior remains a Roll20 smoke-test requirement because campaign condition definitions are user-configurable. + +### Changed — troubleshooting status + +- Added a `Standalone Integrations` row to `!ga-status --details`. +- When evidence is available, the row reports: + - detected TokenMod version and that authorized marker requests are verified; + - detected optional StatusInfo version; + - whether StatusInfo condition descriptions are enabled. +- The default `!ga-status` panel remains short and unchanged in purpose. +- StatusInfo remains optional and does not affect overall GameAssist health. + +### Compatibility boundaries + +- Preserved all existing GameAssist command literals. +- Preserved standalone TokenMod as the required marker dependency for NPCManager and ConcentrationTracker. +- Preserved standalone StatusInfo as an optional condition-description and menu script. +- Did not add integrated `[GAMEASSIST:MODULES:TOKENMOD]`, `[GAMEASSIST:MODULES:STATUSINFO]`, or `[GAMEASSIST:CORE:MARKERSERVICE]` sections; those remain assigned to the `v0.1.5.x` roadmap. +- Did not modify `state.TokenMod` or `state.STATUSINFO` beyond read-only interoperability diagnostics. +- Did not change GameAssist's persistent state schema or configuration snapshot schema. +- Did not route marker verification through the serialized queue. + +### Version and MECHSUITS records + +- Advanced the script header, MECHSUITS banner `project_version`, prose guarantee, visible release banner, runtime `VERSION`, README, smoke-test target, and `script.json` to `0.1.4.7`. +- Added `0.1.4.6` to `script.json.previousversions`. +- Updated `[GAMEASSIST:POLICY]` for the marker-verification delay and NPC HP initialization grace period. +- Updated `[GAMEASSIST:APP]` and `[GAMEASSIST:APP:UTILS]` for external evidence, TokenMod authorization, outcome verification, and StatusInfo observer preservation. +- Updated `[GAMEASSIST:CORE]` and `[GAMEASSIST:CORE:OBJECT]` for the release version and public-contract dependency confirmation. +- Updated `[GAMEASSIST:INTERFACES:COMMANDS]` for the new troubleshooting evidence. +- Updated `[GAMEASSIST:MODULES:NPCMANAGER]` for verified marker requests, new-token initialization suppression, and its independent module patch version; updated `[GAMEASSIST:MODULES:CONCENTRATIONTRACKER]` for verified marker requests and its independent module patch version. +- Updated `[GAMEASSIST:BOOTSTRAP]` for the v0.1.4.7 startup version record without changing lifecycle order. +- Preserved the literal `GAMEASSIST` codename, existing tag names, nesting, and file-scoped canonical tree. + +### Documentation + +- Updated `README.md` with: + - TokenMod `0.8.88` and StatusInfo `0.3.11` supported baselines; + - standalone installation boundaries; + - `players-can-ids` clarification; + - direct failure-recovery commands; + - module version updates; + - v0.1.4.6 to v0.1.4.7 upgrade and rollback guidance. +- Updated `Smoketest.md` with: + - expected contract-aware dependency results; + - the new `Standalone Integrations` troubleshooting row; + - an initial `players-can-ids` OFF-state isolation pass followed, when applicable, by a restored campaign-setting compatibility pass; + - TokenMod direct-command isolation; + - optional StatusInfo observer checks; + - add/remove/teardown and delayed-warning acceptance checks; + - an NPCHPRoller auto-roll-on-add check that refuses false death/revival history while preserving later gameplay transitions. +- Updated `ROADMAP.md` to move Issue #24 into live Roll20 acceptance while keeping integrated TokenMod, StatusInfo, and MarkerService work in `v0.1.5.x`. + +### Roll20 API sandbox acceptance + +- Completed the focused v0.1.4.7 acceptance pass with standalone TokenMod `0.8.88` and optional StatusInfo `0.3.11` behavior enabled for the campaign. +- Confirmed GameAssist health and standalone-integration diagnostics respond with the expected module/dependency posture. +- Confirmed NPCManager adds and removes the built-in `dead` marker, records genuine death/revival history, and completes audit/report/teardown workflows. +- Confirmed NPCHPRoller auto-roll-on-add establishes a new NPC's starting HP without flashing the death marker or creating a false death/revival pair. +- Confirmed a later genuine positive-to-zero change on that auto-rolled NPC is still recorded and a later positive-HP change is still annotated as revival. +- Confirmed ConcentrationTracker marker add, direct status reading, removal, and teardown behavior. +- Confirmed StatusInfo continues observing the relevant TokenMod marker changes without duplicate GameAssist condition output. +- Confirmed the marker workflows remain functional after restoring the campaign's normal TokenMod `players-can-ids` setting. + +### Release artifacts + +The repository script and versioned artifact share this Git-normalized SHA-256: + +| Artifact | SHA-256 | +| --- | --- | +| `GameAssist` | `ADBC0F18CD3517E50A91CEBAA05D83ABD531F9595130F2EF3B750548C05D4953` | +| `GameAssist-v0.1.4.7` | `ADBC0F18CD3517E50A91CEBAA05D83ABD531F9595130F2EF3B750548C05D4953` | + +Local Roll20 test copy: + +| Artifact | SHA-256 | +| --- | --- | +| `outputs/GameAssist-v0.1.4.7-issue24-test.js` | `0E3D0CE9C97B6A9EACB587C3486E2ABC774A876D8160499EF4BF3FF604AF903C` | + +### Verification + +| Check | Result | +| --- | --- | +| JavaScript syntax for repository, versioned, local Roll20, and both harness artifacts | Passed | +| Existing status/lifecycle regression harness | Passed (57 assertions) | +| Focused TokenMod/StatusInfo interoperability and NPC initialization harness | Passed (31 assertions) | +| Built-in `dead` add/remove with `players-can-ids=false` | Passed in simulation | +| NPCHPRoller auto-roll-on-add placeholder HP suppression | Passed in simulation | +| Genuine post-initialization NPC death/revival tracking | Passed in simulation | +| Custom concentration tag add/remove/status | Passed in simulation | +| NPCManager and ConcentrationTracker teardown marker requests | Passed in simulation | +| Deliberately failed TokenMod mutation and actionable warning | Passed in simulation | +| TokenMod observer notification path used by StatusInfo | Passed in simulation | +| `script.json` parse and version/previous-version metadata | Passed | +| `GameAssist`, `GameAssist-v0.1.4.7`, and local Roll20 test copy normalized identity | Passed | +| MECHSUITS section pairing, proper nesting, metadata, footers, and canonical-tree agreement | Passed (19 sections; 19 canonical-tree entries) | +| Unchanged implementation-section regression | Passed (8 sections) | +| Prior command-literal preservation | Passed (135 unique literals) | +| Global Roll20 `on`/`off` non-override contract | Passed | +| `git diff --check` | Passed | +| Roll20 API sandbox acceptance with TokenMod and optional StatusInfo | Passed | +The Roll20 API sandbox acceptance pass confirmed real `sendChat` routing, TokenMod timing, token marker persistence, StatusInfo condition-description behavior, and NPCHPRoller/NPCManager initialization ordering for this release candidate. diff --git a/GameAssist/GameAssist.js b/GameAssist/GameAssist.js index 8c32bc712e..257a0f371d 100644 --- a/GameAssist/GameAssist.js +++ b/GameAssist/GameAssist.js @@ -1,5 +1,152 @@ +/* +======================================== +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.1.2 === +// === GameAssist v0.1.4.7 === // === Author: Mord Eagle === // ============================= // Released under the MIT License (see https://opensource.org/licenses/MIT) @@ -24,81 +171,179 @@ (() => { 'use strict'; - const VERSION = '0.1.1.2'; - const STATE_KEY = 'GameAssist'; - const MODULES = {}; - let READY = false; + 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. + // ------------------------------------------------------------------------- - // ————— QUEUE + WATCHDOG ————— - let _busy = false; - let _lastStart = 0; - const _queue = []; - const DEFAULT_TIMEOUT = 30000; - const WATCHDOG_INTERVAL = 15000; + // ————— 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 _enqueue(task, priority = 0, timeout = DEFAULT_TIMEOUT) { - _queue.push({ task, priority, enqueuedAt: Date.now(), timeout }); - _queue.sort((a,b) => b.priority - a.priority || a.enqueuedAt - b.enqueuedAt); - _runNext(); + function monotonic() { + if (typeof performance !== 'undefined' && typeof performance.now === 'function') { + return performance.now(); + } + return Date.now(); } - function _runNext() { - if (_busy || !_queue.length) return; - const { task, timeout } = _queue.shift(); - _busy = true; - _lastStart = Date.now(); + function isoNow() { + return new Date(now()).toISOString(); + } - const timer = setTimeout(() => { - GameAssist.log('Core', `Task timed out after ${timeout}ms`, 'WARN'); - _busy = false; - _runNext(); - }, timeout); + function localNow() { + return new Date(now()).toLocaleString(); + } - Promise.resolve() - .then(task) - .catch(err => GameAssist.log('Core', `Error in task: ${err.message}`, 'ERROR')) - .finally(() => { - clearTimeout(timer); - _busy = false; - const duration = Date.now() - _lastStart; - GameAssist._metrics.taskDurations.push(duration); - GameAssist._metrics.lastUpdate = new Date().toISOString(); - _runNext(); - }); + function localTime(raw = now()) { + return new Date(raw).toLocaleTimeString(); } - setInterval(() => { - if (_busy && Date.now() - _lastStart > DEFAULT_TIMEOUT * 2) { - GameAssist.log('Core', 'Watchdog forced queue reset', 'WARN'); - _busy = false; - _runNext(); - } - }, WATCHDOG_INTERVAL); + function createMetricsStore() { + return { + totals: {}, + history: [], + durations: [], + sessionStart: isoNow(), + lastUpdate: null + }; + } - // ————— HANDLER TRACKING ————— - globalThis._handlers = globalThis._handlers || {}; - const originalOn = typeof globalThis.on === 'function' ? globalThis.on : null; - const originalOff = typeof globalThis.off === 'function' ? globalThis.off : null; + function ensureStateRoot() { + const current = state[STATE_KEY]; + const root = (current && typeof current === 'object' && !Array.isArray(current)) + ? current + : {}; - globalThis.on = (event, handler) => { - globalThis._handlers[event] = globalThis._handlers[event] || []; - globalThis._handlers[event].push(handler); - if (typeof originalOn === 'function') { - return originalOn(event, handler); + if (!root.config || typeof root.config !== 'object') { + root.config = {}; } - }; - globalThis.off = (event, handler) => { - if (!globalThis._handlers[event]) return; - globalThis._handlers[event] = globalThis._handlers[event].filter(h => h !== handler); - if (typeof originalOff === 'function') { - return originalOff(event, handler); + 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(); + } } - }; - // ————— UTILITIES ————— + 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) { - const args = {}, pattern = /--(\w+)(?:\s+("[^"]*"|[^\s]+))?/g; + // 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; @@ -112,54 +357,720 @@ 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) { - state[STATE_KEY] = state[STATE_KEY] || { config: {} }; - state[STATE_KEY][mod] = state[STATE_KEY][mod] || { config: {}, runtime: {} }; - return state[STATE_KEY][mod]; + const root = ensureStateRoot(); + return ensureStateBranch(root, mod).branch; } + function saveState(mod, data) { - state[STATE_KEY] = state[STATE_KEY] || { config: {} }; - state[STATE_KEY][mod] = Object.assign(getState(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) { - if (state[STATE_KEY]?.[mod]) delete state[STATE_KEY][mod]; + const root = ensureStateRoot(); + if (root?.[mod]) delete root[mod]; } function auditState() { - const root = state[STATE_KEY] || {}; + const root = ensureStateRoot(); + const whitelist = new Set(['config', 'flags', 'metrics']); + const repaired = []; + const unknown = []; + Object.keys(root).forEach(k => { - if (k === 'config') return; - if (!MODULES[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'); - delete root[k]; - } else { - const branch = root[k]; - if (!branch.config || !branch.runtime) { - GameAssist.log('Core', `Malformed state for ${k}`, 'WARN'); - delete root[k]; - } } }); + + 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 = new Date().toISOString(); + 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, ''') + .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', @@ -172,38 +1083,209 @@ .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 known = KNOWN_SCRIPTS.map(normalizeScriptName); - const active = Object.keys(state.api?.scripts || {}).map(normalizeScriptName); - const good = active.filter(n => known.includes(n)); - const bad = active.filter(n => !known.includes(n)); - GameAssist.log('Compat', '✅ Known: ' + (good.join(', ') || 'none')); - GameAssist.log('Compat', '❓ Unknown: ' + (bad.join(', ') || 'none')); - GameAssist.log('Compat', '🔌 Events: ' + GameAssist._plannedEvents.join(', ')); - GameAssist.log('Compat', '💬 Commands: ' + GameAssist._plannedChatPrefixes.join(', ')); - } - // ————— CONFIG PARSER ————— - function parseConfigValue(raw) { - raw = raw.trim(); - if (raw === 'true') return true; - if (raw === 'false') return false; - if (!isNaN(raw)) return Number(raw); - if ((raw.startsWith('{') && raw.endsWith('}')) || (raw.startsWith('[') && raw.endsWith(']'))) { - try { return JSON.parse(raw); } - catch { GameAssist.log('Config', 'Invalid JSON: ' + _sanitize(raw)); } + 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; } - return raw; - } - // ————— GameAssist CORE ————— - const GameAssist = { - _metrics: { - commands: 0, - messages: 0, - errors: 0, - stateAudits: 0, + 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 }, @@ -212,35 +1294,39 @@ _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 = new Date().toLocaleTimeString(); + const timestamp = localTime(); const levelIcon = { INFO: 'ℹ️', WARN: '⚠️', ERROR: '❌' }[level] || 'ℹ️'; - - // escape user-supplied text, then split on newlines - const safe = _sanitize(msg).split('\n'); - - // prepend /w gm to every continuation line so Roll20 treats - // the whole block as one whisper - const stitched = safe.map((l, i) => (i ? '/w gm ' + l : l)).join('\n'); + const safeLines = _sanitize(String(msg ?? '')).split('\n'); + const body = safeLines.join('
'); sendChat( 'GameAssist', - `/w gm ${levelIcon} [${timestamp}] [${mod}] ${stitched}` + `/w gm ${levelIcon} [${timestamp}] [${_sanitize(mod)}] ${body}` ); }, handleError(mod, err) { this._metrics.errors++; - this._metrics.lastUpdate = new Date().toISOString(); + 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 } = {}) { + register(name, initFn, { + enabled = true, + events = [], + prefixes = [], + teardown = null, + dependsOn = [], + preserveRuntimeOnDisable = false + } = {}) { if (READY) { this.log('Core', `Cannot register after ready: ${name}`, 'WARN'); return; @@ -249,139 +1335,700 @@ this.log('Core', `Duplicate module: ${name}`, 'WARN'); return; } - MODULES[name] = { initFn, teardown, enabled, initialized: false, events, prefixes }; + 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 = [] } = {}) { + onCommand(prefix, fn, mod, { gmOnly = false, acl = [], match = { caseInsensitive: true, mode: 'token' } } = {}) { const wrapped = msg => { - if (msg.type !== 'api' || !msg.content.startsWith(prefix)) return; + 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 = new Date().toISOString(); + this._metrics.lastUpdate = isoNow(); + recordMetric('command', { mod, note: prefix, noHistory: true }); try { fn(msg); } catch(e) { this.handleError(mod, e); } }; - on('chat:message', wrapped); + R20_ON('chat:message', wrapped); this._commandHandlers[mod] = (this._commandHandlers[mod] || []).concat({ event:'chat:message', fn:wrapped }); }, offCommands(mod) { - (this._commandHandlers[mod] || []).forEach(h => off(h.event, h.fn)); + // Clears internal bookkeeping only; does not detach from Roll20 bus. this._commandHandlers[mod] = []; }, onEvent(evt, fn, mod) { const wrapped = (...args) => { - if (!READY || !MODULES[mod].initialized) return; + if (!MODULES[mod]?.initialized || !MODULES[mod]?.active) return; + if (!READY) return; this._metrics.messages++; - this._metrics.lastUpdate = new Date().toISOString(); + this._metrics.lastUpdate = isoNow(); + recordMetric('event', { mod, note: evt, noHistory: true }); try { fn(...args); } catch(e) { this.handleError(mod, e); } }; - on(evt, wrapped); + R20_ON(evt, wrapped); this._listeners[mod] = (this._listeners[mod] || []).concat({ event:evt, fn:wrapped }); }, offEvents(mod) { - (this._listeners[mod] || []).forEach(h => off(h.event, h.fn)); + // Clears internal bookkeeping only; does not detach from Roll20 bus. this._listeners[mod] = []; }, _clearAllListeners() { - Object.keys(this._commandHandlers).forEach(m => this.offCommands(m)); - Object.keys(this._listeners).forEach(m => this.offEvents(m)); + 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]; - if (!m) { this.log('Core', `No such module: ${name}`, 'WARN'); return; } - this.offEvents(name); - this.offCommands(name); - clearState(name); - getState(name).config.enabled = true; + 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; - try { m.initFn(); this.log(name, 'Enabled'); } - catch(e) { this.handleError(name, e); } + 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]; - if (!m) { this.log('Core', `No such module: ${name}`, 'WARN'); return; } - if (typeof m.teardown === 'function') { + 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'); } } - this.offEvents(name); - this.offCommands(name); - clearState(name); - getState(name).config.enabled = false; + + 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}`); + } - // ————— CONFIG COMMAND ————— GameAssist.onCommand('!ga-config', msg => { const parts = msg.content.trim().split(/\s+/); const sub = parts[1]; if (sub === 'list') { - const ts = new Date().toLocaleString(); + const ts = localNow(); const ver = `v${VERSION}`; - const cfg = JSON.stringify(state[STATE_KEY].config, null, 2) + + 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})\n\n${cfg}
`); - sendChat('GameAssist', `/w gm Config written to "${name}"`); + 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); - if (!MODULES[mod]) { + const BAD_KEYS = new Set(POLICY.config.unsafeKeys); + if (!MODULES[mod] || MODULES[mod].internal) { GameAssist.log('Config', `Unknown module: ${mod}`, 'WARN'); return; } - getState(mod).config[key.trim()] = parsed; - GameAssist.log('Config', `Set ${mod}.${key.trim()} = ${JSON.stringify(parsed)}`); + 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' && parts.length >= 4) { + else if (sub === 'get') { + if (parts.length < 3) { + GameAssist.log('Config', 'Usage: !ga-config get [key]', 'WARN'); + return; + } const mod = parts[2]; - const key = parts[3]; - if (!MODULES[mod]) { + if (!MODULES[mod] || MODULES[mod].internal) { GameAssist.log('Config', `Unknown module: ${mod}`, 'WARN'); return; } - const val = getState(mod).config[key]; - GameAssist.log('Config', `${mod}.${key} = ${JSON.stringify(val)}`); + 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).map(([name, mod]) => { - const cfg = getState(name).config; - const status = cfg.enabled ? '✅' : '❌'; - const init = mod.initialized ? '🔄' : '⏸️'; - return `${status}${init} ${name}`; - }).join('\n'); + 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 [args]'); + GameAssist.log('Config', 'Usage: !ga-config list|set|get|modules|cleanup [args]'); } }, 'Core', { gmOnly: true }); @@ -405,43 +2052,332 @@ }, 'Core', { gmOnly: true }); GameAssist.onCommand('!ga-status', msg => { - const metrics = GameAssist._metrics; - const avgDuration = metrics.taskDurations.length > 0 - ? (metrics.taskDurations.reduce((a,b) => a+b, 0) / metrics.taskDurations.length).toFixed(2) - : 'N/A'; - - const status = [ - `**GameAssist ${VERSION} Status**`, - `Commands: ${metrics.commands}`, - `Messages: ${metrics.messages}`, - `Errors: ${metrics.errors}`, - `Avg Task Duration: ${avgDuration}ms`, - `Queue Length: ${_queue.length}`, - `Last Update: ${metrics.lastUpdate || 'Never'}`, - `Modules: ${Object.keys(MODULES).length}`, - `Active Listeners: ${Object.values(GameAssist._listeners).flat().length}` - ].join('\n'); + 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('Status', status); + 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 = getState('CritFumble'); + const modState = GameAssist.getState('CritFumble'); Object.assign(modState.config, { enabled: true, - debug: true, + debug: false, useEmojis: true, - rollDelayMs: 200, + rollDelayMs: POLICY.critFumble.rollDelayMs, // Preserve any values previously saved in state ...modState.config }); - modState.runtime.activePlayers = modState.runtime.activePlayers || {}; + + const ACTIVE_PLAYER_LIMIT = POLICY.runtime.activePlayerLimit; // ─── Constants ───────────────────────────────────────────────────────────────── - /** Which Roll20 rolltemplates we watch for natural-1s */ - const VALID_TEMPLATES = ['atk','atkdmg','npcatk','spell']; + /** 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', @@ -449,11 +2385,57 @@ 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 ────────────────────────────────────────────────────────── /** @@ -475,10 +2457,15 @@ 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) { - who = sanitizeWho(who); const content = fields.map(f=>`{{${f.label}=${f.value}}}`).join(' '); - sendChat('CritFumble', `/w "${who}" &{template:default} {{name=${title}}} ${content}`); + sendChat('CritFumble', `${whisperPrefix(who)}&{template:default} {{name=${title}}} ${content}`); } function getFumbleTableName(type) { @@ -487,8 +2474,8 @@ function sendConfirmMenu(who) { const confirmButtons = [ - `[Confirm-Crit-Martial](!Confirm-Crit-Martial)`, - `[Confirm-Crit-Magic](!Confirm-Crit-Magic)` + GameAssist.createButton('Confirm-Crit-Martial', '!confirm-crit-martial'), + GameAssist.createButton('Confirm-Crit-Magic', '!confirm-crit-magic') ].join(' '); // Send to player @@ -504,11 +2491,11 @@ function sendFumbleMenu(who) { sendConfirmMenu(who); const buttons = [ - `[⚔ Melee](!critfumble-melee)`, - `[🏹 Ranged](!critfumble-ranged)`, - `[🎯 Thrown](!critfumble-thrown)`, - `[🔥 Spell](!critfumble-spell)`, - `[👊 Natural/Unarmed](!critfumble-natural)` + 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 } @@ -526,7 +2513,7 @@ } function executeTableRoll(tableName) { setTimeout(()=>{ - sendChat('', `/roll 1t[${tableName}]`); + GameAssist.rollTable(tableName); debugLog(`Roll command executed: /roll 1t[${tableName}]`); }, modState.config.rollDelayMs); } @@ -536,7 +2523,7 @@ if (!table) { sendTemplateMessage(who, "⚠️ Invalid Fumble Type", [ { label: "Requested", value: `"${type}"` }, - { label: "Valid Types", value: Object.keys(FUMBLE_TABLES).join(', ') } + { label: "Valid Types", value: FUMBLE_TYPE_ORDER.join(', ') } ]); debugLog(`Invalid fumble type "${type}"`); return; @@ -576,60 +2563,97 @@ } function showManualTriggerMenu() { - const players = Object.values(modState.runtime.activePlayers); - if (!players.length) { + 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 = players.map(name=> - `[${name}](!critfumblemenu-${encodeURIComponent(name)})` - ).join(' '); + 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(encodedName) { - sendFumbleMenu(decodeURIComponent(encodedName)); - debugLog(`Manually triggered fumble menu for: ${encodedName}`); + 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) { - sendTemplateMessage(who, "📘 CritFumble Help", [ - { label: "Version", value: "v0.2.4.9" }, - { label: "Commands", value: "`!critfail`, `!critfumble help`, `!critfumble-`, `!confirm-crit-martial`, `!confirm-crit-magic`" }, - { label: "Description", value: "Auto-detects critical misses and prompts attacker with a fumble menu; GM can also manually trigger via `!critfail`." }, - { label: "Valid Types", value: Object.keys(FUMBLE_TABLES).join(', ') } + 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 handleRoll(msg) { + 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 && !modState.runtime.activePlayers[msg.playerid]) { - const p = getObj('player', msg.playerid); - if (p) modState.runtime.activePlayers[msg.playerid] = p.get('displayname'); - } + if (msg.playerid) noteActivePlayer(rt, msg.playerid); // API‐style commands if (msg.type==='api') { - const cmd = (msg.content||'').trim().toLowerCase(); + const rawCmd = (msg.content||'').trim(); + const cmd = rawCmd.toLowerCase(); if (cmd==='!critfail') { - debugLog('Manual trigger: !critfail'); + debugLog(`Manual trigger: ${rawCmd}`); return showManualTriggerMenu(); } - if (cmd==='!critfumble help') { + if (/^!critfumble\s+menu$/.test(cmd)) { + return showCritFumbleMenu(msg.who); + } + if (/^!critfumble(?:\s+help)?$/.test(cmd)) { return showHelpMessage(msg.who); } - if (cmd.startsWith('!critfumblemenu-')) { - return handleManualTrigger(msg.content.replace('!critfumblemenu-','')); + 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 = msg.content.replace('!critfumble-','').toLowerCase(); + const fumbleType = cmd.slice('!critfumble-'.length).split(/\s+/)[0]; debugLog(`${who} selected fumble type: ${fumbleType}`); return rollFumbleTable(who, fumbleType); } @@ -643,9 +2667,19 @@ } // auto-detect natural 1 on a valid rolltemplate - if (!msg.rolltemplate || !VALID_TEMPLATES.includes(msg.rolltemplate)) return; + 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)) return; + 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}`); @@ -653,61 +2687,1597 @@ } GameAssist.onEvent('chat:message', handleRoll, 'CritFumble'); - GameAssist.log('CritFumble','v0.2.4.9 Ready: Auto fumble detection + !critfail','INFO',{startup:true}); + 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'] }); - - // ————— NPC MANAGER MODULE v0.1.1.0 ————— + // --- 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 = getState('NPCManager'); + const modState = GameAssist.getState('NPCManager'); Object.assign(modState.config, { enabled: true, autoTrackDeath: true, deadMarker: 'dead', + autoHide: false, + hideLayer: 'gmlayer', ...modState.config }); - function isNPC(token) { - if (!token || token.get('layer') !== 'objects') return false; - const charId = token.get('represents'); - if (!charId) return false; + 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: charId, + _characterid: linked.character.id, name: 'npc' })[0]; - return npcAttr && npcAttr.get('current') === '1'; + if (!npcAttr || npcAttr.get('current') !== '1') return null; + return linked; } - function checkForDeath(token) { - if (!modState.config.autoTrackDeath || !isNPC(token)) return; + 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; + } - const hp = parseInt(token.get('bar1_value'), 10) || 0; - const markers = (token.get('statusmarkers') || '').split(','); - const isDead = markers.includes(modState.config.deadMarker); + 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 (hp < 1 && !isDead) { - sendChat('api', `!token-mod --ids ${token.id} --set statusmarkers|+${modState.config.deadMarker}`); - GameAssist.log('NPCManager', `${token.get('name')} marked as dead (HP: ${hp})`); - } else if (hp >= 1 && isDead) { - sendChat('api', `!token-mod --ids ${token.id} --set statusmarkers|-${modState.config.deadMarker}`); - GameAssist.log('NPCManager', `${token.get('name')} revived (HP: ${hp})`); + 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; } - } - function handleTokenChange(obj, prev) { - if (obj.get('bar1_value') !== prev.bar1_value) { - checkForDeath(obj); + 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; } - } - GameAssist.onCommand('!npc-death-report', msg => { + 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, @@ -715,16 +4285,30 @@ layer: 'objects' }); - const flagged = []; + const needsMarker = []; + const needsClear = []; + const invalid = []; for (let token of tokens) { - if (!isNPC(token)) continue; + 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 markers = (token.get('statusmarkers') || '').split(','); - const isDead = markers.includes(modState.config.deadMarker); + const isDead = tokenHasMarker(token, modState.config.deadMarker); - if ((hp < 1 && !isDead) || (hp >= 1 && isDead)) { - flagged.push({ + 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, @@ -733,39 +4317,259 @@ } } - if (flagged.length === 0) { - GameAssist.log('NPCManager', '✅ Living NPCs have correct death marker states.'); - } else { - GameAssist.log('NPCManager', `⚠️ ${flagged.length} NPC(s) with mismatched death markers:`); - flagged.forEach(({ name, id, hp, markers }) => { - GameAssist.log('NPCManager', `- ${name} [${id}] | HP: ${hp} | Markers: ${markers}`); + 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', 'v0.1.1.0 Ready: Auto death tracking + !npc-death-report', 'INFO', { startup: true }); - }, { - enabled: true, - events: ['change:graphic:bar1_value'], - prefixes: ['!npc-death-report'] - }); + 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; -// ————— CONCENTRATION TRACKER MODULE v0.1.0.5 ————— -GameAssist.register('ConcentrationTracker', function() { + 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 = getState('ConcentrationTracker'); + const modState = GameAssist.getState('ConcentrationTracker'); Object.assign(modState.config, { enabled: true, marker: 'Concentrating', randomize: true, ...modState.config }); - modState.runtime.lastDamage = modState.runtime.lastDamage || {}; + + 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']; - const TOKEN_MARKER = 'Concentrating'; + + // ─── 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 = { @@ -825,9 +4629,21 @@ GameAssist.register('ConcentrationTracker', function() { * Adds or removes the Concentrating status marker. */ function toggleMarker(token, on) { - sendChat('api', - `!token-mod --ids ${token.id} --set statusmarkers|${on ? '+' : '-'}${TOKEN_MARKER}` - ); + 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'); } /** @@ -837,9 +4653,9 @@ GameAssist.register('ConcentrationTracker', function() { function postButtons(recipient) { const dmg = '?{Damage taken?|0}'; const buttons = [ - `[🎯 Maintain Control](!concentration --damage ${dmg} --mode normal)`, - `[🧠 Brace for the Distraction](!concentration --damage ${dmg} --mode adv)`, - `[😣 Struggling to Focus](!concentration --damage ${dmg} --mode dis)` + 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.` @@ -864,15 +4680,31 @@ GameAssist.register('ConcentrationTracker', function() { */ 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 => - (t.get('statusmarkers') || '') - .toLowerCase() - .includes(TOKEN_MARKER.toLowerCase()) - ); + }).filter(t => tokenHasMarker(t, resolution.id)); if (!tokens.length) { return sendChat('ConcentrationTracker', `/w "${player}" No tokens concentrating.` @@ -885,6 +4717,42 @@ GameAssist.register('ConcentrationTracker', function() { 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. @@ -908,6 +4776,7 @@ GameAssist.register('ConcentrationTracker', function() { * 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', @@ -920,19 +4789,16 @@ GameAssist.register('ConcentrationTracker', function() { `/w "${player}" ⚠️ Token not found.` ); } - const charId = token.get('represents'); - if (!charId) { - return sendChat('ConcentrationTracker', - `/w "${player}" ⚠️ Token not linked.` - ); - } - const character = getObj('character', charId); - if (!character) { + + const linked = GameAssist.getLinkedCharacter(token); + if (!linked) { return sendChat('ConcentrationTracker', - `/w "${player}" ⚠️ Character not found.` + `/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'); @@ -943,7 +4809,20 @@ GameAssist.register('ConcentrationTracker', function() { if (mode === 'adv') expr = `2d20kh1 + ${bonus}`; if (mode === 'dis') expr = `2d20kl1 + ${bonus}`; - modState.runtime.lastDamage[msg.playerid] = damage; + 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]; @@ -975,11 +4854,37 @@ GameAssist.register('ConcentrationTracker', function() { */ function handleClear(msg) { const player = msg.who.replace(/ \(GM\)$/, ''); - msg.selected?.forEach(sel => { + + 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) toggleMarker(t, false); + 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)'); + } }); - sendChat('ConcentrationTracker', `/w "${player}" ✅ Cleared markers.`); + + let response = '✅ Requested marker removal.'; + if (skipped.length) { + response += ` Skipped: ${skipped.join(', ')}.`; + } + + sendChat('ConcentrationTracker', `/w "${player}" ${response}`); } /** @@ -987,14 +4892,17 @@ GameAssist.register('ConcentrationTracker', function() { * Repeats the last concentration check. */ function handleLast(msg) { + const { lastDamage } = ensureConcentrationRuntime(); const player = msg.who.replace(/ \(GM\)$/, ''); - const dmg = modState.runtime.lastDamage[msg.playerid]; - if (!dmg) { + 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.` ); } - handleRoll(msg, dmg, 'normal'); + const mode = typeof entry === 'object' && entry.mode ? entry.mode : 'normal'; + handleRoll(msg, dmg, mode); } // ─── Core Handler (Case-Insensitive) ────────────────────────────────────────── @@ -1007,6 +4915,8 @@ GameAssist.register('ConcentrationTracker', function() { const cmd = parts.shift(); // "!concentration" or "!cc" if (!CMDS.includes(cmd)) return; + ensureConcentrationRuntime(); + // 2) Identify player (strip " (GM)") const player = msg.who.replace(/ \(GM\)$/, ''); @@ -1044,41 +4954,86 @@ GameAssist.register('ConcentrationTracker', function() { } // ─── 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.join(' & ')}`, + `Ready: ${[...CMDS, '!ga-conc-status'].join(' & ')}`, 'INFO', { startup: true } ); }, { enabled: true, - prefixes: ['!concentration','!cc'], + events: ['chat:message'], + prefixes: ['!concentration','!cc','!ga-conc-status'], + dependsOn: ['TokenMod'], teardown: () => { const page = Campaign().get('playerpageid'); - findObjs({ _type: 'graphic', _pageid: page, layer: 'objects' }) - .filter(t => - (t.get('statusmarkers') || '') - .toLowerCase() - .includes('concentrating') - ) - .forEach(t => - sendChat('api', - `!token-mod --ids ${t.id} --set statusmarkers|-Concentrating` - ) + 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 = getState('NPCHPRoller'); + const modState = GameAssist.getState('NPCHPRoller'); - Object.assign(modState.config, { - enabled: true, - autoRollOnAdd: false, - ...modState.config - }); + 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” @@ -1108,48 +5063,75 @@ GameAssist.register('ConcentrationTracker', function() { return rollDice(count, sides) + bonus; } - function rollTokenHP(token) { - const charId = token.get('represents'); - if (!charId) { - GameAssist.log('NPCHPRoller', 'Token not linked to character', 'WARN'); - return; + 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: charId, + _characterid: linked.character.id, name: 'npc' })[0]; if (!npcAttr || npcAttr.get('current') !== '1') { - return; + if (logWarnings) { + GameAssist.log('NPCHPRoller', `${displayName} is not flagged as an NPC.`, 'WARN'); + } + return null; } const hpFormulaAttr = findObjs({ _type: 'attribute', - _characterid: charId, + _characterid: linked.character.id, name: 'npc_hpformula' })[0]; if (!hpFormulaAttr) { - GameAssist.log('NPCHPRoller', `No HP formula found for ${token.get('name')}`, 'WARN'); - return; + 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) { - GameAssist.log('NPCHPRoller', `Invalid HP formula: ${formula}`, 'WARN'); - return; + if (logWarnings) { + GameAssist.log('NPCHPRoller', `Invalid HP formula: ${formula}`, 'WARN'); + } + return null; } - const hp = rollHP(diceData); + 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); - GameAssist.log('NPCHPRoller', `${token.get('name')} HP set to ${hp} using [${formula}]`); + 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 => { @@ -1161,14 +5143,18 @@ GameAssist.register('ConcentrationTracker', function() { }); const npcTokens = []; + const skipped = []; for (const token of tokens) { - const characterId = token.get('represents'); - if (!characterId) continue; + const link = GameAssist.getLinkedCharacter(token); + if (!link) { + skipped.push(token.get('name') || '(Unnamed)'); + continue; + } const npcAttr = findObjs({ _type: 'attribute', - _characterid: characterId, + _characterid: link.character.id, name: 'npc' })[0]; @@ -1186,6 +5172,10 @@ GameAssist.register('ConcentrationTracker', function() { 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 => { @@ -1194,48 +5184,372 @@ GameAssist.register('ConcentrationTracker', function() { return; } + const skipped = []; + msg.selected.forEach(sel => { const token = getObj('graphic', sel._id); - if (token) { - try { - rollTokenHP(token); - } catch (err) { - GameAssist.log('NPCHPRoller', `Error processing ${token.get('name')}: ${err.message}`, 'ERROR'); - } + 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.log('NPCHPRoller', 'v0.1.1.0 Ready: !npc-hp-all, !npc-hp-selected', 'INFO', { startup: true }); - }, { - enabled: true, - events: [], - prefixes: ['!npc-hp-all', '!npc-hp-selected'] + 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 ————— - on('ready', () => { + // ============================================================================= + // [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; - state[STATE_KEY] = state[STATE_KEY] || { config: {} }; - GameAssist.config = state[STATE_KEY].config; + const root = ensureStateRoot(); + GameAssist.config = root.config; + if (!root.metrics.sessionStart) { + root.metrics.sessionStart = isoNow(); + } + recordMetric('system', { mod: 'Core', note: 'Sandbox ready' }); GameAssist._clearAllListeners(); - seedDefaults(); auditState(); + seedDefaults(); GameAssist._dedupePlanned(); auditCompatibility(); - GameAssist.log('Core', `GameAssist v${VERSION} ready; modules: ${Object.keys(MODULES).join(', ')}`); + const moduleNames = Object.entries(MODULES) + .filter(([, mod]) => !mod.internal) + .map(([name]) => name); - Object.entries(MODULES).forEach(([name, m]) => { - if (getState(name).config.enabled) { - m.initialized = true; - try { m.initFn(); } - catch(e) { GameAssist.handleError(name, e); } + 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..46af4cd917 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 | ① Paste **GameAssist-v0.1.4.7** ② 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 Copy GameAssist-v0.1.4.7 → Roll20 Mod/API Scripts → Save +🛠 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,1095 @@ 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`. +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: + +* Repository folder name matches the `script.json` script name: `GameAssist`. +* `script.json` points at the actual script artifact: `"script": "GameAssist"`. +* `script.json` includes current `version`, `previousversions`, detailed `description`, `authors`, `roll20userid`, `dependencies`, `modifies`, `conflicts`, and command list. +* The top script header includes name, version, last updated date, description, syntax/commands, dependency notes, and configuration pointers. +* Included file types match Roll20 API repo expectations. Note: the current GameAssist repository uses an extensionless `GameAssist` script artifact because `script.json` points to that file; before an upstream PR, confirm whether Roll20 reviewers want that retained or want a `.js` submission artifact. +* README and smoke test describe the current version and do not promise unverified sandbox behavior. +* MIT license is present. +* 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`. +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, sandbox verification** | 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/previousversions/GameAssist v0.1.1.1.js b/GameAssist/previousversions/GameAssist v0.1.1.1.js new file mode 100644 index 0000000000..c457866a78 --- /dev/null +++ b/GameAssist/previousversions/GameAssist v0.1.1.1.js @@ -0,0 +1,1230 @@ +// ============================= +// === GameAssist v0.1.1.1 === +// === Author: Mord Eagle === +// ============================= +// Released under the MIT License (see https://opensource.org/licenses/MIT) +// +// Copyright (c) 2025 Mord Eagle +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +(() => { + 'use strict'; + + const VERSION = '0.1.1.1'; + const STATE_KEY = 'GameAssist'; + const MODULES = {}; + let READY = false; + + // ————— QUEUE + WATCHDOG ————— + let _busy = false; + let _lastStart = 0; + const _queue = []; + const DEFAULT_TIMEOUT = 30000; + const WATCHDOG_INTERVAL = 15000; + + function _enqueue(task, priority = 0, timeout = DEFAULT_TIMEOUT) { + _queue.push({ task, priority, enqueuedAt: Date.now(), timeout }); + _queue.sort((a,b) => b.priority - a.priority || a.enqueuedAt - b.enqueuedAt); + _runNext(); + } + + function _runNext() { + if (_busy || !_queue.length) return; + const { task, timeout } = _queue.shift(); + _busy = true; + _lastStart = Date.now(); + + const timer = setTimeout(() => { + GameAssist.log('Core', `Task timed out after ${timeout}ms`, 'WARN'); + _busy = false; + _runNext(); + }, timeout); + + Promise.resolve() + .then(task) + .catch(err => GameAssist.log('Core', `Error in task: ${err.message}`, 'ERROR')) + .finally(() => { + clearTimeout(timer); + _busy = false; + const duration = Date.now() - _lastStart; + GameAssist._metrics.taskDurations.push(duration); + GameAssist._metrics.lastUpdate = new Date().toISOString(); + _runNext(); + }); + } + + setInterval(() => { + if (_busy && Date.now() - _lastStart > DEFAULT_TIMEOUT * 2) { + GameAssist.log('Core', 'Watchdog forced queue reset', 'WARN'); + _busy = false; + _runNext(); + } + }, WATCHDOG_INTERVAL); + + // ————— HANDLER TRACKING ————— + globalThis._handlers = globalThis._handlers || {}; + const originalOn = typeof globalThis.on === 'function' ? globalThis.on : null; + const originalOff = typeof globalThis.off === 'function' ? globalThis.off : null; + + globalThis.on = (event, handler) => { + globalThis._handlers[event] = globalThis._handlers[event] || []; + globalThis._handlers[event].push(handler); + if (typeof originalOn === 'function') { + return originalOn(event, handler); + } + }; + + globalThis.off = (event, handler) => { + if (!globalThis._handlers[event]) return; + globalThis._handlers[event] = globalThis._handlers[event].filter(h => h !== handler); + if (typeof originalOff === 'function') { + return originalOff(event, handler); + } + }; + + // ————— UTILITIES ————— + function _parseArgs(content) { + const args = {}, pattern = /--(\w+)(?:\s+("[^"]*"|[^\s]+))?/g; + let m; + while ((m = pattern.exec(content))) { + let v = m[2] || true; + if (typeof v === 'string') { + if (/^".*"$/.test(v)) v = v.slice(1, -1); + else if (/^\d+$/.test(v)) v = parseInt(v, 10); + else if (/,/.test(v)) v = v.split(','); + } + args[m[1]] = v; + } + return { cmd: content.split(/\s+/)[0], args }; + } + + function getState(mod) { + state[STATE_KEY] = state[STATE_KEY] || { config: {} }; + state[STATE_KEY][mod] = state[STATE_KEY][mod] || { config: {}, runtime: {} }; + return state[STATE_KEY][mod]; + } + function saveState(mod, data) { + state[STATE_KEY] = state[STATE_KEY] || { config: {} }; + state[STATE_KEY][mod] = Object.assign(getState(mod), data); + } + function clearState(mod) { + if (state[STATE_KEY]?.[mod]) delete state[STATE_KEY][mod]; + } + + function auditState() { + const root = state[STATE_KEY] || {}; + Object.keys(root).forEach(k => { + if (k === 'config') return; + if (!MODULES[k]) { + GameAssist.log('Core', `Unexpected state branch: ${k}`, 'WARN'); + delete root[k]; + } else { + const branch = root[k]; + if (!branch.config || !branch.runtime) { + GameAssist.log('Core', `Malformed state for ${k}`, 'WARN'); + delete root[k]; + } + } + }); + GameAssist._metrics.stateAudits++; + GameAssist._metrics.lastUpdate = new Date().toISOString(); + } + + function seedDefaults() { + Object.entries(MODULES).forEach(([name, mod]) => { + const cfg = getState(name).config; + if (cfg.enabled === undefined) cfg.enabled = mod.enabled; + }); + } + + function _sanitize(str = '') { + return str.toString() + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/'/g, '''); + } + + // ————— COMPATIBILITY ————— + const KNOWN_SCRIPTS = [ + 'tokenmod.js','universaltvttimporter.js','npc-hp.js','wolfpack.js', + 'critfumble.js','rana-curse.js','statusinfo.js','npc death tracker.js', + 'customizable roll listener.js','5th edition ogl by roll20 companion.js' + ]; + function normalizeScriptName(n) { + return (n||'') + .toLowerCase() + .replace(/\.js$/, '') + .replace(/[\s_]+/g, '-') + .replace(/[^\w-]/g, ''); + } + function auditCompatibility() { + if (!GameAssist.flags.DEBUG_COMPAT) return; + const known = KNOWN_SCRIPTS.map(normalizeScriptName); + const active = Object.keys(state.api?.scripts || {}).map(normalizeScriptName); + const good = active.filter(n => known.includes(n)); + const bad = active.filter(n => !known.includes(n)); + GameAssist.log('Compat', '✅ Known: ' + (good.join(', ') || 'none')); + GameAssist.log('Compat', '❓ Unknown: ' + (bad.join(', ') || 'none')); + GameAssist.log('Compat', '🔌 Events: ' + GameAssist._plannedEvents.join(', ')); + GameAssist.log('Compat', '💬 Commands: ' + GameAssist._plannedChatPrefixes.join(', ')); + } + + // ————— CONFIG PARSER ————— + function parseConfigValue(raw) { + raw = raw.trim(); + if (raw === 'true') return true; + if (raw === 'false') return false; + if (!isNaN(raw)) return Number(raw); + if ((raw.startsWith('{') && raw.endsWith('}')) || (raw.startsWith('[') && raw.endsWith(']'))) { + try { return JSON.parse(raw); } + catch { GameAssist.log('Config', 'Invalid JSON: ' + _sanitize(raw)); } + } + return raw; + } + + // ————— GameAssist CORE ————— + const GameAssist = { + _metrics: { + commands: 0, + messages: 0, + errors: 0, + stateAudits: 0, + taskDurations: [], + lastUpdate: null + }, + _plannedEvents: [], + _plannedChatPrefixes: [], + _listeners: {}, + _commandHandlers: {}, + _eventHandlers: {}, + config: {}, + flags: { DEBUG_COMPAT: false, QUIET_STARTUP: true }, + + log(mod, msg, level = 'INFO', { startup = false } = {}) { + if (startup && GameAssist.flags.QUIET_STARTUP) return; + + const timestamp = new Date().toLocaleTimeString(); + const levelIcon = { INFO: 'ℹ️', WARN: '⚠️', ERROR: '❌' }[level] || 'ℹ️'; + + // escape user-supplied text, then split on newlines + const safe = _sanitize(msg).split('\n'); + + // prepend /w gm to every continuation line so Roll20 treats + // the whole block as one whisper + const stitched = safe.map((l, i) => (i ? '/w gm ' + l : l)).join('\n'); + + sendChat( + 'GameAssist', + `/w gm ${levelIcon} [${timestamp}] [${mod}] ${stitched}` + ); + }, + + handleError(mod, err) { + this._metrics.errors++; + this._metrics.lastUpdate = new Date().toISOString(); + this.log(mod, err.message || String(err), 'ERROR'); + }, + + register(name, initFn, { enabled = true, events = [], prefixes = [], teardown = null } = {}) { + if (READY) { + this.log('Core', `Cannot register after ready: ${name}`, 'WARN'); + return; + } + if (MODULES[name]) { + this.log('Core', `Duplicate module: ${name}`, 'WARN'); + return; + } + MODULES[name] = { initFn, teardown, enabled, initialized: false, events, prefixes }; + this._plannedEvents.push(...events); + this._plannedChatPrefixes.push(...prefixes); + }, + + onCommand(prefix, fn, mod, { gmOnly = false, acl = [] } = {}) { + const wrapped = msg => { + if (msg.type !== 'api' || !msg.content.startsWith(prefix)) return; + if (gmOnly && !playerIsGM(msg.playerid)) return; + if (acl.length && !acl.includes(msg.playerid)) return; + this._metrics.commands++; + this._metrics.lastUpdate = new Date().toISOString(); + try { fn(msg); } + catch(e) { this.handleError(mod, e); } + }; + on('chat:message', wrapped); + this._commandHandlers[mod] = (this._commandHandlers[mod] || []).concat({ event:'chat:message', fn:wrapped }); + }, + + offCommands(mod) { + (this._commandHandlers[mod] || []).forEach(h => off(h.event, h.fn)); + this._commandHandlers[mod] = []; + }, + + onEvent(evt, fn, mod) { + const wrapped = (...args) => { + if (!READY || !MODULES[mod].initialized) return; + this._metrics.messages++; + this._metrics.lastUpdate = new Date().toISOString(); + try { fn(...args); } + catch(e) { this.handleError(mod, e); } + }; + on(evt, wrapped); + this._listeners[mod] = (this._listeners[mod] || []).concat({ event:evt, fn:wrapped }); + }, + + offEvents(mod) { + (this._listeners[mod] || []).forEach(h => off(h.event, h.fn)); + this._listeners[mod] = []; + }, + + _clearAllListeners() { + Object.keys(this._commandHandlers).forEach(m => this.offCommands(m)); + Object.keys(this._listeners).forEach(m => this.offEvents(m)); + }, + + _dedupePlanned() { + this._plannedEvents = [...new Set(this._plannedEvents)]; + this._plannedChatPrefixes = [...new Set(this._plannedChatPrefixes)]; + }, + + enableModule(name) { + _enqueue(() => { + const m = MODULES[name]; + if (!m) { this.log('Core', `No such module: ${name}`, 'WARN'); return; } + this.offEvents(name); + this.offCommands(name); + clearState(name); + getState(name).config.enabled = true; + m.initialized = true; + try { m.initFn(); this.log(name, 'Enabled'); } + catch(e) { this.handleError(name, e); } + }); + }, + + disableModule(name) { + _enqueue(() => { + const m = MODULES[name]; + if (!m) { this.log('Core', `No such module: ${name}`, 'WARN'); return; } + if (typeof m.teardown === 'function') { + try { m.teardown(); } + catch(e) { this.log(name, `Teardown failed: ${e.message}`, 'WARN'); } + } + this.offEvents(name); + this.offCommands(name); + clearState(name); + getState(name).config.enabled = false; + m.initialized = false; + this.log(name, 'Disabled'); + }); + } + }; + + globalThis.GameAssist = GameAssist; + + // ————— CONFIG COMMAND ————— + GameAssist.onCommand('!ga-config', msg => { + const parts = msg.content.trim().split(/\s+/); + const sub = parts[1]; + if (sub === 'list') { + const ts = new Date().toLocaleString(); + const ver = `v${VERSION}`; + const cfg = JSON.stringify(state[STATE_KEY].config, null, 2) + .replace(/[<>&]/g, c=>({'<':'<','>':'>','&':'&'})[c]); + const name = 'GameAssist Config'; + let handout = findObjs({ type:'handout', name })[0]; + if (!handout) handout = createObj('handout', { name, archived:false }); + handout.set('notes', `
Generated: ${ts} (${ver})\n\n${cfg}
`); + sendChat('GameAssist', `/w gm Config written to "${name}"`); + } + else if (sub === 'set' && parts.length >= 4) { + const mod = parts[2]; + const [ key, ...rest ] = parts.slice(3).join(' ').split('='); + const val = rest.join('='); + const parsed = parseConfigValue(val); + if (!MODULES[mod]) { + GameAssist.log('Config', `Unknown module: ${mod}`, 'WARN'); + return; + } + getState(mod).config[key.trim()] = parsed; + GameAssist.log('Config', `Set ${mod}.${key.trim()} = ${JSON.stringify(parsed)}`); + } + else if (sub === 'get' && parts.length >= 4) { + const mod = parts[2]; + const key = parts[3]; + if (!MODULES[mod]) { + GameAssist.log('Config', `Unknown module: ${mod}`, 'WARN'); + return; + } + const val = getState(mod).config[key]; + GameAssist.log('Config', `${mod}.${key} = ${JSON.stringify(val)}`); + } + else if (sub === 'modules') { + const moduleList = Object.entries(MODULES).map(([name, mod]) => { + const cfg = getState(name).config; + const status = cfg.enabled ? '✅' : '❌'; + const init = mod.initialized ? '🔄' : '⏸️'; + return `${status}${init} ${name}`; + }).join('\n'); + GameAssist.log('Config', `Modules:\n${moduleList}`); + } + else { + GameAssist.log('Config', 'Usage: !ga-config list|set|get|modules [args]'); + } + }, 'Core', { gmOnly: true }); + + // ————— CONTROL COMMANDS ————— + GameAssist.onCommand('!ga-enable', msg => { + const mod = msg.content.split(/\s+/)[1]; + if (!mod) { + GameAssist.log('Core', 'Usage: !ga-enable ', 'WARN'); + return; + } + GameAssist.enableModule(mod); + }, 'Core', { gmOnly: true }); + + GameAssist.onCommand('!ga-disable', msg => { + const mod = msg.content.split(/\s+/)[1]; + if (!mod) { + GameAssist.log('Core', 'Usage: !ga-disable ', 'WARN'); + return; + } + GameAssist.disableModule(mod); + }, 'Core', { gmOnly: true }); + + GameAssist.onCommand('!ga-status', msg => { + const metrics = GameAssist._metrics; + const avgDuration = metrics.taskDurations.length > 0 + ? (metrics.taskDurations.reduce((a,b) => a+b, 0) / metrics.taskDurations.length).toFixed(2) + : 'N/A'; + + const status = [ + `**GameAssist ${VERSION} Status**`, + `Commands: ${metrics.commands}`, + `Messages: ${metrics.messages}`, + `Errors: ${metrics.errors}`, + `Avg Task Duration: ${avgDuration}ms`, + `Queue Length: ${_queue.length}`, + `Last Update: ${metrics.lastUpdate || 'Never'}`, + `Modules: ${Object.keys(MODULES).length}`, + `Active Listeners: ${Object.values(GameAssist._listeners).flat().length}` + ].join('\n'); + + GameAssist.log('Status', status); + }, 'Core', { gmOnly: true }); + + // ————— CRITFUMBLE MODULE v0.2.4.8 ————— + GameAssist.register('CritFumble', function() { + // ─── Module Setup ────────────────────────────────────────────────────────────── + const modState = getState('CritFumble'); + Object.assign(modState.config, { + enabled: true, + debug: true, + useEmojis: true, + rollDelayMs: 200, + // Preserve any values previously saved in state + ...modState.config + }); + modState.runtime.activePlayers = modState.runtime.activePlayers || {}; + + // ─── Constants ───────────────────────────────────────────────────────────────── + /** Which Roll20 rolltemplates we watch for natural-1s */ + const VALID_TEMPLATES = ['atk','atkdmg','npcatk','spell']; + const FUMBLE_TABLES = { + melee: 'CF-Melee', + ranged: 'CF-Ranged', + spell: 'CF-Spell', + natural: 'CF-Natural', + thrown: 'CF-Thrown' + }; + // Lookup for confirm tables + const CONFIRM_TABLES = { + 'confirm-crit-martial': 'Confirm-Crit-Martial', + 'confirm-crit-magic': 'Confirm-Crit-Magic' + }; + + // ─── Helper Functions ────────────────────────────────────────────────────────── + /** + * debugLog(msg) + * Logs to the GM only when debug mode is on. + * Uses GameAssist.log under the hood. + */ + function debugLog(msg) { + if (modState.config.debug) { + GameAssist.log('CritFumble', msg); + } + } + function emoji(sym) { + return modState.config.useEmojis ? sym : ''; + } + + // Strip off any " (GM)" suffix so /w target resolves + function sanitizeWho(who) { + return who.replace(/ \(GM\)$/, ''); + } + + function sendTemplateMessage(who,title,fields) { + who = sanitizeWho(who); + const content = fields.map(f=>`{{${f.label}=${f.value}}}`).join(' '); + sendChat('CritFumble', `/w "${who}" &{template:default} {{name=${title}}} ${content}`); + } + + function getFumbleTableName(type) { + return FUMBLE_TABLES[type]||null; + } + + function sendConfirmMenu(who) { + const buttons = [ + `[Confirm-Crit-Martial](!Confirm-Crit-Martial)`, + `[Confirm-Crit-Magic](!Confirm-Crit-Magic)` + ].join(' '); + sendTemplateMessage(who, `${emoji('❓')} Confirm Critical Miss`, [ + { label: "Choose Confirmation Type", value: buttons } + ]); + } + + function sendFumbleMenu(who) { + sendConfirmMenu(who); + const buttons = [ + `[⚔ Melee](!critfumble-melee)`, + `[🏹 Ranged](!critfumble-ranged)`, + `[🎯 Thrown](!critfumble-thrown)`, + `[🔥 Spell](!critfumble-spell)`, + `[👊 Natural/Unarmed](!critfumble-natural)` + ].join(' '); + sendTemplateMessage(who, `${emoji('💥')} Critical Miss!`, [ + { label: "What kind of attack was this?", value: buttons } + ]); + // also whisper to GM for awareness + sendTemplateMessage('gm', `${emoji('💥')} Critical Miss for ${who}!`, [ + { label: "What kind of attack was this?", value: buttons } + ]); + } + + function announceTableRoll(tableName) { + sendTemplateMessage('gm', `${emoji('🎲')} Rolling Table`, [ + { label: "Table", value: `**${tableName}**` } + ]); + } + function executeTableRoll(tableName) { + setTimeout(()=>{ + sendChat('', `/roll 1t[${tableName}]`); + debugLog(`Roll command executed: /roll 1t[${tableName}]`); + }, modState.config.rollDelayMs); + } + + function rollFumbleTable(who,type) { + const table = getFumbleTableName(type); + if (!table) { + sendTemplateMessage(who, "⚠️ Invalid Fumble Type", [ + { label: "Requested", value: `"${type}"` }, + { label: "Valid Types", value: Object.keys(FUMBLE_TABLES).join(', ') } + ]); + debugLog(`Invalid fumble type "${type}"`); + return; + } + announceTableRoll(table); + executeTableRoll(table); + } + + function rollConfirmTable(who,rawCommand) { + const table = CONFIRM_TABLES[rawCommand.toLowerCase()]; + if (!table) { + sendTemplateMessage(who, "⚠️ Invalid Confirm Type", [ + { label: "Requested", value: `"${rawCommand}"` }, + { label: "Valid Options", value: Object.values(CONFIRM_TABLES).join(', ') } + ]); + debugLog(`Invalid confirm type "${rawCommand}"`); + return; + } + announceTableRoll(table); + executeTableRoll(table); + } + + function hasNaturalOne(inlinerolls=[]) { + return inlinerolls.some(group=>{ + if (!group.results||!Array.isArray(group.results.rolls)) return false; + return group.results.rolls.some(roll=>{ + if (roll.type!=='R'||roll.sides!==20) return false; + const results = Array.isArray(roll.results)? roll.results : [roll.results]; + return results.some(r=> (r.r===true||r.r===undefined) && r.v===1 ); + }); + }); + } + + function showManualTriggerMenu() { + const players = Object.values(modState.runtime.activePlayers); + if (!players.length) { + sendTemplateMessage('gm', "⚠️ No Players Detected", [ + { label:"Note", value:"No players have been active yet this session." } + ]); + return; + } + const buttons = players.map(name=> + `[${name}](!critfumblemenu-${encodeURIComponent(name)})` + ).join(' '); + sendTemplateMessage('gm',"Manually Trigger Fumble Menu",[ + { label:"Select Player", value:buttons } + ]); + } + + function handleManualTrigger(encodedName) { + sendFumbleMenu(decodeURIComponent(encodedName)); + debugLog(`Manually triggered fumble menu for: ${encodedName}`); + } + + function showHelpMessage(who) { + sendTemplateMessage(who, "📘 CritFumble Help", [ + { label: "Version", value: "v0.2.4.8" }, + { label: "Commands", value: "`!critfail`, `!critfumble help`, `!critfumble-`, `!confirm-crit-martial`, `!confirm-crit-magic`" }, + { label: "Description", value: "Auto-detects critical misses and prompts attacker with a fumble menu; GM can also manually trigger via `!critfail`." }, + { label: "Valid Types", value: Object.keys(FUMBLE_TABLES).join(', ') } + ]); + } + + function handleRoll(msg) { + if (!msg) return; + // register active players + if (msg.playerid && !modState.runtime.activePlayers[msg.playerid]) { + const p = getObj('player', msg.playerid); + if (p) modState.runtime.activePlayers[msg.playerid] = p.get('displayname'); + } + + // API‐style commands + if (msg.type==='api') { + const cmd = (msg.content||'').trim().toLowerCase(); + + if (cmd==='!critfail') { + debugLog('Manual trigger: !critfail'); + return showManualTriggerMenu(); + } + if (cmd==='!critfumble help') { + return showHelpMessage(msg.who); + } + if (cmd.startsWith('!critfumblemenu-')) { + return handleManualTrigger(msg.content.replace('!critfumblemenu-','')); + } + if (cmd.startsWith('!critfumble-')) { + const who = sanitizeWho(msg.who); + const fumbleType = msg.content.replace('!critfumble-','').toLowerCase(); + debugLog(`${who} selected fumble type: ${fumbleType}`); + return rollFumbleTable(who, fumbleType); + } + if (cmd.startsWith('!confirm-crit-')) { + const who = sanitizeWho(msg.who); + const rawCommand = msg.content.slice(1); // e.g. "confirm-crit-martial" + debugLog(`${who} selected confirm type: ${rawCommand}`); + return rollConfirmTable(who, rawCommand); + } + return; + } + + // auto-detect natural 1 on a valid rolltemplate + if (!msg.rolltemplate || !VALID_TEMPLATES.includes(msg.rolltemplate)) return; + const rolls = msg.inlinerolls||[]; + if (!hasNaturalOne(rolls)) return; + + const who = sanitizeWho(msg.who); + debugLog(`Fumble detected from: ${who}`); + sendFumbleMenu(who); + } + + GameAssist.onEvent('chat:message', handleRoll, 'CritFumble'); + GameAssist.log('CritFumble','v0.2.4.8 Ready: Auto fumble detection + !critfail','INFO',{startup:true}); + }, { + enabled: true, + events: ['chat:message'], + prefixes: ['!critfail','!critfumble'] + }); + + // ————— NPC MANAGER MODULE v0.1.1.0 ————— + GameAssist.register('NPCManager', function() { + const modState = getState('NPCManager'); + + Object.assign(modState.config, { + enabled: true, + autoTrackDeath: true, + deadMarker: 'dead', + ...modState.config + }); + + function isNPC(token) { + if (!token || token.get('layer') !== 'objects') return false; + const charId = token.get('represents'); + if (!charId) return false; + + const npcAttr = findObjs({ + _type: 'attribute', + _characterid: charId, + name: 'npc' + })[0]; + + return npcAttr && npcAttr.get('current') === '1'; + } + + function checkForDeath(token) { + if (!modState.config.autoTrackDeath || !isNPC(token)) return; + + const hp = parseInt(token.get('bar1_value'), 10) || 0; + const markers = (token.get('statusmarkers') || '').split(','); + const isDead = markers.includes(modState.config.deadMarker); + + if (hp < 1 && !isDead) { + sendChat('api', `!token-mod --ids ${token.id} --set statusmarkers|+${modState.config.deadMarker}`); + GameAssist.log('NPCManager', `${token.get('name')} marked as dead (HP: ${hp})`); + } else if (hp >= 1 && isDead) { + sendChat('api', `!token-mod --ids ${token.id} --set statusmarkers|-${modState.config.deadMarker}`); + GameAssist.log('NPCManager', `${token.get('name')} revived (HP: ${hp})`); + } + } + + function handleTokenChange(obj, prev) { + if (obj.get('bar1_value') !== prev.bar1_value) { + checkForDeath(obj); + } + } + + GameAssist.onCommand('!npc-death-report', msg => { + const pageId = Campaign().get('playerpageid'); + const tokens = findObjs({ + _pageid: pageId, + _type: 'graphic', + layer: 'objects' + }); + + const flagged = []; + for (let token of tokens) { + if (!isNPC(token)) continue; + + const hp = parseInt(token.get('bar1_value'), 10) || 0; + const markers = (token.get('statusmarkers') || '').split(','); + const isDead = markers.includes(modState.config.deadMarker); + + if ((hp < 1 && !isDead) || (hp >= 1 && isDead)) { + flagged.push({ + name: token.get('name') || '(Unnamed)', + id: token.id, + hp, + markers: token.get('statusmarkers') || '(none)' + }); + } + } + + if (flagged.length === 0) { + GameAssist.log('NPCManager', '✅ Living NPCs have correct death marker states.'); + } else { + GameAssist.log('NPCManager', `⚠️ ${flagged.length} NPC(s) with mismatched death markers:`); + flagged.forEach(({ name, id, hp, markers }) => { + GameAssist.log('NPCManager', `- ${name} [${id}] | HP: ${hp} | Markers: ${markers}`); + }); + } + }, 'NPCManager', { gmOnly: true }); + + GameAssist.onEvent('change:graphic:bar1_value', handleTokenChange, 'NPCManager'); + GameAssist.log('NPCManager', 'v0.1.1.0 Ready: Auto death tracking + !npc-death-report', 'INFO', { startup: true }); + }, { + enabled: true, + events: ['change:graphic:bar1_value'], + prefixes: ['!npc-death-report'] + }); + +// ————— CONCENTRATION TRACKER MODULE v0.1.0.5 ————— +GameAssist.register('ConcentrationTracker', function() { + // ─── Module Setup ────────────────────────────────────────────────────────────── + const modState = getState('ConcentrationTracker'); + Object.assign(modState.config, { + enabled: true, + marker: 'Concentrating', + randomize: true, + ...modState.config + }); + modState.runtime.lastDamage = modState.runtime.lastDamage || {}; + + // ─── Public Command Prefixes ─────────────────────────────────────────────────── + const CMDS = ['!concentration', '!cc']; + const TOKEN_MARKER = 'Concentrating'; + + // ─── Default Emote Lines ──────────────────────────────────────────────────────── + const DEFAULT_LINES = { + success: [ + "steadies their breath, holding their focus.", + "'s grip tightens as they maintain their spell.", + "staggers slightly but does not lose concentration.", + "clenches their jaw, magic still flickering with intent.", + "narrows their eyes, spell still intact." + ], + failure: [ + "gasps, their focus shattered as the spell falters.", + "'s concentration breaks and the magic fades.", + "cries out, unable to maintain the spell.", + "'s spell fizzles as they lose control.", + "winces, focus lost in the heat of battle." + ] + }; + + // ─── Helper Functions ────────────────────────────────────────────────────────── + + /** + * getConfig() + * Merge default settings with stored config. + */ + function getConfig() { + return Object.assign({ randomize: true }, modState.config); + } + + /** + * getOutcomeLines(name) + * Returns the success/failure emote arrays with {{name}} replaced. + */ + function getOutcomeLines(name) { + const fill = line => line.replace("{{name}}", name); + return { + success: DEFAULT_LINES.success.map(fill), + failure: DEFAULT_LINES.failure.map(fill) + }; + } + + /** + * getConBonus(character) + * Reads the character's Constitution saving throw bonus. + */ + function getConBonus(character) { + const attr = findObjs({ + _type: 'attribute', + _characterid: character.id, + name: 'constitution_save_bonus' + })[0]; + return attr ? parseInt(attr.get('current'), 10) : 0; + } + + /** + * toggleMarker(token, on) + * Adds or removes the Concentrating status marker. + */ + function toggleMarker(token, on) { + sendChat('api', + `!token-mod --ids ${token.id} --set statusmarkers|${on ? '+' : '-'}${TOKEN_MARKER}` + ); + } + + /** + * postButtons(recipient) + * Sends the three-button UI for a new concentration check. + */ + function postButtons(recipient) { + const dmg = '?{Damage taken?|0}'; + const buttons = [ + `[🎯 Maintain Control](!concentration --damage ${dmg} --mode normal)`, + `[🧠 Brace for the Distraction](!concentration --damage ${dmg} --mode adv)`, + `[😣 Struggling to Focus](!concentration --damage ${dmg} --mode dis)` + ].join(' '); + sendChat('ConcentrationTracker', + `/w "${recipient}" ${buttons}
⚠️ Select your token before clicking.` + ); + } + + /** + * sendResult(player, dc, total, rolls, formula) + * Whispers the concentration-check result to player & GM. + */ + function sendResult(player, dc, total, rolls, formula) { + const tpl = + `&{template:default} {{name=🧠 Concentration Check}}` + + ` {{DC=${dc}}} {{Result=Roll(s) ${rolls} → ${total} (from ${formula})}}`; + sendChat('ConcentrationTracker', `/w "${player}" ${tpl}`); + sendChat('ConcentrationTracker', `/w gm ${tpl}`); + } + + /** + * showStatus(player) + * Lists all tokens currently marked Concentrating. + */ + function showStatus(player) { + const page = Campaign().get('playerpageid'); + const tokens = findObjs({ + _type: 'graphic', + _pageid: page, + layer: 'objects' + }).filter(t => + (t.get('statusmarkers') || '') + .toLowerCase() + .includes(TOKEN_MARKER.toLowerCase()) + ); + if (!tokens.length) { + return sendChat('ConcentrationTracker', + `/w "${player}" No tokens concentrating.` + ); + } + let out = `&{template:default} {{name=🧠 Concentration Status}}`; + tokens.forEach(t => { + out += `{{${t.get('name') || 'Unnamed'}=Concentrating}}`; + }); + sendChat('ConcentrationTracker', `/w "${player}" ${out}`); + } + + /** + * showHelp(player) + * Whisper the full list of commands and usage. + */ + function showHelp(player) { + const helpText = [ + "🧠 Concentration Help:", + "• !concentration / !cc → Show buttons", + "• --damage X → Roll vs DC = max(10,⌊X/2⌋)", + "• --mode normal|adv|dis→ Set roll mode", + "• --last → Repeat last check", + "• --off → Remove marker from selected tokens", + "• --status → Who is concentrating", + "• --config randomize on|off → Toggle emote randomization" + ].join('
'); + sendChat('ConcentrationTracker', `/w "${player}" ${helpText}`); + } + + /** + * handleRoll(msg, damage, mode) + * Executes the concentration roll workflow. + */ + function handleRoll(msg, damage, mode) { + const player = msg.who.replace(/ \(GM\)$/, ''); + if (!msg.selected?.length) { + return sendChat('ConcentrationTracker', + `/w "${player}" ⚠️ No token selected.` + ); + } + const token = getObj('graphic', msg.selected[0]._id); + if (!token) { + return sendChat('ConcentrationTracker', + `/w "${player}" ⚠️ Token not found.` + ); + } + const charId = token.get('represents'); + if (!charId) { + return sendChat('ConcentrationTracker', + `/w "${player}" ⚠️ Token not linked.` + ); + } + const character = getObj('character', charId); + if (!character) { + return sendChat('ConcentrationTracker', + `/w "${player}" ⚠️ Character not found.` + ); + } + + const bonus = getConBonus(character); + const dc = Math.max(10, Math.floor(damage / 2)); + const name = token.get('name') || character.get('name'); + const { success: S, failure: F } = getOutcomeLines(name); + const { randomize } = getConfig(); + + let expr = `1d20 + ${bonus}`; + if (mode === 'adv') expr = `2d20kh1 + ${bonus}`; + if (mode === 'dis') expr = `2d20kl1 + ${bonus}`; + + modState.runtime.lastDamage[msg.playerid] = damage; + + sendChat('', `[[${expr}]]`, ops => { + const roll = ops[0].inlinerolls?.[0]; + if (!roll) { + return sendChat('ConcentrationTracker', + `/w "${player}" ⚠️ Roll failed.` + ); + } + const total = roll.results.total; + const formula = roll.expression; + const vals = roll.results.rolls[0].results.map(r => r.v); + const rollsText = (mode === 'normal' ? vals[0] : vals.join(',')); + const ok = total >= dc; + + sendResult(player, dc, total, rollsText, formula); + + const pool = ok ? S : F; + const tail = randomize + ? pool[Math.floor(Math.random() * pool.length)] + : pool[0]; + sendChat(`character|${character.id}`, `/em ${tail}`); + toggleMarker(token, ok); + }); + } + + /** + * handleClear(msg) + * Clears the marker from selected tokens. + */ + function handleClear(msg) { + const player = msg.who.replace(/ \(GM\)$/, ''); + msg.selected?.forEach(sel => { + const t = getObj('graphic', sel._id); + if (t) toggleMarker(t, false); + }); + sendChat('ConcentrationTracker', `/w "${player}" ✅ Cleared markers.`); + } + + /** + * handleLast(msg) + * Repeats the last concentration check. + */ + function handleLast(msg) { + const player = msg.who.replace(/ \(GM\)$/, ''); + const dmg = modState.runtime.lastDamage[msg.playerid]; + if (!dmg) { + return sendChat('ConcentrationTracker', + `/w "${player}" ⚠️ No previous damage.` + ); + } + handleRoll(msg, dmg, 'normal'); + } + + // ─── Core Handler (Case-Insensitive) ────────────────────────────────────────── + function handler(msg) { + if (msg.type !== 'api') return; + + // 1) Normalize prefix: trim + lowercase + const raw = msg.content.trim(); + const parts = raw.toLowerCase().split(/\s+--/); + const cmd = parts.shift(); // "!concentration" or "!cc" + if (!CMDS.includes(cmd)) return; + + // 2) Identify player (strip " (GM)") + const player = msg.who.replace(/ \(GM\)$/, ''); + + // 3) Config branch + if (parts[0]?.startsWith('config ')) { + const [, key, val] = parts[0].split(/\s+/); + if (key === 'randomize') { + modState.config.randomize = (val === 'on' || val === 'true'); + return sendChat('ConcentrationTracker', + `/w "${player}" ✅ Randomize = ${modState.config.randomize}` + ); + } + return sendChat('ConcentrationTracker', + `/w "${player}" ❌ Unknown config ${key}` + ); + } + + // 4) Parse flags + let damage = 0, mode = 'normal'; + for (let p of parts) { + if (p === 'help') return showHelp(player); + if (p === 'status') return showStatus(player); + if (p === 'last') return handleLast(msg); + if (p === 'off') return handleClear(msg); + if (p.startsWith('damage ')) damage = parseInt(p.split(' ')[1], 10); + if (p.startsWith('mode ')) mode = p.split(' ')[1]; + } + + // 5) Execute + if (damage > 0) { + handleRoll(msg, damage, mode); + } else { + postButtons(player); + } + } + + // ─── Wire It Up ──────────────────────────────────────────────────────────────── + GameAssist.onEvent('chat:message', handler, 'ConcentrationTracker'); + GameAssist.log( + 'ConcentrationTracker', + `Ready: ${CMDS.join(' & ')}`, + 'INFO', + { startup: true } + ); +}, { + enabled: true, + prefixes: ['!concentration','!cc'], + teardown: () => { + const page = Campaign().get('playerpageid'); + findObjs({ _type: 'graphic', _pageid: page, layer: 'objects' }) + .filter(t => + (t.get('statusmarkers') || '') + .toLowerCase() + .includes('concentrating') + ) + .forEach(t => + sendChat('api', + `!token-mod --ids ${t.id} --set statusmarkers|-Concentrating` + ) + ); + } +}); + + // ————— NPC HP ROLLER MODULE v0.1.1.0 ————— + GameAssist.register('NPCHPRoller', function() { + const modState = getState('NPCHPRoller'); + + Object.assign(modState.config, { + enabled: true, + autoRollOnAdd: false, + ...modState.config + }); + + function parseDiceString(diceStr) { + // Match “NdM”, “NdM+K”, “NdM + K”, “NdM-K”, case-insensitive on “d” + const match = diceStr.match( + /^\s*(\d+)\s*[dD]\s*(\d+)(?:\s*([+-])\s*(\d+))?\s*$/ + ); + if (!match) return null; + + const count = parseInt(match[1], 10); + const sides = parseInt(match[2], 10); + const sign = match[3] === '-' ? -1 : 1; + const bonus = match[4] ? sign * parseInt(match[4], 10) : 0; + + return { count, sides, bonus }; + } + + function rollDice(count, sides) { + let total = 0; + for (let i = 0; i < count; i++) { + total += Math.floor(Math.random() * sides) + 1; + } + return total; + } + + function rollHP(diceData) { + const { count, sides, bonus } = diceData; + return rollDice(count, sides) + bonus; + } + + function rollTokenHP(token) { + const charId = token.get('represents'); + if (!charId) { + GameAssist.log('NPCHPRoller', 'Token not linked to character', 'WARN'); + return; + } + + const npcAttr = findObjs({ + _type: 'attribute', + _characterid: charId, + name: 'npc' + })[0]; + + if (!npcAttr || npcAttr.get('current') !== '1') { + return; + } + + const hpFormulaAttr = findObjs({ + _type: 'attribute', + _characterid: charId, + name: 'npc_hpformula' + })[0]; + + if (!hpFormulaAttr) { + GameAssist.log('NPCHPRoller', `No HP formula found for ${token.get('name')}`, 'WARN'); + return; + } + + const formula = hpFormulaAttr.get('current'); + const diceData = parseDiceString(formula); + + if (!diceData) { + GameAssist.log('NPCHPRoller', `Invalid HP formula: ${formula}`, 'WARN'); + return; + } + + const hp = rollHP(diceData); + + token.set('bar1_value', hp); + token.set('bar1_max', hp); + + GameAssist.log('NPCHPRoller', `${token.get('name')} HP set to ${hp} using [${formula}]`); + } + + GameAssist.onCommand('!npc-hp-all', async msg => { + const pageId = Campaign().get('playerpageid'); + const tokens = findObjs({ + _pageid: pageId, + _type: 'graphic', + layer: 'objects' + }); + + const npcTokens = []; + + for (const token of tokens) { + const characterId = token.get('represents'); + if (!characterId) continue; + + const npcAttr = findObjs({ + _type: 'attribute', + _characterid: characterId, + name: 'npc' + })[0]; + + if (npcAttr && npcAttr.get('current') === '1') { + npcTokens.push(token); + } + } + + GameAssist.log('NPCHPRoller', `Rolling HP for ${npcTokens.length} NPCs on current map...`); + + for (const token of npcTokens) { + try { + rollTokenHP(token); + } catch (err) { + GameAssist.log('NPCHPRoller', `Error processing ${token.get('name')}: ${err.message}`, 'ERROR'); + } + } + }, 'NPCHPRoller', { gmOnly: true }); + + GameAssist.onCommand('!npc-hp-selected', msg => { + if (!msg.selected || msg.selected.length === 0) { + GameAssist.log('NPCHPRoller', 'No tokens selected', 'WARN'); + return; + } + + msg.selected.forEach(sel => { + const token = getObj('graphic', sel._id); + if (token) { + try { + rollTokenHP(token); + } catch (err) { + GameAssist.log('NPCHPRoller', `Error processing ${token.get('name')}: ${err.message}`, 'ERROR'); + } + } + }); + }, 'NPCHPRoller', { gmOnly: true }); + + GameAssist.log('NPCHPRoller', 'v0.1.1.0 Ready: !npc-hp-all, !npc-hp-selected', 'INFO', { startup: true }); + }, { + enabled: true, + events: [], + prefixes: ['!npc-hp-all', '!npc-hp-selected'] + }); + + // ————— BOOTSTRAP ————— + on('ready', () => { + if (READY) return; + READY = true; + + state[STATE_KEY] = state[STATE_KEY] || { config: {} }; + GameAssist.config = state[STATE_KEY].config; + + GameAssist._clearAllListeners(); + seedDefaults(); + auditState(); + GameAssist._dedupePlanned(); + auditCompatibility(); + + GameAssist.log('Core', `GameAssist v${VERSION} ready; modules: ${Object.keys(MODULES).join(', ')}`); + + Object.entries(MODULES).forEach(([name, m]) => { + if (getState(name).config.enabled) { + m.initialized = true; + try { m.initFn(); } + catch(e) { GameAssist.handleError(name, e); } + } + }); + }); + +})(); diff --git a/GameAssist/previousversions/GameAssist v0.1.1.2.js b/GameAssist/previousversions/GameAssist v0.1.1.2.js new file mode 100644 index 0000000000..8c32bc712e --- /dev/null +++ b/GameAssist/previousversions/GameAssist v0.1.1.2.js @@ -0,0 +1,1241 @@ +// ============================= +// === GameAssist v0.1.1.2 === +// === Author: Mord Eagle === +// ============================= +// Released under the MIT License (see https://opensource.org/licenses/MIT) +// +// Copyright (c) 2025 Mord Eagle +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +(() => { + 'use strict'; + + const VERSION = '0.1.1.2'; + const STATE_KEY = 'GameAssist'; + const MODULES = {}; + let READY = false; + + // ————— QUEUE + WATCHDOG ————— + let _busy = false; + let _lastStart = 0; + const _queue = []; + const DEFAULT_TIMEOUT = 30000; + const WATCHDOG_INTERVAL = 15000; + + function _enqueue(task, priority = 0, timeout = DEFAULT_TIMEOUT) { + _queue.push({ task, priority, enqueuedAt: Date.now(), timeout }); + _queue.sort((a,b) => b.priority - a.priority || a.enqueuedAt - b.enqueuedAt); + _runNext(); + } + + function _runNext() { + if (_busy || !_queue.length) return; + const { task, timeout } = _queue.shift(); + _busy = true; + _lastStart = Date.now(); + + const timer = setTimeout(() => { + GameAssist.log('Core', `Task timed out after ${timeout}ms`, 'WARN'); + _busy = false; + _runNext(); + }, timeout); + + Promise.resolve() + .then(task) + .catch(err => GameAssist.log('Core', `Error in task: ${err.message}`, 'ERROR')) + .finally(() => { + clearTimeout(timer); + _busy = false; + const duration = Date.now() - _lastStart; + GameAssist._metrics.taskDurations.push(duration); + GameAssist._metrics.lastUpdate = new Date().toISOString(); + _runNext(); + }); + } + + setInterval(() => { + if (_busy && Date.now() - _lastStart > DEFAULT_TIMEOUT * 2) { + GameAssist.log('Core', 'Watchdog forced queue reset', 'WARN'); + _busy = false; + _runNext(); + } + }, WATCHDOG_INTERVAL); + + // ————— HANDLER TRACKING ————— + globalThis._handlers = globalThis._handlers || {}; + const originalOn = typeof globalThis.on === 'function' ? globalThis.on : null; + const originalOff = typeof globalThis.off === 'function' ? globalThis.off : null; + + globalThis.on = (event, handler) => { + globalThis._handlers[event] = globalThis._handlers[event] || []; + globalThis._handlers[event].push(handler); + if (typeof originalOn === 'function') { + return originalOn(event, handler); + } + }; + + globalThis.off = (event, handler) => { + if (!globalThis._handlers[event]) return; + globalThis._handlers[event] = globalThis._handlers[event].filter(h => h !== handler); + if (typeof originalOff === 'function') { + return originalOff(event, handler); + } + }; + + // ————— UTILITIES ————— + function _parseArgs(content) { + const args = {}, pattern = /--(\w+)(?:\s+("[^"]*"|[^\s]+))?/g; + let m; + while ((m = pattern.exec(content))) { + let v = m[2] || true; + if (typeof v === 'string') { + if (/^".*"$/.test(v)) v = v.slice(1, -1); + else if (/^\d+$/.test(v)) v = parseInt(v, 10); + else if (/,/.test(v)) v = v.split(','); + } + args[m[1]] = v; + } + return { cmd: content.split(/\s+/)[0], args }; + } + + function getState(mod) { + state[STATE_KEY] = state[STATE_KEY] || { config: {} }; + state[STATE_KEY][mod] = state[STATE_KEY][mod] || { config: {}, runtime: {} }; + return state[STATE_KEY][mod]; + } + function saveState(mod, data) { + state[STATE_KEY] = state[STATE_KEY] || { config: {} }; + state[STATE_KEY][mod] = Object.assign(getState(mod), data); + } + function clearState(mod) { + if (state[STATE_KEY]?.[mod]) delete state[STATE_KEY][mod]; + } + + function auditState() { + const root = state[STATE_KEY] || {}; + Object.keys(root).forEach(k => { + if (k === 'config') return; + if (!MODULES[k]) { + GameAssist.log('Core', `Unexpected state branch: ${k}`, 'WARN'); + delete root[k]; + } else { + const branch = root[k]; + if (!branch.config || !branch.runtime) { + GameAssist.log('Core', `Malformed state for ${k}`, 'WARN'); + delete root[k]; + } + } + }); + GameAssist._metrics.stateAudits++; + GameAssist._metrics.lastUpdate = new Date().toISOString(); + } + + function seedDefaults() { + Object.entries(MODULES).forEach(([name, mod]) => { + const cfg = getState(name).config; + if (cfg.enabled === undefined) cfg.enabled = mod.enabled; + }); + } + + function _sanitize(str = '') { + return str.toString() + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/'/g, '''); + } + + // ————— COMPATIBILITY ————— + const KNOWN_SCRIPTS = [ + 'tokenmod.js','universaltvttimporter.js','npc-hp.js','wolfpack.js', + 'critfumble.js','rana-curse.js','statusinfo.js','npc death tracker.js', + 'customizable roll listener.js','5th edition ogl by roll20 companion.js' + ]; + function normalizeScriptName(n) { + return (n||'') + .toLowerCase() + .replace(/\.js$/, '') + .replace(/[\s_]+/g, '-') + .replace(/[^\w-]/g, ''); + } + function auditCompatibility() { + if (!GameAssist.flags.DEBUG_COMPAT) return; + const known = KNOWN_SCRIPTS.map(normalizeScriptName); + const active = Object.keys(state.api?.scripts || {}).map(normalizeScriptName); + const good = active.filter(n => known.includes(n)); + const bad = active.filter(n => !known.includes(n)); + GameAssist.log('Compat', '✅ Known: ' + (good.join(', ') || 'none')); + GameAssist.log('Compat', '❓ Unknown: ' + (bad.join(', ') || 'none')); + GameAssist.log('Compat', '🔌 Events: ' + GameAssist._plannedEvents.join(', ')); + GameAssist.log('Compat', '💬 Commands: ' + GameAssist._plannedChatPrefixes.join(', ')); + } + + // ————— CONFIG PARSER ————— + function parseConfigValue(raw) { + raw = raw.trim(); + if (raw === 'true') return true; + if (raw === 'false') return false; + if (!isNaN(raw)) return Number(raw); + if ((raw.startsWith('{') && raw.endsWith('}')) || (raw.startsWith('[') && raw.endsWith(']'))) { + try { return JSON.parse(raw); } + catch { GameAssist.log('Config', 'Invalid JSON: ' + _sanitize(raw)); } + } + return raw; + } + + // ————— GameAssist CORE ————— + const GameAssist = { + _metrics: { + commands: 0, + messages: 0, + errors: 0, + stateAudits: 0, + taskDurations: [], + lastUpdate: null + }, + _plannedEvents: [], + _plannedChatPrefixes: [], + _listeners: {}, + _commandHandlers: {}, + _eventHandlers: {}, + config: {}, + flags: { DEBUG_COMPAT: false, QUIET_STARTUP: true }, + + log(mod, msg, level = 'INFO', { startup = false } = {}) { + if (startup && GameAssist.flags.QUIET_STARTUP) return; + + const timestamp = new Date().toLocaleTimeString(); + const levelIcon = { INFO: 'ℹ️', WARN: '⚠️', ERROR: '❌' }[level] || 'ℹ️'; + + // escape user-supplied text, then split on newlines + const safe = _sanitize(msg).split('\n'); + + // prepend /w gm to every continuation line so Roll20 treats + // the whole block as one whisper + const stitched = safe.map((l, i) => (i ? '/w gm ' + l : l)).join('\n'); + + sendChat( + 'GameAssist', + `/w gm ${levelIcon} [${timestamp}] [${mod}] ${stitched}` + ); + }, + + handleError(mod, err) { + this._metrics.errors++; + this._metrics.lastUpdate = new Date().toISOString(); + this.log(mod, err.message || String(err), 'ERROR'); + }, + + register(name, initFn, { enabled = true, events = [], prefixes = [], teardown = null } = {}) { + if (READY) { + this.log('Core', `Cannot register after ready: ${name}`, 'WARN'); + return; + } + if (MODULES[name]) { + this.log('Core', `Duplicate module: ${name}`, 'WARN'); + return; + } + MODULES[name] = { initFn, teardown, enabled, initialized: false, events, prefixes }; + this._plannedEvents.push(...events); + this._plannedChatPrefixes.push(...prefixes); + }, + + onCommand(prefix, fn, mod, { gmOnly = false, acl = [] } = {}) { + const wrapped = msg => { + if (msg.type !== 'api' || !msg.content.startsWith(prefix)) return; + if (gmOnly && !playerIsGM(msg.playerid)) return; + if (acl.length && !acl.includes(msg.playerid)) return; + this._metrics.commands++; + this._metrics.lastUpdate = new Date().toISOString(); + try { fn(msg); } + catch(e) { this.handleError(mod, e); } + }; + on('chat:message', wrapped); + this._commandHandlers[mod] = (this._commandHandlers[mod] || []).concat({ event:'chat:message', fn:wrapped }); + }, + + offCommands(mod) { + (this._commandHandlers[mod] || []).forEach(h => off(h.event, h.fn)); + this._commandHandlers[mod] = []; + }, + + onEvent(evt, fn, mod) { + const wrapped = (...args) => { + if (!READY || !MODULES[mod].initialized) return; + this._metrics.messages++; + this._metrics.lastUpdate = new Date().toISOString(); + try { fn(...args); } + catch(e) { this.handleError(mod, e); } + }; + on(evt, wrapped); + this._listeners[mod] = (this._listeners[mod] || []).concat({ event:evt, fn:wrapped }); + }, + + offEvents(mod) { + (this._listeners[mod] || []).forEach(h => off(h.event, h.fn)); + this._listeners[mod] = []; + }, + + _clearAllListeners() { + Object.keys(this._commandHandlers).forEach(m => this.offCommands(m)); + Object.keys(this._listeners).forEach(m => this.offEvents(m)); + }, + + _dedupePlanned() { + this._plannedEvents = [...new Set(this._plannedEvents)]; + this._plannedChatPrefixes = [...new Set(this._plannedChatPrefixes)]; + }, + + enableModule(name) { + _enqueue(() => { + const m = MODULES[name]; + if (!m) { this.log('Core', `No such module: ${name}`, 'WARN'); return; } + this.offEvents(name); + this.offCommands(name); + clearState(name); + getState(name).config.enabled = true; + m.initialized = true; + try { m.initFn(); this.log(name, 'Enabled'); } + catch(e) { this.handleError(name, e); } + }); + }, + + disableModule(name) { + _enqueue(() => { + const m = MODULES[name]; + if (!m) { this.log('Core', `No such module: ${name}`, 'WARN'); return; } + if (typeof m.teardown === 'function') { + try { m.teardown(); } + catch(e) { this.log(name, `Teardown failed: ${e.message}`, 'WARN'); } + } + this.offEvents(name); + this.offCommands(name); + clearState(name); + getState(name).config.enabled = false; + m.initialized = false; + this.log(name, 'Disabled'); + }); + } + }; + + globalThis.GameAssist = GameAssist; + + // ————— CONFIG COMMAND ————— + GameAssist.onCommand('!ga-config', msg => { + const parts = msg.content.trim().split(/\s+/); + const sub = parts[1]; + if (sub === 'list') { + const ts = new Date().toLocaleString(); + const ver = `v${VERSION}`; + const cfg = JSON.stringify(state[STATE_KEY].config, null, 2) + .replace(/[<>&]/g, c=>({'<':'<','>':'>','&':'&'})[c]); + const name = 'GameAssist Config'; + let handout = findObjs({ type:'handout', name })[0]; + if (!handout) handout = createObj('handout', { name, archived:false }); + handout.set('notes', `
Generated: ${ts} (${ver})\n\n${cfg}
`); + sendChat('GameAssist', `/w gm Config written to "${name}"`); + } + else if (sub === 'set' && parts.length >= 4) { + const mod = parts[2]; + const [ key, ...rest ] = parts.slice(3).join(' ').split('='); + const val = rest.join('='); + const parsed = parseConfigValue(val); + if (!MODULES[mod]) { + GameAssist.log('Config', `Unknown module: ${mod}`, 'WARN'); + return; + } + getState(mod).config[key.trim()] = parsed; + GameAssist.log('Config', `Set ${mod}.${key.trim()} = ${JSON.stringify(parsed)}`); + } + else if (sub === 'get' && parts.length >= 4) { + const mod = parts[2]; + const key = parts[3]; + if (!MODULES[mod]) { + GameAssist.log('Config', `Unknown module: ${mod}`, 'WARN'); + return; + } + const val = getState(mod).config[key]; + GameAssist.log('Config', `${mod}.${key} = ${JSON.stringify(val)}`); + } + else if (sub === 'modules') { + const moduleList = Object.entries(MODULES).map(([name, mod]) => { + const cfg = getState(name).config; + const status = cfg.enabled ? '✅' : '❌'; + const init = mod.initialized ? '🔄' : '⏸️'; + return `${status}${init} ${name}`; + }).join('\n'); + GameAssist.log('Config', `Modules:\n${moduleList}`); + } + else { + GameAssist.log('Config', 'Usage: !ga-config list|set|get|modules [args]'); + } + }, 'Core', { gmOnly: true }); + + // ————— CONTROL COMMANDS ————— + GameAssist.onCommand('!ga-enable', msg => { + const mod = msg.content.split(/\s+/)[1]; + if (!mod) { + GameAssist.log('Core', 'Usage: !ga-enable ', 'WARN'); + return; + } + GameAssist.enableModule(mod); + }, 'Core', { gmOnly: true }); + + GameAssist.onCommand('!ga-disable', msg => { + const mod = msg.content.split(/\s+/)[1]; + if (!mod) { + GameAssist.log('Core', 'Usage: !ga-disable ', 'WARN'); + return; + } + GameAssist.disableModule(mod); + }, 'Core', { gmOnly: true }); + + GameAssist.onCommand('!ga-status', msg => { + const metrics = GameAssist._metrics; + const avgDuration = metrics.taskDurations.length > 0 + ? (metrics.taskDurations.reduce((a,b) => a+b, 0) / metrics.taskDurations.length).toFixed(2) + : 'N/A'; + + const status = [ + `**GameAssist ${VERSION} Status**`, + `Commands: ${metrics.commands}`, + `Messages: ${metrics.messages}`, + `Errors: ${metrics.errors}`, + `Avg Task Duration: ${avgDuration}ms`, + `Queue Length: ${_queue.length}`, + `Last Update: ${metrics.lastUpdate || 'Never'}`, + `Modules: ${Object.keys(MODULES).length}`, + `Active Listeners: ${Object.values(GameAssist._listeners).flat().length}` + ].join('\n'); + + GameAssist.log('Status', status); + }, 'Core', { gmOnly: true }); + + // ————— CRITFUMBLE MODULE v0.2.4.9 ————— + GameAssist.register('CritFumble', function() { + // ─── Module Setup ────────────────────────────────────────────────────────────── + const modState = getState('CritFumble'); + Object.assign(modState.config, { + enabled: true, + debug: true, + useEmojis: true, + rollDelayMs: 200, + // Preserve any values previously saved in state + ...modState.config + }); + modState.runtime.activePlayers = modState.runtime.activePlayers || {}; + + // ─── Constants ───────────────────────────────────────────────────────────────── + /** Which Roll20 rolltemplates we watch for natural-1s */ + const VALID_TEMPLATES = ['atk','atkdmg','npcatk','spell']; + const FUMBLE_TABLES = { + melee: 'CF-Melee', + ranged: 'CF-Ranged', + spell: 'CF-Spell', + natural: 'CF-Natural', + thrown: 'CF-Thrown' + }; + // Lookup for confirm tables + const CONFIRM_TABLES = { + 'confirm-crit-martial': 'Confirm-Crit-Martial', + 'confirm-crit-magic': 'Confirm-Crit-Magic' + }; + + // ─── Helper Functions ────────────────────────────────────────────────────────── + /** + * debugLog(msg) + * Logs to the GM only when debug mode is on. + * Uses GameAssist.log under the hood. + */ + function debugLog(msg) { + if (modState.config.debug) { + GameAssist.log('CritFumble', msg); + } + } + function emoji(sym) { + return modState.config.useEmojis ? sym : ''; + } + + // Strip off any " (GM)" suffix so /w target resolves + function sanitizeWho(who) { + return who.replace(/ \(GM\)$/, ''); + } + + function sendTemplateMessage(who,title,fields) { + who = sanitizeWho(who); + const content = fields.map(f=>`{{${f.label}=${f.value}}}`).join(' '); + sendChat('CritFumble', `/w "${who}" &{template:default} {{name=${title}}} ${content}`); + } + + function getFumbleTableName(type) { + return FUMBLE_TABLES[type]||null; + } + + function sendConfirmMenu(who) { + const confirmButtons = [ + `[Confirm-Crit-Martial](!Confirm-Crit-Martial)`, + `[Confirm-Crit-Magic](!Confirm-Crit-Magic)` + ].join(' '); + + // Send to player + sendTemplateMessage(who, `${emoji('❓')} Confirm Critical Miss`, [ + { label: "Choose Confirmation Type", value: confirmButtons } + ]); + // Also send to GM + sendTemplateMessage('gm', `${emoji('❓')} Confirm Critical Miss for ${who}!`, [ + { label: "Choose Confirmation Type", value: confirmButtons } + ]); +} + + function sendFumbleMenu(who) { + sendConfirmMenu(who); + const buttons = [ + `[⚔ Melee](!critfumble-melee)`, + `[🏹 Ranged](!critfumble-ranged)`, + `[🎯 Thrown](!critfumble-thrown)`, + `[🔥 Spell](!critfumble-spell)`, + `[👊 Natural/Unarmed](!critfumble-natural)` + ].join(' '); + sendTemplateMessage(who, `${emoji('💥')} Critical Miss!`, [ + { label: "What kind of attack was this?", value: buttons } + ]); + // also whisper to GM for awareness + sendTemplateMessage('gm', `${emoji('💥')} Critical Miss for ${who}!`, [ + { label: "What kind of attack was this?", value: buttons } + ]); + } + + function announceTableRoll(tableName) { + sendTemplateMessage('gm', `${emoji('🎲')} Rolling Table`, [ + { label: "Table", value: `**${tableName}**` } + ]); + } + function executeTableRoll(tableName) { + setTimeout(()=>{ + sendChat('', `/roll 1t[${tableName}]`); + debugLog(`Roll command executed: /roll 1t[${tableName}]`); + }, modState.config.rollDelayMs); + } + + function rollFumbleTable(who,type) { + const table = getFumbleTableName(type); + if (!table) { + sendTemplateMessage(who, "⚠️ Invalid Fumble Type", [ + { label: "Requested", value: `"${type}"` }, + { label: "Valid Types", value: Object.keys(FUMBLE_TABLES).join(', ') } + ]); + debugLog(`Invalid fumble type "${type}"`); + return; + } + announceTableRoll(table); + executeTableRoll(table); + } + + function rollConfirmTable(who,rawCommand) { + const table = CONFIRM_TABLES[rawCommand.toLowerCase()]; + if (!table) { + sendTemplateMessage(who, "⚠️ Invalid Confirm Type", [ + { label: "Requested", value: `"${rawCommand}"` }, + { label: "Valid Options", value: Object.values(CONFIRM_TABLES).join(', ') } + ]); + debugLog(`Invalid confirm type "${rawCommand}"`); + return; + } + announceTableRoll(table); + executeTableRoll(table); + } + + function hasNaturalOne(inlinerolls) { + for (const group of inlinerolls) { + if (!group || !group.results || !Array.isArray(group.results.rolls)) continue; + for (const roll of group.results.rolls) { + // Only look at d20 dice rolls + if (roll.type !== 'R' || roll.sides !== 20 || !Array.isArray(roll.results)) continue; + for (const result of roll.results) { + // Defensive: result must have .v (value); .r is not always present + if (typeof result.v !== 'number') continue; + if (result.v === 1) return true; + } + } + } + return false; +} + + function showManualTriggerMenu() { + const players = Object.values(modState.runtime.activePlayers); + if (!players.length) { + sendTemplateMessage('gm', "⚠️ No Players Detected", [ + { label:"Note", value:"No players have been active yet this session." } + ]); + return; + } + const buttons = players.map(name=> + `[${name}](!critfumblemenu-${encodeURIComponent(name)})` + ).join(' '); + sendTemplateMessage('gm',"Manually Trigger Fumble Menu",[ + { label:"Select Player", value:buttons } + ]); + } + + function handleManualTrigger(encodedName) { + sendFumbleMenu(decodeURIComponent(encodedName)); + debugLog(`Manually triggered fumble menu for: ${encodedName}`); + } + + function showHelpMessage(who) { + sendTemplateMessage(who, "📘 CritFumble Help", [ + { label: "Version", value: "v0.2.4.9" }, + { label: "Commands", value: "`!critfail`, `!critfumble help`, `!critfumble-`, `!confirm-crit-martial`, `!confirm-crit-magic`" }, + { label: "Description", value: "Auto-detects critical misses and prompts attacker with a fumble menu; GM can also manually trigger via `!critfail`." }, + { label: "Valid Types", value: Object.keys(FUMBLE_TABLES).join(', ') } + ]); + } + + function handleRoll(msg) { + if (!msg) return; + // register active players + if (msg.playerid && !modState.runtime.activePlayers[msg.playerid]) { + const p = getObj('player', msg.playerid); + if (p) modState.runtime.activePlayers[msg.playerid] = p.get('displayname'); + } + + // API‐style commands + if (msg.type==='api') { + const cmd = (msg.content||'').trim().toLowerCase(); + + if (cmd==='!critfail') { + debugLog('Manual trigger: !critfail'); + return showManualTriggerMenu(); + } + if (cmd==='!critfumble help') { + return showHelpMessage(msg.who); + } + if (cmd.startsWith('!critfumblemenu-')) { + return handleManualTrigger(msg.content.replace('!critfumblemenu-','')); + } + if (cmd.startsWith('!critfumble-')) { + const who = sanitizeWho(msg.who); + const fumbleType = msg.content.replace('!critfumble-','').toLowerCase(); + debugLog(`${who} selected fumble type: ${fumbleType}`); + return rollFumbleTable(who, fumbleType); + } + if (cmd.startsWith('!confirm-crit-')) { + const who = sanitizeWho(msg.who); + const rawCommand = msg.content.slice(1); // e.g. "confirm-crit-martial" + debugLog(`${who} selected confirm type: ${rawCommand}`); + return rollConfirmTable(who, rawCommand); + } + return; + } + + // auto-detect natural 1 on a valid rolltemplate + if (!msg.rolltemplate || !VALID_TEMPLATES.includes(msg.rolltemplate)) return; + const rolls = msg.inlinerolls||[]; + if (!hasNaturalOne(rolls)) return; + + const who = sanitizeWho(msg.who); + debugLog(`Fumble detected from: ${who}`); + sendFumbleMenu(who); + } + + GameAssist.onEvent('chat:message', handleRoll, 'CritFumble'); + GameAssist.log('CritFumble','v0.2.4.9 Ready: Auto fumble detection + !critfail','INFO',{startup:true}); + }, { + enabled: true, + events: ['chat:message'], + prefixes: ['!critfail','!critfumble'] + }); + + // ————— NPC MANAGER MODULE v0.1.1.0 ————— + GameAssist.register('NPCManager', function() { + const modState = getState('NPCManager'); + + Object.assign(modState.config, { + enabled: true, + autoTrackDeath: true, + deadMarker: 'dead', + ...modState.config + }); + + function isNPC(token) { + if (!token || token.get('layer') !== 'objects') return false; + const charId = token.get('represents'); + if (!charId) return false; + + const npcAttr = findObjs({ + _type: 'attribute', + _characterid: charId, + name: 'npc' + })[0]; + + return npcAttr && npcAttr.get('current') === '1'; + } + + function checkForDeath(token) { + if (!modState.config.autoTrackDeath || !isNPC(token)) return; + + const hp = parseInt(token.get('bar1_value'), 10) || 0; + const markers = (token.get('statusmarkers') || '').split(','); + const isDead = markers.includes(modState.config.deadMarker); + + if (hp < 1 && !isDead) { + sendChat('api', `!token-mod --ids ${token.id} --set statusmarkers|+${modState.config.deadMarker}`); + GameAssist.log('NPCManager', `${token.get('name')} marked as dead (HP: ${hp})`); + } else if (hp >= 1 && isDead) { + sendChat('api', `!token-mod --ids ${token.id} --set statusmarkers|-${modState.config.deadMarker}`); + GameAssist.log('NPCManager', `${token.get('name')} revived (HP: ${hp})`); + } + } + + function handleTokenChange(obj, prev) { + if (obj.get('bar1_value') !== prev.bar1_value) { + checkForDeath(obj); + } + } + + GameAssist.onCommand('!npc-death-report', msg => { + const pageId = Campaign().get('playerpageid'); + const tokens = findObjs({ + _pageid: pageId, + _type: 'graphic', + layer: 'objects' + }); + + const flagged = []; + for (let token of tokens) { + if (!isNPC(token)) continue; + + const hp = parseInt(token.get('bar1_value'), 10) || 0; + const markers = (token.get('statusmarkers') || '').split(','); + const isDead = markers.includes(modState.config.deadMarker); + + if ((hp < 1 && !isDead) || (hp >= 1 && isDead)) { + flagged.push({ + name: token.get('name') || '(Unnamed)', + id: token.id, + hp, + markers: token.get('statusmarkers') || '(none)' + }); + } + } + + if (flagged.length === 0) { + GameAssist.log('NPCManager', '✅ Living NPCs have correct death marker states.'); + } else { + GameAssist.log('NPCManager', `⚠️ ${flagged.length} NPC(s) with mismatched death markers:`); + flagged.forEach(({ name, id, hp, markers }) => { + GameAssist.log('NPCManager', `- ${name} [${id}] | HP: ${hp} | Markers: ${markers}`); + }); + } + }, 'NPCManager', { gmOnly: true }); + + GameAssist.onEvent('change:graphic:bar1_value', handleTokenChange, 'NPCManager'); + GameAssist.log('NPCManager', 'v0.1.1.0 Ready: Auto death tracking + !npc-death-report', 'INFO', { startup: true }); + }, { + enabled: true, + events: ['change:graphic:bar1_value'], + prefixes: ['!npc-death-report'] + }); + +// ————— CONCENTRATION TRACKER MODULE v0.1.0.5 ————— +GameAssist.register('ConcentrationTracker', function() { + // ─── Module Setup ────────────────────────────────────────────────────────────── + const modState = getState('ConcentrationTracker'); + Object.assign(modState.config, { + enabled: true, + marker: 'Concentrating', + randomize: true, + ...modState.config + }); + modState.runtime.lastDamage = modState.runtime.lastDamage || {}; + + // ─── Public Command Prefixes ─────────────────────────────────────────────────── + const CMDS = ['!concentration', '!cc']; + const TOKEN_MARKER = 'Concentrating'; + + // ─── Default Emote Lines ──────────────────────────────────────────────────────── + const DEFAULT_LINES = { + success: [ + "steadies their breath, holding their focus.", + "'s grip tightens as they maintain their spell.", + "staggers slightly but does not lose concentration.", + "clenches their jaw, magic still flickering with intent.", + "narrows their eyes, spell still intact." + ], + failure: [ + "gasps, their focus shattered as the spell falters.", + "'s concentration breaks and the magic fades.", + "cries out, unable to maintain the spell.", + "'s spell fizzles as they lose control.", + "winces, focus lost in the heat of battle." + ] + }; + + // ─── Helper Functions ────────────────────────────────────────────────────────── + + /** + * getConfig() + * Merge default settings with stored config. + */ + function getConfig() { + return Object.assign({ randomize: true }, modState.config); + } + + /** + * getOutcomeLines(name) + * Returns the success/failure emote arrays with {{name}} replaced. + */ + function getOutcomeLines(name) { + const fill = line => line.replace("{{name}}", name); + return { + success: DEFAULT_LINES.success.map(fill), + failure: DEFAULT_LINES.failure.map(fill) + }; + } + + /** + * getConBonus(character) + * Reads the character's Constitution saving throw bonus. + */ + function getConBonus(character) { + const attr = findObjs({ + _type: 'attribute', + _characterid: character.id, + name: 'constitution_save_bonus' + })[0]; + return attr ? parseInt(attr.get('current'), 10) : 0; + } + + /** + * toggleMarker(token, on) + * Adds or removes the Concentrating status marker. + */ + function toggleMarker(token, on) { + sendChat('api', + `!token-mod --ids ${token.id} --set statusmarkers|${on ? '+' : '-'}${TOKEN_MARKER}` + ); + } + + /** + * postButtons(recipient) + * Sends the three-button UI for a new concentration check. + */ + function postButtons(recipient) { + const dmg = '?{Damage taken?|0}'; + const buttons = [ + `[🎯 Maintain Control](!concentration --damage ${dmg} --mode normal)`, + `[🧠 Brace for the Distraction](!concentration --damage ${dmg} --mode adv)`, + `[😣 Struggling to Focus](!concentration --damage ${dmg} --mode dis)` + ].join(' '); + sendChat('ConcentrationTracker', + `/w "${recipient}" ${buttons}
⚠️ Select your token before clicking.` + ); + } + + /** + * sendResult(player, dc, total, rolls, formula) + * Whispers the concentration-check result to player & GM. + */ + function sendResult(player, dc, total, rolls, formula) { + const tpl = + `&{template:default} {{name=🧠 Concentration Check}}` + + ` {{DC=${dc}}} {{Result=Roll(s) ${rolls} → ${total} (from ${formula})}}`; + sendChat('ConcentrationTracker', `/w "${player}" ${tpl}`); + sendChat('ConcentrationTracker', `/w gm ${tpl}`); + } + + /** + * showStatus(player) + * Lists all tokens currently marked Concentrating. + */ + function showStatus(player) { + const page = Campaign().get('playerpageid'); + const tokens = findObjs({ + _type: 'graphic', + _pageid: page, + layer: 'objects' + }).filter(t => + (t.get('statusmarkers') || '') + .toLowerCase() + .includes(TOKEN_MARKER.toLowerCase()) + ); + if (!tokens.length) { + return sendChat('ConcentrationTracker', + `/w "${player}" No tokens concentrating.` + ); + } + let out = `&{template:default} {{name=🧠 Concentration Status}}`; + tokens.forEach(t => { + out += `{{${t.get('name') || 'Unnamed'}=Concentrating}}`; + }); + sendChat('ConcentrationTracker', `/w "${player}" ${out}`); + } + + /** + * showHelp(player) + * Whisper the full list of commands and usage. + */ + function showHelp(player) { + const helpText = [ + "🧠 Concentration Help:", + "• !concentration / !cc → Show buttons", + "• --damage X → Roll vs DC = max(10,⌊X/2⌋)", + "• --mode normal|adv|dis→ Set roll mode", + "• --last → Repeat last check", + "• --off → Remove marker from selected tokens", + "• --status → Who is concentrating", + "• --config randomize on|off → Toggle emote randomization" + ].join('
'); + sendChat('ConcentrationTracker', `/w "${player}" ${helpText}`); + } + + /** + * handleRoll(msg, damage, mode) + * Executes the concentration roll workflow. + */ + function handleRoll(msg, damage, mode) { + const player = msg.who.replace(/ \(GM\)$/, ''); + if (!msg.selected?.length) { + return sendChat('ConcentrationTracker', + `/w "${player}" ⚠️ No token selected.` + ); + } + const token = getObj('graphic', msg.selected[0]._id); + if (!token) { + return sendChat('ConcentrationTracker', + `/w "${player}" ⚠️ Token not found.` + ); + } + const charId = token.get('represents'); + if (!charId) { + return sendChat('ConcentrationTracker', + `/w "${player}" ⚠️ Token not linked.` + ); + } + const character = getObj('character', charId); + if (!character) { + return sendChat('ConcentrationTracker', + `/w "${player}" ⚠️ Character not found.` + ); + } + + const bonus = getConBonus(character); + const dc = Math.max(10, Math.floor(damage / 2)); + const name = token.get('name') || character.get('name'); + const { success: S, failure: F } = getOutcomeLines(name); + const { randomize } = getConfig(); + + let expr = `1d20 + ${bonus}`; + if (mode === 'adv') expr = `2d20kh1 + ${bonus}`; + if (mode === 'dis') expr = `2d20kl1 + ${bonus}`; + + modState.runtime.lastDamage[msg.playerid] = damage; + + sendChat('', `[[${expr}]]`, ops => { + const roll = ops[0].inlinerolls?.[0]; + if (!roll) { + return sendChat('ConcentrationTracker', + `/w "${player}" ⚠️ Roll failed.` + ); + } + const total = roll.results.total; + const formula = roll.expression; + const vals = roll.results.rolls[0].results.map(r => r.v); + const rollsText = (mode === 'normal' ? vals[0] : vals.join(',')); + const ok = total >= dc; + + sendResult(player, dc, total, rollsText, formula); + + const pool = ok ? S : F; + const tail = randomize + ? pool[Math.floor(Math.random() * pool.length)] + : pool[0]; + sendChat(`character|${character.id}`, `/em ${tail}`); + toggleMarker(token, ok); + }); + } + + /** + * handleClear(msg) + * Clears the marker from selected tokens. + */ + function handleClear(msg) { + const player = msg.who.replace(/ \(GM\)$/, ''); + msg.selected?.forEach(sel => { + const t = getObj('graphic', sel._id); + if (t) toggleMarker(t, false); + }); + sendChat('ConcentrationTracker', `/w "${player}" ✅ Cleared markers.`); + } + + /** + * handleLast(msg) + * Repeats the last concentration check. + */ + function handleLast(msg) { + const player = msg.who.replace(/ \(GM\)$/, ''); + const dmg = modState.runtime.lastDamage[msg.playerid]; + if (!dmg) { + return sendChat('ConcentrationTracker', + `/w "${player}" ⚠️ No previous damage.` + ); + } + handleRoll(msg, dmg, 'normal'); + } + + // ─── Core Handler (Case-Insensitive) ────────────────────────────────────────── + function handler(msg) { + if (msg.type !== 'api') return; + + // 1) Normalize prefix: trim + lowercase + const raw = msg.content.trim(); + const parts = raw.toLowerCase().split(/\s+--/); + const cmd = parts.shift(); // "!concentration" or "!cc" + if (!CMDS.includes(cmd)) return; + + // 2) Identify player (strip " (GM)") + const player = msg.who.replace(/ \(GM\)$/, ''); + + // 3) Config branch + if (parts[0]?.startsWith('config ')) { + const [, key, val] = parts[0].split(/\s+/); + if (key === 'randomize') { + modState.config.randomize = (val === 'on' || val === 'true'); + return sendChat('ConcentrationTracker', + `/w "${player}" ✅ Randomize = ${modState.config.randomize}` + ); + } + return sendChat('ConcentrationTracker', + `/w "${player}" ❌ Unknown config ${key}` + ); + } + + // 4) Parse flags + let damage = 0, mode = 'normal'; + for (let p of parts) { + if (p === 'help') return showHelp(player); + if (p === 'status') return showStatus(player); + if (p === 'last') return handleLast(msg); + if (p === 'off') return handleClear(msg); + if (p.startsWith('damage ')) damage = parseInt(p.split(' ')[1], 10); + if (p.startsWith('mode ')) mode = p.split(' ')[1]; + } + + // 5) Execute + if (damage > 0) { + handleRoll(msg, damage, mode); + } else { + postButtons(player); + } + } + + // ─── Wire It Up ──────────────────────────────────────────────────────────────── + GameAssist.onEvent('chat:message', handler, 'ConcentrationTracker'); + GameAssist.log( + 'ConcentrationTracker', + `Ready: ${CMDS.join(' & ')}`, + 'INFO', + { startup: true } + ); +}, { + enabled: true, + prefixes: ['!concentration','!cc'], + teardown: () => { + const page = Campaign().get('playerpageid'); + findObjs({ _type: 'graphic', _pageid: page, layer: 'objects' }) + .filter(t => + (t.get('statusmarkers') || '') + .toLowerCase() + .includes('concentrating') + ) + .forEach(t => + sendChat('api', + `!token-mod --ids ${t.id} --set statusmarkers|-Concentrating` + ) + ); + } +}); + + // ————— NPC HP ROLLER MODULE v0.1.1.0 ————— + GameAssist.register('NPCHPRoller', function() { + const modState = getState('NPCHPRoller'); + + Object.assign(modState.config, { + enabled: true, + autoRollOnAdd: false, + ...modState.config + }); + + function parseDiceString(diceStr) { + // Match “NdM”, “NdM+K”, “NdM + K”, “NdM-K”, case-insensitive on “d” + const match = diceStr.match( + /^\s*(\d+)\s*[dD]\s*(\d+)(?:\s*([+-])\s*(\d+))?\s*$/ + ); + if (!match) return null; + + const count = parseInt(match[1], 10); + const sides = parseInt(match[2], 10); + const sign = match[3] === '-' ? -1 : 1; + const bonus = match[4] ? sign * parseInt(match[4], 10) : 0; + + return { count, sides, bonus }; + } + + function rollDice(count, sides) { + let total = 0; + for (let i = 0; i < count; i++) { + total += Math.floor(Math.random() * sides) + 1; + } + return total; + } + + function rollHP(diceData) { + const { count, sides, bonus } = diceData; + return rollDice(count, sides) + bonus; + } + + function rollTokenHP(token) { + const charId = token.get('represents'); + if (!charId) { + GameAssist.log('NPCHPRoller', 'Token not linked to character', 'WARN'); + return; + } + + const npcAttr = findObjs({ + _type: 'attribute', + _characterid: charId, + name: 'npc' + })[0]; + + if (!npcAttr || npcAttr.get('current') !== '1') { + return; + } + + const hpFormulaAttr = findObjs({ + _type: 'attribute', + _characterid: charId, + name: 'npc_hpformula' + })[0]; + + if (!hpFormulaAttr) { + GameAssist.log('NPCHPRoller', `No HP formula found for ${token.get('name')}`, 'WARN'); + return; + } + + const formula = hpFormulaAttr.get('current'); + const diceData = parseDiceString(formula); + + if (!diceData) { + GameAssist.log('NPCHPRoller', `Invalid HP formula: ${formula}`, 'WARN'); + return; + } + + const hp = rollHP(diceData); + + token.set('bar1_value', hp); + token.set('bar1_max', hp); + + GameAssist.log('NPCHPRoller', `${token.get('name')} HP set to ${hp} using [${formula}]`); + } + + GameAssist.onCommand('!npc-hp-all', async msg => { + const pageId = Campaign().get('playerpageid'); + const tokens = findObjs({ + _pageid: pageId, + _type: 'graphic', + layer: 'objects' + }); + + const npcTokens = []; + + for (const token of tokens) { + const characterId = token.get('represents'); + if (!characterId) continue; + + const npcAttr = findObjs({ + _type: 'attribute', + _characterid: characterId, + name: 'npc' + })[0]; + + if (npcAttr && npcAttr.get('current') === '1') { + npcTokens.push(token); + } + } + + GameAssist.log('NPCHPRoller', `Rolling HP for ${npcTokens.length} NPCs on current map...`); + + for (const token of npcTokens) { + try { + rollTokenHP(token); + } catch (err) { + GameAssist.log('NPCHPRoller', `Error processing ${token.get('name')}: ${err.message}`, 'ERROR'); + } + } + }, 'NPCHPRoller', { gmOnly: true }); + + GameAssist.onCommand('!npc-hp-selected', msg => { + if (!msg.selected || msg.selected.length === 0) { + GameAssist.log('NPCHPRoller', 'No tokens selected', 'WARN'); + return; + } + + msg.selected.forEach(sel => { + const token = getObj('graphic', sel._id); + if (token) { + try { + rollTokenHP(token); + } catch (err) { + GameAssist.log('NPCHPRoller', `Error processing ${token.get('name')}: ${err.message}`, 'ERROR'); + } + } + }); + }, 'NPCHPRoller', { gmOnly: true }); + + GameAssist.log('NPCHPRoller', 'v0.1.1.0 Ready: !npc-hp-all, !npc-hp-selected', 'INFO', { startup: true }); + }, { + enabled: true, + events: [], + prefixes: ['!npc-hp-all', '!npc-hp-selected'] + }); + + // ————— BOOTSTRAP ————— + on('ready', () => { + if (READY) return; + READY = true; + + state[STATE_KEY] = state[STATE_KEY] || { config: {} }; + GameAssist.config = state[STATE_KEY].config; + + GameAssist._clearAllListeners(); + seedDefaults(); + auditState(); + GameAssist._dedupePlanned(); + auditCompatibility(); + + GameAssist.log('Core', `GameAssist v${VERSION} ready; modules: ${Object.keys(MODULES).join(', ')}`); + + Object.entries(MODULES).forEach(([name, m]) => { + if (getState(name).config.enabled) { + m.initialized = true; + try { m.initFn(); } + catch(e) { GameAssist.handleError(name, e); } + } + }); + }); + +})(); diff --git a/GameAssist/script.json b/GameAssist/script.json index 09db558caa..7b6dea578f 100644 --- a/GameAssist/script.json +++ b/GameAssist/script.json @@ -1,26 +1,103 @@ { "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 is a modular D&D 5E (2014) automation suite for Roll20. It brings critical misses, concentration checks, NPC hit points, encounter history, configuration, and diagnostics together without forcing every campaign to use every feature.**\n\nGameAssist v0.1.4.7 includes six individually configurable modules behind one shared interface. Begin with guided menus and plain-language help, then use direct commands and deeper diagnostics whenever you need them.\n\n---\n\n## ⚙️ Requirements\n\n- **Roll20 access:** Mod scripts require a game created by a Roll20 Pro subscriber. Players in that game do not need their own Pro subscriptions.\n- **Character sheet:** D&D 5E (2014 Character Sheet)\n- **TokenMod:** Version 0.8.88 is the tested standalone baseline for death and concentration marker changes.\n- **StatusInfo:** Version 0.3.11 is optional and can add condition descriptions and menus.\n- **NPC tokens:** NPCManager and NPCHPRoller use token bar 1 for HP. Tokens must be on the Objects layer, linked to a character with `npc=1`, and need `npc_hpformula` when GameAssist rolls their HP.\n- **CritFumble tables:** `CF-Melee`, `CF-Ranged`, `CF-Thrown`, `CF-Spell`, `CF-Natural`, `Confirm-Crit-Martial`, and `Confirm-Crit-Magic`\n\n> GameAssist does not invent your campaign's fumble results. Create and populate the seven rollable tables before using CritFumble table rolls.\n\n> GameAssist sends marker requests to TokenMod using a GM identity. You do not need to enable TokenMod's player-facing **Players can use --ids** option for GameAssist.\n\n---\n\n## 🚀 Quick Start\n\nGameAssist is controlled from Roll20 chat. Commands beginning with `!` are sent to Mods instead of appearing as ordinary chat messages; players do not install anything separately.\n\n1. From the game's **Settings**, open **Mod (API) Scripts** and add **GameAssist** from the One-Click library.\n2. Confirm that **TokenMod** is also installed if you plan to use death or concentration markers.\n3. Create the seven CritFumble rollable tables if you plan to use CritFumble.\n4. Allow the Mod sandbox to restart, then type `!ga-status` in chat to check the installation.\n5. Open `!ga-config ui` and enable only the modules your campaign needs.\n\nUse `!critfumble help`, `!concentration --help`, or `!npc-death-report --help` whenever you want an in-game guide.\n\n## 🧩 Included Modules\n\n- **CritFumble** — Watches supported attack templates for natural 1s, offers the attacker clear choices, and rolls from your melee, ranged, thrown, spell, or natural-attack fumble tables. The GM can also open a player picker manually or roll martial and magic confirmation tables directly.\n- **ConcentrationTracker** — Builds normal, advantage, or disadvantage Constitution saves from damage, remembers each player's latest check, manages a concentration marker through TokenMod, and lists concentrating tokens on the page marked by Roll20's Player Ribbon.\n- **NPCManager** — Marks defeated NPCs, records revivals, checks HP against death markers, and organizes encounter history into readable handouts using **Campaign > Chapter > Section > Session** scopes. Custom Arc rosters can follow NPCs connected to a character story, vow, rivalry, investigation, redemption, or any other campaign thread.\n- **NPCHPRoller** — Rolls `npc_hpformula` for selected NPCs or qualifying NPCs on the page marked by Roll20's Player Ribbon. Optional automatic rolling handles newly added tokens while setup protection prevents placeholder HP from producing false death and revival records.\n- **ConfigUI** — Provides a GM-only chat interface for enabling modules, changing common settings, reviewing configuration, and reading module summaries. Command-based configuration remains available for advanced users.\n- **DebugTools** — Provides optional GM-only damage, marker, and saving-throw diagnostics. DebugTools starts disabled, and every action remains a preview until the GM includes `--apply`.\n\n---\n\n## 🎲 Example at the Table\n\nA linked NPC falls from positive HP to 0. NPCManager records that death in the active Campaign, Chapter, Section, and Session scopes, then asks TokenMod to apply the configured death marker. If the NPC later rises above 0 HP, GameAssist records the revival and requests marker removal.\n\nThe GM can glance at a recent summary in chat, run an audit to find tokens that need attention, write polished records to campaign handouts, or collect selected NPCs into a custom Arc roster tied to a player character's story.\n\n## 💬 Main Commands\n\n### System and Configuration\n\n- `!ga-status` — Show overall health, module state, errors, and dependency information\n- `!ga-status --details` — Add activity, listener, timing, and standalone-integration diagnostics\n- `!ga-config ui` or `!ga-config-ui` — Open the GM configuration panel\n- `!ga-config modules` — List modules and their current status\n- `!ga-config list` — Write a versioned configuration-only snapshot handout\n- `!ga-config get [key]` / `!ga-config set =` — Review or change module configuration\n- `!ga-config cleanup` — Clean up saved data left by removed or unknown modules\n- `!ga-enable ` / `!ga-disable ` — Enable or disable a module\n- `!ga-metrics` — Show GameAssist runtime metrics\n\n### Critical Misses\n\n- `!critfumble help` — Open the CritFumble quick-reference guide\n- `!critfumble menu` — Open the guided Natural 1 menu\n- `!critfail` — Open the direct manual player picker\n- `!critfumble-` — Roll the selected fumble table\n- `!confirm-crit-martial` / `!confirm-crit-magic` — Roll confirmation tables\n\n### Concentration\n\n- `!concentration` or `!cc` — Open concentration-check buttons\n- `!concentration --damage --mode ` — Roll a concentration save\n- `!concentration --last` — Repeat the player's previous check\n- `!concentration --off` — Remove the configured marker from selected linked tokens\n- `!concentration --status` — List concentrating tokens on the page marked by Roll20's Player Ribbon\n- `!concentration --config randomize on|off` — Toggle randomized result narration\n- `!ga-conc-status` — Show the GM concentration activity summary\n\n### NPC History and Reporting\n\n- `!npc-death-report --help` or `!npc-death-help` — Open the central NPCManager guide\n- `!npc-death-report [--scope campaign|chapter|section|session] [--recent|--page N|--write]` — Review a reporting scope or open the report writer\n- `!npc-death-buckets` — Review or rename active reporting scopes\n- `!npc-death-write` or `!npc-wr` — Write NPC history to handouts\n- `!npc-death-audit` — Check HP and death-marker consistency and update the audit handout\n- `!npc-death-arc` — Create and manage custom Arc rosters from selected tokens or the current Session\n- `!npc-death-clear [--scope campaign|chapter|section|session] [--nested] [--confirm]` — Clear records using explicit confirmation\n\n### NPC Hit Points and Diagnostics\n\n- `!npc-hp-selected` — Roll HP for selected qualifying NPCs\n- `!npc-hp-all` — Roll HP for qualifying NPCs on the page marked by Roll20's Player Ribbon\n- `!ga-debug` — Open diagnostics after enabling DebugTools\n\n> GameAssist help panels explain available choices in plain language. The full README contains the complete command matrix, permissions, examples, module settings, report workflows, migration guidance, and troubleshooting procedures.\n\n---\n\n## 🔄 Working Alongside Other Mods\n\nGameAssist is designed to share a campaign with other Roll20 Mods. **TokenMod is an intended dependency, and StatusInfo is an intended optional companion.** Problems arise only when two enabled tools try to control the same Roll20 data or react to the same event.\n\n- **NPC HP in token bar 1:** Another Mod that automatically rolls, replaces, damages, heals, or synchronizes bar 1 can trigger GameAssist's HP and death-history workflows. Choose one tool to own automatic NPC HP changes, or disable the overlapping feature.\n- **Death and concentration markers:** Another Mod that automatically adds or removes the same configured status marker can compete with GameAssist and produce confusing audit results. Assign one automation tool to each marker.\n- **Natural 1 attack handling:** Another Mod that watches the same attack templates for natural 1s can produce duplicate menus or multiple fumble results. Keep only one automatic critical-miss workflow enabled.\n- **GameAssist report handouts:** Report, audit, Arc, and configuration handouts are regenerated from GameAssist data when their write commands run. Keep unrelated campaign notes in separate handouts so they are not replaced.\n\nThese cautions concern overlapping behavior, not the mere presence of another Mod. Lighting, initiative, maps, macros, character utilities, and unrelated automation can continue normally.\n\n---\n\n## 🛡️ Safeguards You Can See\n\n- Newly added NPC tokens receive a short setup window so placeholder HP does not create a false death and revival.\n- Death and concentration marker requests are checked after TokenMod runs; the GM receives a useful warning when the token does not reach the requested state.\n- Built-in and custom markers are matched to the exact marker Roll20 stores on the token.\n- `!npc-death-audit` creates or updates a dedicated handout listing NPCs that need attention.\n- Every DebugTools action is a dry run until the GM includes `--apply`.\n- Every module can be enabled or disabled independently, allowing the campaign to keep useful features while turning off an overlap.\n\n## 🩺 Troubleshooting\n\nStart with `!ga-status`, then use `!ga-status --details` and `!ga-config modules`. Open the affected module's help panel before changing persistent state or enabling DebugTools.\n\n> **Unverifiable** does not necessarily mean TokenMod is missing. Test a real death or concentration marker and confirm the resulting token state.\n\n## 📚 Documentation and Support\n\nSee the [GameAssist README](https://github.com/Mord-Eagle/GameAssist#readme) for complete installation, configuration, command syntax, examples, permissions, report workflows, migration notes, troubleshooting, and upgrade guidance.\n\nBug reports and feature requests are welcome through [GameAssist Issues](https://github.com/Mord-Eagle/GameAssist/issues).", "authors": "Mord Eagle", "roll20userid": "10646976", - "modifies": [], + "patreon": "", + "tipeee": "", + "useroptions": [], + "dependencies": [ + "TokenMod" + ], + "modifies": { + "campaign.playerpageid": "read", + "campaign._token_markers": "read", + "graphic._pageid": "read", + "graphic.bar1_value": "read,write", + "graphic.bar1_max": "write", + "graphic.statusmarkers": "read,write", + "graphic.represents": "read", + "graphic.name": "read", + "graphic.layer": "read,write", + "character.name": "read", + "attribute._characterid": "read", + "attribute.name": "read", + "attribute.current": "read", + "handout.name": "read,write", + "handout.archived": "write", + "handout.notes": "write", + "player.displayname": "read" + }, "conflicts": [ + "Concentration", + "DeathTracker", "Other scripts that modify NPC HP or use bar1_value" ], - "useroptions": [], "commands": [ - "!ga-enable", - "!ga-disable", - "!ga-config", - "!npc-hp-all", - "!npc-hp-selected", - "!npc-death-report", + "!ga-status", + "!ga-status --details", + "!ga-config ui", + "!ga-config-ui", + "!ga-config modules", + "!ga-config list", + "!ga-config get [key]", + "!ga-config set =", + "!ga-config cleanup", + "!ga-enable ", + "!ga-disable ", + "!ga-metrics", + "!ga-metrics reset", + "!critfumble", + "!critfumble help", + "!critfumble menu", + "!critfail", + "!critfumble-", + "!confirm-crit-martial", + "!confirm-crit-magic", "!concentration", "!cc", - "!critfail", - "!critfumble" + "!concentration --help", + "!concentration --damage --mode ", + "!concentration --last", + "!concentration --off", + "!concentration --status", + "!concentration --config randomize on|off", + "!ga-conc-status", + "!npc-death-help", + "!npc-death-report", + "!npc-death-report --scope ", + "!npc-death-report --recent", + "!npc-death-report --page ", + "!npc-death-report --write", + "!npc-death-report --help", + "!npc-death-buckets", + "!npc-death-buckets [--campaign ] [--chapter ] [--section ] [--session ]", + "!npc-death-buckets --resetSession", + "!npc-death-clear", + "!npc-death-clear --scope [--nested] [--confirm]", + "!npc-death-write", + "!npc-wr", + "!npc-death-write --all", + "!npc-death-write --scope ", + "!npc-death-write --newSection ", + "!npc-death-audit", + "!npc-death-arc", + "!npc-death-arc --name ", + "!npc-death-arc --name --session", + "!npc-death-arc --name --note ", + "!npc-death-arc --name --manage", + "!npc-death-arc --name --allowDuplicates", + "!npc-hp-selected", + "!npc-hp-all", + "!ga-debug damage --amount [--token ] [--apply]", + "!ga-debug marker --marker [--state on|off|toggle] [--token ] [--apply]", + "!ga-debug save --dc [--bonus ] [--mode normal|adv|dis] [--label ] [--apply]" ] }