Skip to main content
Glama
custom-effects.repo.ts14.8 kB
/** * CustomEffectsRepository * * Handles CRUD operations for custom effects (divine boons, curses, transformations). */ import Database from 'better-sqlite3'; import { CustomEffect, CustomEffectSchema, ApplyCustomEffectArgs, EffectMechanic, EffectTrigger, RemovalCondition, TriggerEvent, ActorType } from '../../schema/improvisation.js'; interface CustomEffectRow { id: number; target_id: string; target_type: string; name: string; description: string | null; source_type: string; source_entity_id: string | null; source_entity_name: string | null; category: string; power_level: number; mechanics: string; duration_type: string; duration_value: number | null; rounds_remaining: number | null; triggers: string; removal_conditions: string; stackable: number; max_stacks: number; current_stacks: number; is_active: number; created_at: string; expires_at: string | null; } export class CustomEffectsRepository { constructor(private db: Database.Database) {} /** * Apply a new custom effect to a target */ apply(args: ApplyCustomEffectArgs): CustomEffect { const now = new Date().toISOString(); // Calculate expires_at for time-based durations let expiresAt: string | null = null; if (args.duration.type !== 'permanent' && args.duration.type !== 'until_removed' && args.duration.value) { const expireDate = new Date(); switch (args.duration.type) { case 'minutes': expireDate.setMinutes(expireDate.getMinutes() + args.duration.value); break; case 'hours': expireDate.setHours(expireDate.getHours() + args.duration.value); break; case 'days': expireDate.setDate(expireDate.getDate() + args.duration.value); break; // 'rounds' is tracked separately via rounds_remaining } if (args.duration.type !== 'rounds') { expiresAt = expireDate.toISOString(); } } // Check for existing non-stackable effect const existing = this.findByTargetAndName(args.target_id, args.target_type, args.name); if (existing && !args.stackable) { // Refresh duration instead of creating new return this.refreshDuration(existing.id, args.duration.value || null); } if (existing && args.stackable && existing.current_stacks < (args.max_stacks ?? 1)) { // Increment stacks return this.incrementStacks(existing.id); } // Insert new effect const stmt = this.db.prepare(` INSERT INTO custom_effects ( target_id, target_type, name, description, source_type, source_entity_id, source_entity_name, category, power_level, mechanics, duration_type, duration_value, rounds_remaining, triggers, removal_conditions, stackable, max_stacks, current_stacks, is_active, created_at, expires_at ) VALUES ( @targetId, @targetType, @name, @description, @sourceType, @sourceEntityId, @sourceEntityName, @category, @powerLevel, @mechanics, @durationType, @durationValue, @roundsRemaining, @triggers, @removalConditions, @stackable, @maxStacks, @currentStacks, @isActive, @createdAt, @expiresAt ) `); const result = stmt.run({ targetId: args.target_id, targetType: args.target_type, name: args.name, description: args.description, sourceType: args.source.type, sourceEntityId: args.source.entity_id || null, sourceEntityName: args.source.entity_name || null, category: args.category, powerLevel: args.power_level, mechanics: JSON.stringify(args.mechanics), durationType: args.duration.type, durationValue: args.duration.value || null, roundsRemaining: args.duration.type === 'rounds' ? args.duration.value : null, triggers: JSON.stringify(args.triggers), removalConditions: JSON.stringify(args.removal_conditions), stackable: args.stackable ? 1 : 0, maxStacks: args.max_stacks ?? 1, currentStacks: 1, isActive: 1, createdAt: now, expiresAt }); return this.findById(result.lastInsertRowid as number)!; } /** * Find an effect by ID */ findById(id: number): CustomEffect | null { const stmt = this.db.prepare('SELECT * FROM custom_effects WHERE id = ?'); const row = stmt.get(id) as CustomEffectRow | undefined; return row ? this.rowToEffect(row) : null; } /** * Find effect by target and name */ findByTargetAndName(targetId: string, targetType: ActorType, name: string): CustomEffect | null { const stmt = this.db.prepare(` SELECT * FROM custom_effects WHERE target_id = ? AND target_type = ? AND name = ? AND is_active = 1 `); const row = stmt.get(targetId, targetType, name) as CustomEffectRow | undefined; return row ? this.rowToEffect(row) : null; } /** * Get all active effects on a target */ getEffectsOnTarget(targetId: string, targetType: ActorType, filters?: { category?: string; source_type?: string; is_active?: boolean; }): CustomEffect[] { let query = 'SELECT * FROM custom_effects WHERE target_id = ? AND target_type = ?'; const params: any[] = [targetId, targetType]; if (filters?.category) { query += ' AND category = ?'; params.push(filters.category); } if (filters?.source_type) { query += ' AND source_type = ?'; params.push(filters.source_type); } if (filters?.is_active !== undefined) { query += ' AND is_active = ?'; params.push(filters.is_active ? 1 : 0); } else { // Default to active effects only query += ' AND is_active = 1'; } const stmt = this.db.prepare(query); const rows = stmt.all(...params) as CustomEffectRow[]; return rows.map(row => this.rowToEffect(row)); } /** * Get effects by trigger event */ getEffectsByTrigger(targetId: string, targetType: ActorType, event: TriggerEvent): CustomEffect[] { const effects = this.getEffectsOnTarget(targetId, targetType); return effects.filter(effect => effect.triggers.some(trigger => trigger.event === event || trigger.event === 'always_active') ); } /** * Remove an effect by ID */ remove(id: number): boolean { const stmt = this.db.prepare('DELETE FROM custom_effects WHERE id = ?'); const result = stmt.run(id); return result.changes > 0; } /** * Remove effect by target and name */ removeByName(targetId: string, targetType: ActorType, name: string): boolean { const stmt = this.db.prepare(` DELETE FROM custom_effects WHERE target_id = ? AND target_type = ? AND name = ? `); const result = stmt.run(targetId, targetType, name); return result.changes > 0; } /** * Deactivate an effect (keep record but mark inactive) */ deactivate(id: number): CustomEffect | null { const stmt = this.db.prepare('UPDATE custom_effects SET is_active = 0 WHERE id = ?'); stmt.run(id); return this.findById(id); } /** * Advance round-based durations, deactivating expired effects */ advanceRounds(targetId: string, targetType: ActorType, rounds: number = 1): { advanced: CustomEffect[]; expired: CustomEffect[]; } { const effects = this.getEffectsOnTarget(targetId, targetType); const advanced: CustomEffect[] = []; const expired: CustomEffect[] = []; for (const effect of effects) { if (effect.duration_type === 'rounds' && effect.rounds_remaining !== null) { const newRoundsRemaining = effect.rounds_remaining - rounds; if (newRoundsRemaining <= 0) { // Effect expires this.deactivate(effect.id); expired.push({ ...effect, is_active: false, rounds_remaining: 0 }); } else { // Update rounds remaining const stmt = this.db.prepare('UPDATE custom_effects SET rounds_remaining = ? WHERE id = ?'); stmt.run(newRoundsRemaining, effect.id); advanced.push({ ...effect, rounds_remaining: newRoundsRemaining }); } } else { advanced.push(effect); } } return { advanced, expired }; } /** * Refresh duration on an existing effect */ refreshDuration(id: number, newDurationValue: number | null): CustomEffect { const effect = this.findById(id); if (!effect) { throw new Error(`Effect ${id} not found`); } if (effect.duration_type === 'rounds') { const stmt = this.db.prepare('UPDATE custom_effects SET rounds_remaining = ? WHERE id = ?'); stmt.run(newDurationValue, id); } else if (effect.duration_type !== 'permanent' && effect.duration_type !== 'until_removed') { // Recalculate expires_at const expireDate = new Date(); const value = newDurationValue || effect.duration_value || 1; switch (effect.duration_type) { case 'minutes': expireDate.setMinutes(expireDate.getMinutes() + value); break; case 'hours': expireDate.setHours(expireDate.getHours() + value); break; case 'days': expireDate.setDate(expireDate.getDate() + value); break; } const stmt = this.db.prepare('UPDATE custom_effects SET expires_at = ?, duration_value = ? WHERE id = ?'); stmt.run(expireDate.toISOString(), value, id); } return this.findById(id)!; } /** * Increment stacks on a stackable effect */ incrementStacks(id: number): CustomEffect { const effect = this.findById(id); if (!effect) { throw new Error(`Effect ${id} not found`); } if (!effect.stackable) { throw new Error(`Effect ${effect.name} is not stackable`); } if (effect.current_stacks >= effect.max_stacks) { // At max, just refresh duration return this.refreshDuration(id, effect.duration_value); } const stmt = this.db.prepare('UPDATE custom_effects SET current_stacks = current_stacks + 1 WHERE id = ?'); stmt.run(id); return this.findById(id)!; } /** * Decrement stacks on a stackable effect (removes if reaches 0) */ decrementStacks(id: number): CustomEffect | null { const effect = this.findById(id); if (!effect) { return null; } if (effect.current_stacks <= 1) { this.remove(id); return null; } const stmt = this.db.prepare('UPDATE custom_effects SET current_stacks = current_stacks - 1 WHERE id = ?'); stmt.run(id); return this.findById(id); } /** * Check and remove expired time-based effects */ cleanupExpired(): number { const now = new Date().toISOString(); const stmt = this.db.prepare(` UPDATE custom_effects SET is_active = 0 WHERE is_active = 1 AND expires_at IS NOT NULL AND expires_at < ? `); const result = stmt.run(now); return result.changes; } /** * Get all active effects with a specific mechanic type */ getEffectsByMechanicType(targetId: string, targetType: ActorType, mechanicType: string): CustomEffect[] { const effects = this.getEffectsOnTarget(targetId, targetType); return effects.filter(effect => effect.mechanics.some(m => m.type === mechanicType) ); } /** * Calculate total bonus from all effects of a given mechanic type */ calculateTotalBonus(targetId: string, targetType: ActorType, mechanicType: string, condition?: string): number { const effects = this.getEffectsByMechanicType(targetId, targetType, mechanicType); let total = 0; for (const effect of effects) { for (const mechanic of effect.mechanics) { if (mechanic.type === mechanicType) { // Check condition match if specified if (condition && mechanic.condition && !mechanic.condition.includes(condition)) { continue; } if (typeof mechanic.value === 'number') { total += mechanic.value * effect.current_stacks; } } } } return total; } /** * Convert database row to CustomEffect object */ private rowToEffect(row: CustomEffectRow): CustomEffect { return CustomEffectSchema.parse({ id: row.id, target_id: row.target_id, target_type: row.target_type, name: row.name, description: row.description, source_type: row.source_type, source_entity_id: row.source_entity_id, source_entity_name: row.source_entity_name, category: row.category, power_level: row.power_level, mechanics: JSON.parse(row.mechanics) as EffectMechanic[], duration_type: row.duration_type, duration_value: row.duration_value, rounds_remaining: row.rounds_remaining, triggers: JSON.parse(row.triggers) as EffectTrigger[], removal_conditions: JSON.parse(row.removal_conditions) as RemovalCondition[], stackable: row.stackable === 1, max_stacks: row.max_stacks, current_stacks: row.current_stacks, is_active: row.is_active === 1, created_at: row.created_at, expires_at: row.expires_at }); } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/Mnehmos/rpg-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server