Architecture: Hook Manager - Drowbe/coffee-pub-blacksmith GitHub Wiki
HookManager - Correct Implementation Approach
The Right Way: Simple Orchestration Layer
The HookManager should act as an orchestration layer where you just register a hook and a callback. It should be simple, not complex.
How It Should Work (Your Flow)
FoundryVTT Event → HookManager → Your Callback → Your Logic
↓ ↓ ↓ ↓
Actor Updated → Hook Fired → Your Function → Update Health Panel
Core Principles
Parameter Order (CRITICAL)
HookManager.registerHook({
name: 'hookName', // 1. FoundryVTT hook name
description: 'Description', // 2. Human-readable description
context: 'context-name', // 3. String for batch cleanup
priority: 3, // 4. Execution priority (1-5)
callback: (args) => { // 5. Your function to execute
// Your logic here
}
});
⚠️ WARNING: Parameter order is strict and must be exact!
When NOT to Use HookManager
Avoid using HookManager for these FoundryVTT lifecycle hooks:
Hooks.once('init', ...)- Module initializationHooks.once('ready', ...)- Module ready stateHooks.once('setup', ...)- Game setup
Why?
- These are one-time events that don't need priority management
- They're too critical to risk HookManager abstraction
- They're already working perfectly with FoundryVTT's native system
- HookManager is designed for recurring hooks that need ongoing management
Use HookManager for:
- Recurring hooks (
renderChatMessage,updateCombat, etc.) - Hooks that need priority ordering
- Hooks that need context-based cleanup
- Custom application hooks
Priority System (1-5)
priority: 1, // CRITICAL - runs first (system cleanup, critical features)
priority: 2, // HIGH - runs early (core functionality, data validation)
priority: 3, // NORMAL - default (most hooks, standard features)
priority: 4, // LOW - runs later (nice-to-have features, UI updates)
priority: 5, // LOWEST - runs last (cosmetic features, debug hooks)
Execution Order:
- Priority 1 hooks execute first (critical system operations)
- Priority 2 hooks execute second (core functionality)
- Priority 3 hooks execute third (normal operations)
- Priority 4 hooks execute fourth (low-priority features)
- Priority 5 hooks execute last (cosmetic/debug features)
Within the same priority level, hooks execute in the order they were registered.
1. Simple Registration
// This is what you want - simple and clean
HookManager.registerHook({
name: 'updateActor',
description: 'Updates health panel when actor HP changes',
context: 'health-panel',
priority: 3, // 1=Critical, 2=High, 3=Normal, 4=Low, 5=Lowest
callback: (actor, changes) => {
// Your logic here - update health panel, etc.
if (changes.system?.attributes?.hp) {
PanelManager.instance?.healthPanel?.update();
}
}
});
Parameter Order (REQUIRED):
name- FoundryVTT hook namedescription- Human-readable description for debuggingcontext- String for batch cleanup and organizationpriority- Execution priority (1-5)callback- Your function to execute
2. Automatic Data Passing
- FoundryVTT automatically provides the data when hooks fire
- You don't need to ask for this data - it's automatic
- No complex routing or abstraction layers needed
3. Clean Separation of Concerns
- HookManager: Just registers and manages hooks
- Your Code: Contains all the actual logic
- No business logic in the HookManager
What HookManager Should Do
✅ Good: Simple Orchestration
- Register hooks with callbacks
- Provide cleanup when module disables
- Organize hooks for visibility/debugging
- Handle hook lifecycle management
❌ Bad: Complex Routing
- Don't route events between panels
- Don't embed business logic
- Don't create multiple abstraction layers
- Don't overcomplicate what should be simple
Example Implementation
/**
* HookManager - Simple Orchestration Layer
* Registers hooks and provides cleanup - no business logic
*/
export class HookManager {
static hooks = new Map(); // hookName -> { hookId, callbacks: [], registeredAt }
static contexts = new Map(); // context -> Set(callbackId)
/**
* Generate unique callback ID
*/
static _makeCallbackId(name) {
return `${name}_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
}
/**
* Register a hook with a callback
* @param {Object} options - Hook registration options
* @param {string} options.name - FoundryVTT hook name
* @param {string} options.description - Human-readable description for debugging
* @param {string} options.context - String for batch cleanup and organization
* @param {number} options.priority - Priority level (1-5, default: 3)
* @param {Function} options.callback - Your callback function
* @param {Object} options.options - Additional options (e.g., { once: true, throttleMs: 50 })
* @param {string} options.key - Optional dedupe key to prevent duplicate registrations
* @returns {string} callbackId for cleanup
*/
static registerHook({ name, description = '', context, priority = 3, callback, options = {}, key }) {
if (typeof callback !== 'function') {
throw new Error(`HookManager: callback must be a function for ${name}`);
}
// Check for dedupe if key provided
if (key && this.hooks.has(name)) {
const existing = this.hooks.get(name).callbacks.find(cb => cb.key === key);
if (existing) return existing.callbackId;
}
// Create wrapper once per hook name
if (!this.hooks.has(name)) {
const hookRunner = (...args) => {
const entry = this.hooks.get(name);
if (!entry) return;
// Create stable copy and sort once
const list = entry.callbacks.slice().sort((a, b) =>
a.priority - b.priority || a.registeredAt - b.registeredAt
);
// Collect callbacks to remove (don't mutate during iteration)
const toRemove = [];
for (const cb of list) {
try {
cb.callback(...args);
if (cb.options?.once) {
toRemove.push(cb.callbackId);
}
} catch (error) {
console.error(`Hook callback error in ${name}:`, error);
}
}
// Cleanup "once" hooks after iteration
for (const id of toRemove) {
this.removeCallback(id);
}
};
const hookId = Hooks.on(name, hookRunner);
this.hooks.set(name, { hookId, callbacks: [], registeredAt: Date.now() });
}
const entry = this.hooks.get(name);
const callbackId = this._makeCallbackId(name);
// Apply throttle/debounce if specified
let finalCallback = callback;
if (options.throttleMs) {
finalCallback = this._throttle(callback, options.throttleMs);
} else if (options.debounceMs) {
finalCallback = this._debounce(callback, options.debounceMs);
}
const callbackRecord = {
callbackId,
callback: finalCallback,
description,
priority,
registeredAt: Date.now(),
options,
key
};
entry.callbacks.push(callbackRecord);
// Sort by priority, then by registration time for stability
entry.callbacks.sort((a, b) => a.priority - b.priority || a.registeredAt - b.registeredAt);
// Store context for batch cleanup
if (context) {
if (!this.contexts.has(context)) {
this.contexts.set(context, new Set());
}
this.contexts.get(context).add(callbackId);
}
// Logging hook registration
postConsoleAndNotification(
MODULE.NAME,
`Hook registered: ${name}`,
{ description, priority, totalCallbacks: entry.callbacks.length },
true,
false
);
return callbackId;
}
/**
* Remove a specific hook
* @param {string} hookName - Hook to remove
* @returns {boolean} Success status
*/
static removeHook(hookName) {
const hook = this.hooks.get(hookName);
if (!hook) return false;
// Remove all callbacks from contexts
hook.callbacks.forEach(cb => {
this._removeFromContexts(cb.callbackId);
});
Hooks.off(hookName, hook.hookId);
this.hooks.delete(hookName);
postConsoleAndNotification(
MODULE.NAME,
`Hook removed: ${hookName}`,
{ totalHooks: this.hooks.size },
true,
false
);
return true;
}
/**
* Remove a specific callback by its ID
* @param {string} callbackId - The callback ID returned from registerHook
* @returns {boolean} Success status
*/
static removeCallback(callbackId) {
const hookName = callbackId.split('_')[0];
const entry = this.hooks.get(hookName);
if (!entry) return false;
const idx = entry.callbacks.findIndex(cb => cb.callbackId === callbackId);
if (idx === -1) return false;
// Remove from contexts
this._removeFromContexts(callbackId);
// Remove the callback
entry.callbacks.splice(idx, 1);
// If no more callbacks, remove the entire hook
if (entry.callbacks.length === 0) {
Hooks.off(hookName, entry.hookId);
this.hooks.delete(hookName);
postConsoleAndNotification(
MODULE.NAME,
`Hook completely removed: ${hookName}`,
{ totalHooks: this.hooks.size },
true,
false
);
} else {
postConsoleAndNotification(
MODULE.NAME,
`Callback removed from hook: ${hookName}`,
{ remainingCallbacks: entry.callbacks.length },
true,
false
);
}
return true;
}
/**
* Remove all callbacks for a specific context
* @param {string} context - Context to cleanup
*/
static disposeByContext(context) {
const set = this.contexts.get(context);
if (!set) return;
for (const id of Array.from(set)) {
this.removeCallback(id);
}
this.contexts.delete(context);
}
/**
* Clean up all hooks
*/
static cleanup() {
this.hooks.forEach((hook, name) => {
if (hook.hookId) {
Hooks.off(name, hook.hookId);
}
});
const totalCleaned = this.hooks.size;
this.hooks.clear();
this.contexts.clear();
postConsoleAndNotification(
MODULE.NAME,
'All hooks cleaned up',
{ totalCleaned },
false,
false
);
}
/**
* Get hook statistics
* @returns {Object} Hook statistics
*/
static getStats() {
return {
totalHooks: this.hooks.size,
totalContexts: this.contexts.size,
hooks: Array.from(this.hooks.entries()).map(([name, hook]) => ({
name,
totalCallbacks: hook.callbacks.length,
registeredAt: new Date(hook.registeredAt).toISOString()
}))
};
}
/**
* Check if a hook is registered
* @param {string} hookName - Hook to check
* @returns {boolean} Is registered
*/
static hasHook(hookName) {
return this.hooks.has(hookName);
}
/**
* Show detailed hook information with priority grouping
*/
static showHookDetails() {
const stats = this.getStats();
console.group('COFFEE PUB • BLACKSMITH | HOOK MANAGER DETAILS');
console.log('==========================================================');
console.log(`Total Hooks: ${stats.totalHooks} | Active: ${stats.totalHooks} | Inactive: 0`);
console.log('==========================================================');
// Group by priority
const byPriority = new Map();
for (const [name, hook] of this.hooks.entries()) {
hook.callbacks.forEach(cb => {
if (!byPriority.has(cb.priority)) {
byPriority.set(cb.priority, []);
}
byPriority.get(cb.priority).push({ name, ...cb });
});
}
// Display by priority (1-5)
for (let priority = 1; priority <= 5; priority++) {
const hooks = byPriority.get(priority);
if (!hooks || hooks.length === 0) continue;
const priorityName = ['CRITICAL', 'HIGH', 'NORMAL', 'LOW', 'LOWEST'][priority - 1];
console.log(`\n${priorityName} PRIORITY (${priority})`);
console.log('==================================================');
hooks.forEach(({ name, description, callbackId, registeredAt }) => {
const time = new Date(registeredAt).toLocaleTimeString();
console.log(`ACTIVE ${name}`);
console.log(` ID: ${callbackId} | Priority: ${priority} | Categories: [general]`);
console.log(` Registered: ${time}`);
console.log(` Description: ${description || 'No description'}`);
});
}
console.groupEnd();
}
/**
* Show simple hook summary
*/
static showHooks() {
const stats = this.getStats();
console.log(`COFFEE PUB • BLACKSMITH | Total Hooks: ${stats.totalHooks}`);
console.log('Hook Names:', Array.from(this.hooks.keys()).join(', '));
}
/**
* Get hooks by priority level
*/
static getHooksByPriority(priority) {
const result = [];
for (const [name, hook] of this.hooks.entries()) {
const callbacks = hook.callbacks.filter(cb => cb.priority === priority);
if (callbacks.length > 0) {
result.push({ name, callbacks });
}
}
return result;
}
/**
* Get hooks by category (placeholder for future categorization)
*/
static getHooksByCategory(category) {
// For now, return all hooks since we don't have category system yet
const result = [];
for (const [name, hook] of this.hooks.entries()) {
result.push({ name, callbacks: hook.callbacks });
}
return result;
}
/**
* Throttle utility function
*/
static _throttle(fn, ms) {
let last = 0;
return (...args) => {
const now = Date.now();
if (now - last >= ms) {
last = now;
fn(...args);
}
};
}
/**
* Debounce utility function
*/
static _debounce(fn, ms) {
let timeout;
return (...args) => {
clearTimeout(timeout);
timeout = setTimeout(() => fn(...args), ms);
};
}
/**
* Remove callback from all contexts
*/
static _removeFromContexts(callbackId) {
for (const [context, set] of this.contexts.entries()) {
if (set.has(callbackId)) {
set.delete(callbackId);
if (set.size === 0) {
this.contexts.delete(context);
}
break;
}
}
}
/**
* Initialize HookManager with lifecycle hooks and console commands
*/
static initialize() {
// Set up lifecycle hooks
Hooks.once('closeGame', () => {
this.cleanup();
});
// Add console commands for debugging
window.blacksmithHooks = () => this.showHooks();
window.blacksmithHookDetails = () => this.showHookDetails();
window.blacksmithHookStats = () => this.getStats();
// Log initialization
postConsoleAndNotification(
MODULE.NAME,
"Hook Manager | Initialization",
"Initialized with console commands: blacksmithHooks(), blacksmithHookDetails(), blacksmithHookStats()",
true,
false
);
}
}
How to Use It (Simple)
// In your main code, register hooks with your logic
HookManager.registerHook({
name: 'updateActor',
description: 'Updates health panel when actor HP changes',
context: 'health-panel',
priority: 3, // Normal priority (default)
callback: (actor, changes) => {
// Your logic here - update health panel, etc.
if (changes.system?.attributes?.hp) {
PanelManager.instance?.healthPanel?.update();
}
}
});
HookManager.registerHook({
name: 'updateToken',
description: 'Handles token position updates',
context: 'token-position',
priority: 2, // High priority - runs early
callback: (token, changes) => {
// Your logic here
if (changes.x || changes.y) {
// Handle position changes
}
}
});
// Critical system hooks get priority 1
HookManager.registerHook({
name: 'closeGame',
description: 'Critical cleanup when game closes',
context: 'system-cleanup',
priority: 1, // Critical - runs first
callback: () => {
// Critical cleanup logic
}
});
// Temporary hooks with auto-cleanup
HookManager.registerHook({
name: 'userLogin',
description: 'One-time welcome message',
context: 'user-welcome',
priority: 4, // Low priority
options: { once: true }, // Auto-cleanup after first execution
callback: (user) => {
console.log(`Welcome back, ${user.name}!`);
}
});
// Advanced features: dedupe, context, and performance
HookManager.registerHook({
name: 'updateToken',
description: 'Update token visuals with dedupe protection',
priority: 2, // High priority
key: `token:${token.id}`, // Prevents duplicate registrations
context: `token:${token.id}`, // For batch cleanup
options: { throttleMs: 50 }, // Performance optimization
callback: (token, changes) => {
// Handle token updates efficiently
if (changes.x || changes.y) {
// Update position-dependent visuals
}
}
});
Real-World Migration Examples
Example 1: Combat Tracker Hook Migration
// BEFORE: Direct Hooks.on registration
Hooks.on('renderCombatTracker', (app, html, data) => {
// 100+ lines of combat tracker enhancement logic
// Health rings, portraits, drag & drop functionality
});
// AFTER: HookManager registration
const hookId = HookManager.registerHook({
name: 'renderCombatTracker',
description: 'Adds health rings, portraits, drag & drop to combat tracker',
priority: 3, // Normal priority - UI enhancements
callback: (app, html, data) => {
// 100+ lines of combat tracker enhancement logic
// Health rings, portraits, drag & drop functionality
// EXACTLY THE SAME LOGIC - just wrapped in HookManager
},
context: 'combat-tools' // For cleanup
});
// Log hook registration
postConsoleAndNotification(MODULE.NAME, "Hook Manager | renderCombatTracker", "combat-tools", true, false);
Example 2: Combat Update Hook Migration
// BEFORE: Direct Hooks.on registration
Hooks.on('updateCombat', (combat, changed) => {
// Combat round change logic
if (changed.round !== undefined) {
// Handle round changes
}
});
// AFTER: HookManager registration
const hookId = HookManager.registerHook({
name: 'updateCombat',
description: 'Combat Tracker: Handle round changes and initiative checking',
priority: 2, // High priority - core combat functionality
callback: (combat, changed) => {
// Combat round change logic
if (changed.round !== undefined) {
// Handle round changes
}
// EXACTLY THE SAME LOGIC - just wrapped in HookManager
},
context: 'combat-tracker-round-change'
});
// Log hook registration
postConsoleAndNotification(MODULE.NAME, "Hook Manager | updateCombat", "combat-tracker-round-change", true, false);
Example 3: Timer Hook Migration
// BEFORE: Direct Hooks.on registration
Hooks.on('updateCombat', this._onUpdateCombat.bind(this));
// AFTER: HookManager registration
const hookId = HookManager.registerHook({
name: 'updateCombat',
description: 'Round Timer: Reset round timer stats on round changes',
priority: 3, // Normal priority - timer management
callback: this._onUpdateCombat.bind(this),
context: 'timer-round'
});
// Log hook registration
postConsoleAndNotification(MODULE.NAME, "Hook Manager | updateCombat", "timer-round", true, false);
Example 4: Statistics Hook Migration
// BEFORE: Direct Hooks.on registration
Hooks.on('updateCombat', this._onUpdateCombat.bind(this));
// AFTER: HookManager registration
const hookId = HookManager.registerHook({
name: 'updateCombat',
description: 'Combat Stats: Record combat data for analytics',
priority: 3, // Normal priority - statistics collection
callback: this._onUpdateCombat.bind(this),
context: 'stats-combat'
});
// Log hook registration
postConsoleAndNotification(MODULE.NAME, "Hook Manager | updateCombat", "stats-combat", true, false);
Migration Benefits Demonstrated
1. Hook Conflict Resolution
// BEFORE: Multiple files registering the same hook
// combat-tools.js
Hooks.on('renderCombatTracker', ...); // UI enhancements
// combat-tracker.js
Hooks.on('renderCombatTracker', ...); // Core functionality
// timer-planning.js
Hooks.on('renderCombatTracker', ...); // Timer display
// Result: Last one wins, others are overwritten!
// AFTER: HookManager handles multiple callbacks
// All three hooks now work together with proper priority ordering
HookManager.registerHook({ name: 'renderCombatTracker', priority: 3, ... }); // UI
HookManager.registerHook({ name: 'renderCombatTracker', priority: 2, ... }); // Core
HookManager.registerHook({ name: 'renderCombatTracker', priority: 4, ... }); // Timer
2. Centralized Hook Management
// Console commands provide visibility into all hooks
blacksmithHooks() // Quick overview
blacksmithHookDetails() // Full breakdown with priority grouping
blacksmithHookStats() // Raw data for debugging
// Example output:
// HIGH PRIORITY (2)
// ==================================================
// ACTIVE updateCombat
// ID: updateCombat_1756398880835_8g44mkg | Priority: 2 | Categories: [general]
// Registered: 9:34:40 AM
// Description: Combat Tracker: Handle round changes and initiative checking
3. Context-Based Cleanup
// Each hook gets a context for batch cleanup
HookManager.registerHook({
name: 'updateCombat',
context: 'combat-tracker-round-change',
// ... other options
});
// Later, cleanup all combat tracker hooks
HookManager.disposeByContext('combat-tracker-round-change');
Advanced Features
1. Dedupe Protection
// Prevents duplicate registrations during re-renders
HookManager.registerHook({
name: 'updateActor',
key: `hp:${actor.id}`, // Unique identifier
callback: (actor, changes) => {
// This will only register once per actor
}
});
2. Context-Based Cleanup
// Register with context for batch cleanup
HookManager.registerHook({
name: 'updateToken',
context: `token:${token.id}`,
callback: (token, changes) => {
// Handle token updates
}
});
// Later, cleanup all hooks for a specific token
HookManager.disposeByContext(`token:${token.id}`);
3. Performance Optimization
// Throttle noisy hooks (e.g., updateToken)
HookManager.registerHook({
name: 'updateToken',
options: { throttleMs: 50 }, // Max once per 50ms
callback: (token, changes) => {
// Only runs at most once every 50ms
}
});
// Debounce for final state (e.g., search input)
HookManager.registerHook({
name: 'searchInput',
options: { debounceMs: 300 }, // Wait 300ms after last input
callback: (input) => {
// Only runs after user stops typing
}
});
"Once" Semantics for Auto-Cleanup
The HookManager supports automatic cleanup for temporary hooks using the { once: true } option:
// This hook will automatically remove itself after the first execution
HookManager.registerHook({
name: 'userLogin',
description: 'One-time welcome message',
options: { once: true }, // Auto-cleanup after first execution
callback: (user) => {
console.log(`Welcome back, ${user.name}!`);
// Hook automatically removes itself after this runs
}
});
Benefits of "Once" Hooks:
- Automatic cleanup - no need to manually remove temporary hooks
- Memory efficient - prevents accumulation of unused hooks
- Perfect for events that should only happen once (welcome messages, initialization, etc.)
- Cleaner code - no manual cleanup required
API Reference
Core Methods
// Register a hook
const callbackId = HookManager.registerHook({
name: 'hookName',
description: 'Optional description',
priority: 3, // 1-5, default: 3
callback: (arg1, arg2) => { /* your logic */ },
options: { once: true, throttleMs: 50, debounceMs: 300 },
key: 'uniqueKey', // Optional dedupe
context: 'contextName' // Optional batch cleanup
});
// Remove a specific callback
const removed = HookManager.removeCallback(callbackId); // Returns boolean
// Remove an entire hook and all its callbacks
const removed = HookManager.removeHook('hookName'); // Returns boolean
// Cleanup by context
HookManager.disposeByContext('contextName'); // Returns void
// Cleanup everything
HookManager.cleanup(); // Returns void
// Check if hook exists
const exists = HookManager.hasHook('hookName'); // Returns boolean
// Get statistics
const stats = HookManager.getStats(); // Returns object
Return Types
registerHook(...) -> callbackId: stringremoveCallback(callbackId) -> booleanremoveHook(hookName) -> booleandisposeByContext(context) -> voidcleanup() -> voidhasHook(hookName) -> booleangetStats() -> { totalHooks, totalContexts, hooks: [...] }
Enhanced Console Commands
// Show detailed hook information with priority grouping
HookManager.showHookDetails();
// Show simple hook summary
HookManager.showHooks();
// Filter hooks by priority
const criticalHooks = HookManager.getHooksByPriority(1);
const normalHooks = HookManager.getHooksByPriority(3);
// Filter hooks by category
const combatHooks = HookManager.getHooksByCategory('combat');
const canvasHooks = HookManager.getHooksByCategory('canvas');
// Get basic statistics
const stats = HookManager.getHookStats();
Why This Approach is Right
- Simple - Easy to understand and debug
- Efficient - No unnecessary function calls or routing
- Maintainable - Logic stays in the right place
- FoundryVTT Native - Works with the system, not against it
- Easy to Grep - Named parameters make searching and debugging easier
- Self-Documenting - Clear what each parameter does without remembering order
- Extensible - Easy to add new options without breaking existing code
The Problem with Complex Approaches
Complex HookManagers with routing layers:
- Add unnecessary complexity
- Create more points of failure
- Make debugging harder
- Don't solve real problems
- Violate the principle of "keep it simple"
Migration Success Stories
Hook Conflict Resolution
- Before: 5+
renderCombatTrackerhooks overwriting each other - After: All hooks work together with proper priority ordering
- Result: Combat tracker enhancements now function correctly
Centralized Visibility
- Before: No way to see what hooks were registered or their status
- After: Console commands show all hooks with descriptions and priorities
- Result: Easy debugging and monitoring of hook system
Cleanup Management
- Before: Hooks could accumulate and cause memory leaks
- After: Context-based cleanup and automatic lifecycle management
- Result: Better performance and resource management
Bottom Line
The HookManager should be a simple orchestration layer that:
- Registers your callbacks
- Lets FoundryVTT handle the data passing
- Keeps your logic in your code
- Provides clean organization and cleanup
Keep it simple. Don't overcomplicate.
FoundryVTT Hook Signatures
Important: Your callbacks receive the native FoundryVTT arguments which vary by hook type:
// updateActor: (actor, changes, options, userId)
HookManager.registerHook({
name: 'updateActor',
callback: (actor, changes, options, userId) => {
// DnD5e: changes.system?.attributes?.hp
// Other systems may have different structures
}
});
// updateToken: (token, changes, options, userId)
HookManager.registerHook({
name: 'updateToken',
callback: (token, changes, options, userId) => {
// changes.x, changes.y for position
// changes.scale for size
}
});
// renderChatMessage: (message, html, data)
HookManager.registerHook({
name: 'renderChatMessage',
callback: (message, html, data) => {
// Modify the HTML before display
}
});
Always check the FoundryVTT documentation for the specific hook you're using to ensure correct parameter handling.
CRITICAL DESIGN ISSUES IDENTIFIED
Problem 1: Multiple Systems Need Same Hook
// This will FAIL - can't register same hook twice!
HookManager.registerHook({
name: 'updateActor',
description: 'Updates health panel when actor HP changes',
callback: (actor, changes) => {
// Health Panel needs this
if (changes.system?.attributes?.hp) {
PanelManager.instance?.healthPanel?.update();
}
}
});
HookManager.registerHook({
name: 'updateActor',
description: 'Updates combat stats when actor HP changes',
callback: (actor, changes) => {
// Stats Tracker also needs this
if (changes.system?.attributes?.hp) {
StatsManager.instance?.updateCombatStats(actor);
}
}
});
Current Design Limitation: Only one callback per hook name
Problem 2: Module Conflicts
// Module A registers updateToken
HookManager.registerHook({
name: 'updateToken',
description: 'Module A token handler',
callback: (token, changes) => {
// Module A's logic
}
});
// Module B registers updateToken
HookManager.registerHook({
name: 'updateToken',
description: 'Module B token handler',
callback: (token, changes) => {
// Module B's logic
}
});
Module B will OVERWRITE Module A's hook!
REQUIRED DESIGN CHANGES
Solution: Multiple Callbacks Per Hook with Priority
static registerHook({ name, description = '', context, priority = 3, callback, options = {} }) {
// Check if this hook already exists
if (this.hooks.has(name)) {
// Add callback to existing hook
const hook = this.hooks.get(name);
hook.callbacks.push({
callback,
description,
priority,
registeredAt: Date.now(),
options
});
// Sort callbacks by priority (1 = highest, 5 = lowest)
hook.callbacks.sort((a, b) => a.priority - b.priority);
return `${name}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
// Create new hook with multiple callback support
const hookId = Hooks.on(name, (...args) => {
// Execute all callbacks in priority order
this.hooks.get(name).callbacks
.sort((a, b) => a.priority - b.priority)
.forEach(cb => {
try {
cb.callback(...args);
// Auto-cleanup for once hooks
if (cb.options?.once) {
this.removeCallback(cb.callbackId);
}
} catch (error) {
console.error(`Hook callback error in ${name}:`, error);
}
});
});
this.hooks.set(name, {
hookId,
callbacks: [{
callback,
description,
priority,
registeredAt: Date.now(),
options
}],
registeredAt: Date.now()
});
return hookId;
}
Updated Data Structure
static hooks = new Map(); // hookName -> { hookId, callbacks: [], registeredAt, priority, description }
Unique ID Generation
// Each callback gets a globally unique ID for precise removal
return `${name}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
// Result: "updateActor_1703123456789_a1b2c3d4e"
// Benefits:
// - Globally unique (no collision possibility)
// - Sortable (timestamp allows chronological ordering)
// - Debuggable (you can see when it was created)
// - Human readable (still easy to identify)
// - Supports priority and options for advanced features
DESIGN PRINCIPLES UPDATED
- Simple orchestration layer ✅
- No business logic in HookManager ✅
- Clean separation of concerns ✅
- Automatic data passing ✅
- Proper cleanup and management ✅
- Multiple callbacks per hook ✅ (NEW)
- Conflict resolution ✅ (NEW)
- Error handling in callbacks ✅ (NEW)
IMPLEMENTATION PRIORITY
- Fix multiple callback support (CRITICAL)
- Add error handling (HIGH)
- Implement callback removal by ID (MEDIUM)
- Add execution order control (LOW)
Keep the simple philosophy, fix the multiple callback limitation.
🤖 AI IMPLEMENTATION GUIDE
For Future AI Assistants Implementing HookManager Migration
CRITICAL: This is a production system. Make focused, tested changes only.
IMPLEMENTATION APPROACH
1. Migration Philosophy
- Change NO code until you understand the current state
- Make ONE change at a time - test before proceeding
- Roll back immediately if anything breaks
- NO large consolidations - prefer incremental approach
- NO documentation updates until after successful testing
2. Required Investigation Steps
// ALWAYS start with these console commands to understand current state
blacksmithHooks() // Quick overview
blacksmithHookDetails() // Full breakdown with priority grouping
blacksmithHookStats() // Raw data for debugging
3. Migration Process
- Identify target hook - Find a direct
Hooks.on()registration - Add HookManager import -
import { HookManager } from './manager-hooks.js'; - Replace registration - Convert
Hooks.on()toHookManager.registerHook() - Add logging -
postConsoleAndNotification(MODULE.NAME, "Hook Manager | hookName", "context", true, false); - Test immediately - Verify functionality works
- Move to next hook - Only after successful testing
MIGRATION TEMPLATE
Before (Direct Hook Registration)
// OLD: Direct Hooks.on registration
Hooks.on('hookName', (arg1, arg2) => {
// Existing logic - DO NOT CHANGE THIS CODE
// Just wrap it in HookManager
});
After (HookManager Registration)
// NEW: HookManager registration
import { HookManager } from './manager-hooks.js';
const hookId = HookManager.registerHook({
name: 'hookName',
description: 'Brief description of what this hook does',
context: 'descriptive-context-name', // For cleanup
priority: 3, // 1=Critical, 2=High, 3=Normal, 4=Low, 5=Lowest
callback: (arg1, arg2) => {
// EXACTLY THE SAME LOGIC - unchanged
// Existing logic - DO NOT CHANGE THIS CODE
// Just wrap it in HookManager
}
});
// Log hook registration
postConsoleAndNotification(MODULE.NAME, "Hook Manager | hookName", "descriptive-context-name", true, false);
⚠️ CRITICAL: Parameter Order Must Be Exact
namedescriptioncontextprioritycallback
PRIORITY GUIDELINES
Priority 1 (CRITICAL)
- System cleanup, critical features
- Must run first before anything else
Priority 2 (HIGH)
- Core functionality, data validation
- Runs early in the process
Priority 3 (NORMAL)
- Most hooks, standard features
- Default priority for most use cases
Priority 4 (LOW)
- Nice-to-have features, UI updates
- Runs later in the process
Priority 5 (LOWEST)
- Cosmetic features, debug hooks
- Runs last
CONTEXT NAMING CONVENTIONS
Use Descriptive Context Names
// GOOD: Clear, descriptive contexts
context: 'combat-tracker-round-change'
context: 'timer-round'
context: 'stats-combat'
context: 'combat-tools'
// BAD: Vague contexts
context: 'hook1'
context: 'temp'
context: 'stuff'
Context Examples by Module
// Combat-related hooks
context: 'combat-tracker-round-change'
context: 'combat-tracker-player-initiative'
context: 'combat-tools'
// Timer-related hooks
context: 'timer-round'
context: 'timer-planning'
context: 'timer-combat'
// Statistics hooks
context: 'stats-combat'
context: 'stats-player'
TESTING CHECKLIST
After Each Migration
- Console output - Verify hook registration message appears
- Functionality test - Trigger the hook and verify it works
- HookManager status - Run
blacksmithHookDetails()to see new hook - No errors - Check console for any error messages
- Rollback plan - Keep backup of original code until testing complete
Common Test Scenarios
// For renderCombatTracker hooks
// - Open combat tracker
// - Verify visual enhancements appear (health rings, portraits, etc.)
// - Check console for hook registration message
// For updateCombat hooks
// - Start/advance combat
// - Verify expected behavior occurs
// - Check console for hook registration message
// For updateActor hooks
// - Modify an actor's HP or other attributes
// - Verify expected behavior occurs
// - Check console for hook registration message
ERROR PREVENTION
Common Mistakes to Avoid
- Changing business logic - Only wrap existing code, don't modify it
- Missing imports - Always add
import { HookManager } from './manager-hooks.js'; - Wrong priority - Use 3 for normal hooks unless you have a specific reason
- Missing logging - Always add the postConsoleAndNotification call
- Vague contexts - Use descriptive context names for cleanup
Import Pattern
// CORRECT: Add import at top of file
import { HookManager } from './manager-hooks.js';
// INCORRECT: Missing import
// This will cause "HookManager is not defined" error
Logging Pattern
// CORRECT: Log after registration
const hookId = HookManager.registerHook({...});
postConsoleAndNotification(MODULE.NAME, "Hook Manager | hookName", "context", true, false);
// INCORRECT: Missing logging
// This makes debugging harder
ROLLBACK PROCEDURE
If Something Breaks
- Immediately stop - Don't make more changes
- Restore original code - Use the backup you kept
- Test functionality - Verify it works again
- Analyze the problem - What went wrong?
- Fix the issue - Make a smaller, more focused change
- Test again - Before proceeding
Backup Strategy
// BEFORE making changes, create a backup
// Either:
// 1. Copy the original Hooks.on line to a comment
// 2. Keep a backup file
// 3. Use git to track changes
// Example backup in comment:
// BACKUP: Hooks.on('updateCombat', this._onUpdateCombat.bind(this));
const hookId = HookManager.registerHook({...});
MIGRATION ORDER RECOMMENDATION
Start with Simple Hooks
- Statistics hooks - Usually simple, low-risk
- Timer hooks - Moderate complexity, good for learning
- Combat tracker hooks - More complex, save for later
- UI enhancement hooks - Most complex, test thoroughly
Hook Complexity Guide
// SIMPLE: Direct method calls
Hooks.on('updateCombat', this._onUpdateCombat.bind(this));
// → Easy to migrate, low risk
// MODERATE: Inline logic with some complexity
Hooks.on('updateCombat', (combat, changed) => {
if (changed.round !== undefined) {
// Some logic here
}
});
// → Moderate complexity, test carefully
// COMPLEX: Large inline functions (100+ lines)
Hooks.on('renderCombatTracker', (app, html, data) => {
// 100+ lines of complex logic
});
// → High complexity, test thoroughly, consider breaking into smaller functions
SUCCESS INDICATORS
What Success Looks Like
- Hook registration message appears in console
- Functionality works exactly as before
- HookManager shows new hook in
blacksmithHookDetails() - No console errors related to the migration
- Performance maintained - no noticeable slowdown
Console Output Example
// Successful migration shows:
COFFEE PUB • BLACKSMITH: Hook Manager | updateCombat stats-combat
// And in blacksmithHookDetails():
NORMAL PRIORITY (3)
==================================================
ACTIVE updateCombat
ID: updateCombat_1756402883904_t4rk1ne | Priority: 3 | Categories: [general]
Registered: 10:41:23 AM
Description: Combat Stats: Record combat data for analytics
FINAL REMINDERS
Core Principles
- ONE change at a time
- Test after each change
- Keep backups
- Roll back if broken
- Don't optimize or clean up unless specifically requested
- Focus on migration only
Success Metrics
- All hooks migrated to HookManager
- No functionality broken
- Console shows all hooks registered
- HookManager provides visibility and control
- System performance maintained or improved
When to Stop
- All direct
Hooks.on()registrations migrated - All functionality working correctly
- HookManager providing value (visibility, cleanup, etc.)
- No critical errors or performance issues
Remember: This is a production system. Make focused, tested changes only. When in doubt, make a smaller change and test it thoroughly before proceeding.