@reldens/skills
Version:
476 lines (353 loc) • 12.8 kB
Markdown
# EVENT SYSTEM ARCHITECTURE
## @reldens/skills Package Event Flow
## OVERVIEW
The @reldens/skills package uses a sophisticated event-driven architecture based on `EventsManagerSingleton` from `@reldens/utils`. Understanding this system is critical for proper implementation and testing.
## EVENT MANAGER SINGLETON
**File:** `@reldens/utils` package
**Instance:** `EventsManagerSingleton`
### Key Characteristics
1. **Global Singleton**: Only ONE instance exists across the entire application
2. **Shared by ALL entities**: Skills, ClassPath, LevelsSet, Sender, etc. all use the SAME EventsManager
3. **removeKey Registry**: Maintains a GLOBAL registry of removeKeys that persists between listener registrations
4. **Owner-Namespaced Events**: Events are prefixed with owner identifiers to avoid cross-owner collisions
## EVENT NAMING CONVENTION
### Event Name Construction
```javascript
// File: lib/levels-set.js:267-272
eventFullName(eventName) {
return this.getOwnerEventKey() + '.' + eventName;
}
getOwnerEventKey() {
return sc.get(this.owner, 'eventsPrefix', 'skills.ownerId.' + this.getOwnerId());
}
```
### Example Event Names
Given:
- Owner ID: `'player-123'`
- Owner eventsPrefix: `'skills.ownerId.player-123'`
- Event: `'reldens.skills.levelUp'`
**Full Event Name:**
```
skills.ownerId.player-123.reldens.skills.levelUp
```
This ensures events for Player 123 don't trigger listeners registered for Player 456.
## LISTENER REGISTRATION
### Method Signature
```javascript
// File: lib/levels-set.js:262-265
listenEvent(eventName, callback, removeKey, masterKey) {
return this.events.onWithKey(
this.eventFullName(eventName), // Full namespaced event name
callback, // Function to execute
removeKey, // Unique identifier for removal
masterKey // Master removal key (optional)
);
}
```
### Parameters Explained
| Parameter | Type | Required | Purpose |
|-----------|------|----------|---------|
| `eventName` | string | Yes | Base event name (e.g., `SkillsEvents.LEVEL_UP`) |
| `callback` | function | Yes | Function to execute when event fires |
| `removeKey` | string | No | **UNIQUE** identifier for this specific listener |
| `masterKey` | string | No | Master key for bulk removal (unused in current codebase) |
### Critical: removeKey Uniqueness
**IMPORTANT:** The `removeKey` parameter MUST be globally unique across your entire application!
**WHY:** EventsManagerSingleton maintains a global removeKey registry that is NOT cleared by `removeAllListeners()`.
**Bad Example (Causes Collisions):**
```javascript
// Test 1
classPath.listenEvent(SkillsEvents.ADD_SKILLS_BEFORE, callback, 'before-listener');
// afterEach() calls EventsManagerSingleton.removeAllListeners()
// Test 2 - COLLISION!
classPath.listenEvent(SkillsEvents.REMOVE_SKILLS_BEFORE, callback, 'before-listener');
// ❌ Registration fails silently - removeKey 'before-listener' already exists
```
**Good Example:**
```javascript
// Test 1
classPath.listenEvent(SkillsEvents.ADD_SKILLS_BEFORE, callback, 'add-before-listener');
// Test 2
classPath.listenEvent(SkillsEvents.REMOVE_SKILLS_BEFORE, callback, 'remove-before-listener');
// ✓ Different removeKey - works correctly
```
## EVENT FIRING
### Method Signature
```javascript
// File: lib/levels-set.js:257-260
async fireEvent(eventName, ...args) {
return await this.events.emit(this.eventFullName(eventName), ...args);
}
```
### Execution Flow
1. **Build Full Event Name**: `eventFullName()` adds owner prefix
2. **Emit to EventsManager**: `emit()` finds all registered listeners for that exact event name
3. **Execute Callbacks**: All matching listeners execute (synchronously in callback, but emit is async)
4. **Return**: After all listeners complete
### Key Points
- `fireEvent` is `async` and should be `awaited`
- Callbacks execute in registration order
- If no listeners exist, emit() completes immediately (no error)
- Listeners execute **synchronously** within the emit call (despite async emit method)
## COMMON EVENT PATTERNS
### Pattern 1: Skill Execution Events
```javascript
// Before skill execution
await this.fireEvent(SkillsEvents.SKILL_BEFORE_EXECUTE, this, target);
// Execute skill logic
let result = await this.doSomething();
// After skill execution
await this.fireEvent(SkillsEvents.SKILL_AFTER_EXECUTE, this, target);
```
### Pattern 2: Modifier Application Events
```javascript
// Before applying modifiers
await this.fireEvent(SkillsEvents.SKILL_APPLY_OWNER_EFFECTS, this, target);
// Apply modifiers
this.applyModifiers(this.ownerEffects, this.owner, true);
// Modifiers applied (no after event - fire within applyModifiers if needed)
```
### Pattern 3: Level Progression Events
```javascript
// Before level up
await this.fireEvent(SkillsEvents.LEVEL_UP, this);
// Apply level modifiers
await this.applyLevelModifiers();
// Level up complete (no explicit after event)
```
## EVENT PARAMETERS
### Common Parameter Patterns
Most events pass:
1. **`this`** - The object firing the event (Skill, ClassPath, etc.)
2. **Additional context** - Target, skills, damage, etc.
### Example Event Parameters
```javascript
// SKILL_BEFORE_EXECUTE
await this.fireEvent(SkillsEvents.SKILL_BEFORE_EXECUTE, this, target);
// Params: (skill, target)
// SKILL_ATTACK_APPLY_DAMAGE
await this.fireEvent(SkillsEvents.SKILL_ATTACK_APPLY_DAMAGE, this, target, damage, newValue);
// Params: (skill, target, damage, newValue)
// LEVEL_EXPERIENCE_ADDED
await this.fireEvent(SkillsEvents.LEVEL_EXPERIENCE_ADDED,
this, // levelsSet
number, // xp added
newTotalExp, // new total
currentLevelIndex, // index
nextLevelIndex, // next index
nextLevelKey, // next key
nextLevel, // next Level object
nextLevelExp, // xp required
isLevelUp // boolean
);
// Params: (levelsSet, number, newTotal, currentIdx, nextIdx, nextKey, nextLevel, nextExp, isLevelUp)
```
## SERVER-CLIENT EVENT SYNCHRONIZATION
### Sender Pattern (Server-Side)
**File:** `lib/server/sender.js`
The Sender class listens to ClassPath events and broadcasts them to connected clients:
```javascript
// Example: Level Up
this.classPath.listenEvent(SkillsEvents.LEVEL_UP, async (classPath) => {
await this.sendLevelUpData(classPath);
}, this.getOwnerUniqueEventKey('sender.level.up'));
```
**Flow:**
1. Server: ClassPath.levelUp() fires LEVEL_UP event
2. Server: Sender listener catches event
3. Server: Sender.sendLevelUpData() constructs message
4. Server: Client.send() or Client.broadcast() transmits message
5. Client: Receives message
6. Client: Receiver.processMessage() handles it
### Message Format
```javascript
{
act: 'rski.Lu', // Action constant (LEVEL_UP)
owner: 'player-123',
data: {
lvl: 5, // Current level
lab: 'Warrior', // Label
ne: 2000, // Next level XP
skl: ['sword', 'shield'] // Skill keys
}
}
```
### Receiver Pattern (Client-Side)
**File:** `lib/client/receiver.js`
The Receiver processes incoming messages and maps them to handler methods:
```javascript
// In your client code:
class MyGameClient extends Receiver {
onLevelUp(message) {
console.log('Level up!', message.data.lvl);
// Update UI, play animation, etc.
}
}
// Process all incoming messages
gameServer.onMessage('*', (message) => {
receiver.processMessage(message);
});
```
## TESTING ANTI-PATTERNS
### ❌ ANTI-PATTERN 1: Testing Through Events (Race Conditions)
**BAD:**
```javascript
it('should send level up message', async () => {
let sender = new Sender(classPath, mockClient);
sender.registerListeners(); // Registers event listener
await classPath.levelUp(); // Fires event asynchronously
// Race condition! Event may not have completed yet
assert.strictEqual(mockClient.sentMessages.length, 1); // ❌ May fail randomly
});
```
**GOOD:**
```javascript
it('should send level up message', async () => {
let sender = new Sender(classPath, mockClient);
sender.registerListeners();
// Call method directly, not through events
await sender.sendLevelUpData(classPath); // ✓ Deterministic
assert.strictEqual(mockClient.sentMessages.length, 1); // ✓ Always works
});
```
### ❌ ANTI-PATTERN 2: Using sleep() to Fix Race Conditions
**BAD:**
```javascript
await classPath.levelUp();
await TestHelpers.sleep(10); // ❌ Hack to wait for async events
assert.strictEqual(eventFired, true);
```
**GOOD:**
```javascript
await sender.sendLevelUpData(classPath); // ✓ Call directly
assert.strictEqual(eventFired, true);
```
**When sleep() IS Appropriate:**
```javascript
// Testing actual timer features (skillDelay, castTime)
skill.validate(); // Sets skillActivationTimer
await TestHelpers.sleep(skill.skillDelay + 10); // ✓ Testing timer behavior
assert.strictEqual(skill.canActivate, true);
```
### ❌ ANTI-PATTERN 3: Reusing removeKeys
**BAD:**
```javascript
// Test 1
classPath.listenEvent(SkillsEvents.ADD_SKILLS_BEFORE, callback, 'listener');
// Test 2 (after clearEventListeners)
classPath.listenEvent(SkillsEvents.REMOVE_SKILLS_BEFORE, callback, 'listener');
// ❌ removeKey collision!
```
**GOOD:**
```javascript
// Test 1
classPath.listenEvent(SkillsEvents.ADD_SKILLS_BEFORE, callback, 'add-listener');
// Test 2
classPath.listenEvent(SkillsEvents.REMOVE_SKILLS_BEFORE, callback, 'remove-listener');
// ✓ Unique removeKeys
```
## EVENT LIFECYCLE
### Skill Execution Event Sequence
```
1. VALIDATE_BEFORE
2. VALIDATE_SUCCESS (or VALIDATE_FAIL)
3. SKILL_BEFORE_EXECUTE
4. SKILL_APPLY_OWNER_EFFECTS (if ownerEffects exist)
5. SKILL_BEFORE_CAST (if castTime > 0)
6. SKILL_BEFORE_RUN_LOGIC
7. SKILL_AFTER_RUN_LOGIC
8. SKILL_AFTER_CAST (if castTime > 0)
9. SKILL_AFTER_EXECUTE
```
### Attack Skill Additional Events
```
10. SKILL_ATTACK_APPLY_DAMAGE (when damage is applied)
```
### Effect Skill Additional Events
```
10. SKILL_EFFECT_TARGET_MODIFIERS (when modifiers are applied)
```
### Physical Skill Additional Events
```
10. SKILL_PHYSICAL_ATTACK_HIT (on collision)
11. SKILL_PHYSICAL_EFFECT_HIT (on collision)
```
### Level Progression Event Sequence
```
1. INIT_LEVEL_SET_START
2. SET_LEVELS
3. GENERATED_LEVELS (for each auto-filled level range)
4. INIT_LEVEL_SET_END
5. LEVEL_EXPERIENCE_ADDED (when XP is added)
6. LEVEL_UP (when level increases)
7. LEVEL_DOWN (when level decreases)
8. LEVEL_APPLY_MODIFIERS (before modifiers are applied/reverted)
```
### ClassPath Event Sequence
```
1. INIT_CLASS_PATH_END (after initialization)
2. SET_SKILLS (after setOwnerSkills)
3. ADD_SKILLS_BEFORE
4. ADD_SKILLS_AFTER
5. REMOVE_SKILLS_BEFORE
6. REMOVE_SKILLS_AFTER
```
## EVENT DEBUGGING TIPS
### 1. Check Event Name Construction
```javascript
// Add logging in development
async fireEvent(eventName, ...args) {
let fullName = this.eventFullName(eventName);
console.log('[EVENT]', fullName, args);
return await this.events.emit(fullName, ...args);
}
```
### 2. Verify Listener Registration
```javascript
// Log when registering
listenEvent(eventName, callback, removeKey, masterKey) {
let fullName = this.eventFullName(eventName);
console.log('[LISTEN]', fullName, 'key:', removeKey);
return this.events.onWithKey(fullName, callback, removeKey, masterKey);
}
```
### 3. Check removeKey Collisions
```javascript
// Ensure all removeKeys are unique in your tests
let removeKeys = new Set();
function registerListener(event, callback, removeKey) {
if (removeKeys.has(removeKey)) {
throw new Error(`removeKey collision: ${removeKey}`);
}
removeKeys.add(removeKey);
return classPath.listenEvent(event, callback, removeKey);
}
```
## CRITICAL RULES
1. **Always await fireEvent()** - It's async for a reason
2. **Use unique removeKeys** - Never reuse removeKeys across tests
3. **Test methods directly** - Don't test through events unless testing the event system itself
4. **Never use sleep() for race conditions** - Fix the test pattern instead
5. **Clear listeners properly** - Call TestHelpers.clearEventListeners() in afterEach
6. **Namespace by owner** - Event names automatically include owner prefix
7. **Pass complete context** - Include all relevant data as event parameters
8. **Document event parameters** - Comment what each event passes to listeners
## REFERENCES
- Event Names: `lib/skills-events.js`
- Event Methods: `lib/levels-set.js:257-272`
- Sender Integration: `lib/server/sender.js`
- Receiver Integration: `lib/client/receiver.js`
- Test Helpers: `tests/utils/test-helpers.js`