From 79ee9991506a7b446689ac6cfa9e574ee4360112 Mon Sep 17 00:00:00 2001 From: Sky Johnson Date: Thu, 7 Aug 2025 17:09:30 -0500 Subject: [PATCH] modernize chat package --- internal/chat/README.md | 226 -------- internal/chat/channel.go | 319 ++++++++--- internal/chat/channel/channel.go | 359 ------------ internal/chat/channel_test.go | 341 ++++++++++++ internal/chat/chat.go | 455 ---------------- internal/chat/chat_test.go | 906 ------------------------------- internal/chat/database.go | 270 --------- internal/chat/doc.go | 63 +++ internal/chat/interfaces.go | 122 ----- internal/chat/manager.go | 307 ----------- internal/chat/master.go | 293 ++++++++++ internal/chat/master_test.go | 389 +++++++++++++ internal/chat/types.go | 86 ++- 13 files changed, 1382 insertions(+), 2754 deletions(-) delete mode 100644 internal/chat/README.md delete mode 100644 internal/chat/channel/channel.go create mode 100644 internal/chat/channel_test.go delete mode 100644 internal/chat/chat.go delete mode 100644 internal/chat/chat_test.go delete mode 100644 internal/chat/database.go create mode 100644 internal/chat/doc.go delete mode 100644 internal/chat/interfaces.go delete mode 100644 internal/chat/manager.go create mode 100644 internal/chat/master.go create mode 100644 internal/chat/master_test.go diff --git a/internal/chat/README.md b/internal/chat/README.md deleted file mode 100644 index 96d52c8..0000000 --- a/internal/chat/README.md +++ /dev/null @@ -1,226 +0,0 @@ -# Chat System - -The chat system provides comprehensive channel-based communication for EverQuest II server emulation, converted from the original C++ EQ2EMu implementation. - -## Overview - -The chat system manages multiple chat channels with membership, access control, and message routing capabilities. It supports both persistent world channels (loaded from database) and temporary custom channels (created by players). - -## Architecture - -### Core Components - -**ChatManager** - Main chat system coordinator managing multiple channels -**Channel** - Individual channel implementation with membership and messaging -**ChatService** - High-level service interface for chat operations -**DatabaseChannelManager** - Database persistence layer for world channels - -### Key Features - -- **Channel Types**: World (persistent) and Custom (temporary) channels -- **Access Control**: Level, race, and class restrictions with bitmask filtering -- **Password Protection**: Optional password protection for channels -- **Language Integration**: Multilingual chat processing with language comprehension -- **Discord Integration**: Optional Discord webhook bridge for specific channels -- **Thread Safety**: All operations use proper Go concurrency patterns - -## Channel Types - -### World Channels -- Persistent channels loaded from database at server startup -- Cannot be deleted by players -- Configured with access restrictions (level, race, class) -- Examples: "Auction", "Level_1-9", "Trade" - -### Custom Channels -- Created dynamically by players -- Automatically deleted when empty for 5+ minutes -- Support optional password protection -- Player-controlled membership - -## Database Schema - -```sql -CREATE TABLE channels ( - name TEXT PRIMARY KEY, - password TEXT, - level_restriction INTEGER NOT NULL DEFAULT 0, - classes INTEGER NOT NULL DEFAULT 0, - races INTEGER NOT NULL DEFAULT 0, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP -); -``` - -## Access Control - -### Level Restrictions -- Minimum level required to join channel -- 0 = no level restriction - -### Race Restrictions (Bitmask) -- Bit position corresponds to race ID -- 0 = all races allowed -- Example: `(1 << raceID) & raceMask` checks if race is allowed - -### Class Restrictions (Bitmask) -- Bit position corresponds to class ID -- 0 = all classes allowed -- Example: `(1 << classID) & classMask` checks if class is allowed - -## Usage Examples - -### Basic Operations - -```go -// Initialize chat service -service := NewChatService(database, clientManager, playerManager, languageProcessor) -err := service.Initialize(ctx) - -// Create custom channel -err := service.CreateChannel("MyChannel", "password123") - -// Join channel -err := service.JoinChannel(characterID, "MyChannel", "password123") - -// Send message -err := service.SendChannelMessage(characterID, "MyChannel", "Hello everyone!") - -// Leave channel -err := service.LeaveChannel(characterID, "MyChannel") -``` - -### Channel Commands - -```go -// Process channel command from client -err := service.ProcessChannelCommand(characterID, "join", "Auction") -err := service.ProcessChannelCommand(characterID, "tell", "Auction", "WTS", "Epic", "Sword") -err := service.ProcessChannelCommand(characterID, "who", "Auction") -err := service.ProcessChannelCommand(characterID, "leave", "Auction") -``` - -### Admin Operations - -```go -// Get channel statistics -stats := service.GetStatistics() -fmt.Printf("Total channels: %d, Active: %d\n", stats.TotalChannels, stats.ActiveChannels) - -// Broadcast system message -err := service.BroadcastSystemMessage("Auction", "Server restart in 10 minutes", "System") - -// Cleanup empty channels -removed := service.CleanupEmptyChannels() -``` - -## Integration Interfaces - -### ClientManager -Handles client communication for channel lists, messages, and updates. - -### PlayerManager -Provides player information for access control and message routing. - -### LanguageProcessor -Processes multilingual messages with language comprehension checks. - -### ChannelDatabase -Manages persistent storage of world channels and configuration. - -## Protocol Integration - -### Packet Types -- `WS_AvailWorldChannels` - Channel list for client -- `WS_ChatChannelUpdate` - Join/leave notifications -- `WS_HearChat` - Channel message delivery -- `WS_WhoChannelQueryReply` - User list responses - -### Channel Actions -- `CHAT_CHANNEL_JOIN` (0) - Player joins channel -- `CHAT_CHANNEL_LEAVE` (1) - Player leaves channel -- `CHAT_CHANNEL_OTHER_JOIN` (2) - Another player joins -- `CHAT_CHANNEL_OTHER_LEAVE` (3) - Another player leaves - -## Language Support - -The chat system integrates with the language system for multilingual communication: - -- Messages are processed based on sender's default language -- Recipients receive scrambled text for unknown languages -- Language comprehension is checked per message -- Proper language ID tracking for all communications - -## Discord Integration (Optional) - -Channels can be configured for Discord webhook integration: - -- Bidirectional chat bridge (EQ2 ↔ Discord) -- Configurable per channel via rules system -- Webhook-based implementation for simplicity -- Server name and character name formatting - -## Thread Safety - -All operations are thread-safe using: -- `sync.RWMutex` for read/write operations -- Atomic operations where appropriate -- Proper locking hierarchies to prevent deadlocks -- Channel-level and manager-level synchronization - -## Performance Considerations - -- Channel lookups use case-insensitive maps -- Member lists use slices with efficient removal -- Read operations use read locks for concurrency -- Database operations are context-aware with timeouts - -## Error Handling - -The system provides comprehensive error handling: -- Channel not found errors -- Access denied errors (level/race/class restrictions) -- Password validation errors -- Database connection errors -- Language processing errors - -## Future Enhancements - -Areas marked for future implementation: -- Advanced Discord bot integration -- Channel moderation features -- Message history and logging -- Channel-specific emote support -- Advanced filtering and search -- Cross-server channel support - -## File Structure - -``` -internal/chat/ -├── README.md # This documentation -├── constants.go # Channel constants and limits -├── types.go # Core data structures -├── interfaces.go # Integration interfaces -├── chat.go # Main ChatManager implementation -├── channel.go # Channel implementation -├── database.go # Database operations -├── manager.go # High-level ChatService -└── channel/ - └── channel.go # Standalone channel package -``` - -## Dependencies - -- `eq2emu/internal/database` - Database wrapper -- `eq2emu/internal/languages` - Language processing -- Standard library: `context`, `sync`, `strings`, `time` - -## Testing - -The chat system is designed for comprehensive testing: -- Mock interfaces for all dependencies -- Isolated channel testing -- Concurrent operation testing -- Database integration testing -- Error condition testing \ No newline at end of file diff --git a/internal/chat/channel.go b/internal/chat/channel.go index e391de1..95df6c2 100644 --- a/internal/chat/channel.go +++ b/internal/chat/channel.go @@ -3,118 +3,179 @@ package chat import ( "fmt" "slices" + "time" + + "eq2emu/internal/database" ) -// NewChannel creates a new channel instance -func NewChannel(name string) *Channel { +// Channel represents a chat channel with membership and message routing capabilities +type Channel struct { + ID int32 `json:"id"` // Channel ID + Name string `json:"name"` // Channel name + Password string `json:"-"` // Channel password (hidden from JSON) + ChannelType int `json:"type"` // Channel type (world/custom) + LevelRestriction int32 `json:"level_restriction"` + RaceRestriction int32 `json:"race_restriction"` + ClassRestriction int32 `json:"class_restriction"` + DiscordEnabled bool `json:"discord_enabled"` + Created time.Time `json:"created"` + Updated time.Time `json:"updated"` + + members []int32 `json:"-"` // Character IDs (not persisted) + db *database.Database `json:"-"` // Database connection + isNew bool `json:"-"` // Whether this is a new channel +} + +// New creates a new channel with the given database +func New(db *database.Database) *Channel { return &Channel{ - name: name, + db: db, + isNew: true, + members: make([]int32, 0), + Created: time.Now(), + Updated: time.Now(), + } +} + +// NewWithData creates a new channel with data +func NewWithData(id int32, name string, channelType int, db *database.Database) *Channel { + return &Channel{ + ID: id, + Name: name, + ChannelType: channelType, + db: db, + isNew: true, + members: make([]int32, 0), + Created: time.Now(), + Updated: time.Now(), + } +} + +// Load loads a channel by ID from the database +func Load(db *database.Database, id int32) (*Channel, error) { + channel := &Channel{ + db: db, + isNew: false, members: make([]int32, 0), } + + query := `SELECT id, name, password, type, level_restriction, race_restriction, class_restriction, discord_enabled, created_at, updated_at FROM channels WHERE id = ?` + row := db.QueryRow(query, id) + + err := row.Scan(&channel.ID, &channel.Name, &channel.Password, &channel.ChannelType, + &channel.LevelRestriction, &channel.RaceRestriction, &channel.ClassRestriction, + &channel.DiscordEnabled, &channel.Created, &channel.Updated) + if err != nil { + return nil, fmt.Errorf("failed to load channel %d: %w", id, err) + } + + return channel, nil +} + +// LoadByName loads a channel by name from the database +func LoadByName(db *database.Database, name string) (*Channel, error) { + channel := &Channel{ + db: db, + isNew: false, + members: make([]int32, 0), + } + + query := `SELECT id, name, password, type, level_restriction, race_restriction, class_restriction, discord_enabled, created_at, updated_at FROM channels WHERE name = ?` + row := db.QueryRow(query, name) + + err := row.Scan(&channel.ID, &channel.Name, &channel.Password, &channel.ChannelType, + &channel.LevelRestriction, &channel.RaceRestriction, &channel.ClassRestriction, + &channel.DiscordEnabled, &channel.Created, &channel.Updated) + if err != nil { + return nil, fmt.Errorf("failed to load channel %s: %w", name, err) + } + + return channel, nil +} + +// GetID returns the channel ID (implements Identifiable interface) +func (c *Channel) GetID() int32 { + return c.ID } // SetName sets the channel name func (c *Channel) SetName(name string) { - c.mu.Lock() - defer c.mu.Unlock() - c.name = name + c.Name = name + c.Updated = time.Now() } // SetPassword sets the channel password func (c *Channel) SetPassword(password string) { - c.mu.Lock() - defer c.mu.Unlock() - c.password = password + c.Password = password + c.Updated = time.Now() } // SetType sets the channel type func (c *Channel) SetType(channelType int) { - c.mu.Lock() - defer c.mu.Unlock() - c.channelType = channelType + c.ChannelType = channelType + c.Updated = time.Now() } // SetLevelRestriction sets the minimum level required to join func (c *Channel) SetLevelRestriction(level int32) { - c.mu.Lock() - defer c.mu.Unlock() - c.levelRestriction = level + c.LevelRestriction = level + c.Updated = time.Now() } // SetRacesAllowed sets the race bitmask for allowed races func (c *Channel) SetRacesAllowed(races int32) { - c.mu.Lock() - defer c.mu.Unlock() - c.raceRestriction = races + c.RaceRestriction = races + c.Updated = time.Now() } // SetClassesAllowed sets the class bitmask for allowed classes func (c *Channel) SetClassesAllowed(classes int32) { - c.mu.Lock() - defer c.mu.Unlock() - c.classRestriction = classes + c.ClassRestriction = classes + c.Updated = time.Now() } // GetName returns the channel name func (c *Channel) GetName() string { - c.mu.RLock() - defer c.mu.RUnlock() - return c.name + return c.Name } // GetType returns the channel type func (c *Channel) GetType() int { - c.mu.RLock() - defer c.mu.RUnlock() - return c.channelType + return c.ChannelType } // GetNumClients returns the number of clients in the channel func (c *Channel) GetNumClients() int { - c.mu.RLock() - defer c.mu.RUnlock() return len(c.members) } // HasPassword returns true if the channel has a password func (c *Channel) HasPassword() bool { - c.mu.RLock() - defer c.mu.RUnlock() - return c.password != "" + return c.Password != "" } // PasswordMatches checks if the provided password matches the channel password func (c *Channel) PasswordMatches(password string) bool { - c.mu.RLock() - defer c.mu.RUnlock() - return c.password == password + return c.Password == password } // CanJoinChannelByLevel checks if a player's level meets the channel requirements func (c *Channel) CanJoinChannelByLevel(level int32) bool { - c.mu.RLock() - defer c.mu.RUnlock() - return level >= c.levelRestriction + return level >= c.LevelRestriction } // CanJoinChannelByRace checks if a player's race is allowed in the channel func (c *Channel) CanJoinChannelByRace(raceID int32) bool { - c.mu.RLock() - defer c.mu.RUnlock() - return c.raceRestriction == NoRaceRestriction || (c.raceRestriction&(1<= c.levelRestriction -} - -// CanJoinChannelByRace checks if a player's race is allowed in the channel -func (c *Channel) CanJoinChannelByRace(raceID int32) bool { - c.mu.RLock() - defer c.mu.RUnlock() - return c.raceRestriction == NoRaceRestriction || (c.raceRestriction&(1< MaxChannelNameLength { - return fmt.Errorf("channel name too long: %d > %d", len(channelName), MaxChannelNameLength) - } - - cm.mu.Lock() - defer cm.mu.Unlock() - - // Check if channel already exists - if _, exists := cm.channels[strings.ToLower(channelName)]; exists { - return fmt.Errorf("channel %s already exists", channelName) - } - - // Create new custom channel - channel := &Channel{ - name: channelName, - channelType: ChannelTypeCustom, - members: make([]int32, 0), - created: time.Now(), - } - - // Set password if provided - if len(password) > 0 && password[0] != "" { - if len(password[0]) > MaxChannelPasswordLength { - return fmt.Errorf("channel password too long: %d > %d", len(password[0]), MaxChannelPasswordLength) - } - channel.password = password[0] - } - - cm.channels[strings.ToLower(channelName)] = channel - return nil -} - -// IsInChannel checks if a character is in the specified channel -func (cm *ChatManager) IsInChannel(characterID int32, channelName string) bool { - cm.mu.RLock() - defer cm.mu.RUnlock() - - if channel, exists := cm.channels[strings.ToLower(channelName)]; exists { - return channel.isInChannel(characterID) - } - return false -} - -// JoinChannel adds a character to a channel -func (cm *ChatManager) JoinChannel(characterID int32, channelName string, password ...string) error { - cm.mu.Lock() - defer cm.mu.Unlock() - - channel, exists := cm.channels[strings.ToLower(channelName)] - if !exists { - return fmt.Errorf("channel %s does not exist", channelName) - } - - // Check password if channel has one - if channel.password != "" { - if len(password) == 0 || password[0] != channel.password { - return fmt.Errorf("invalid password for channel %s", channelName) - } - } - - // Get player info for validation - playerInfo, err := cm.playerManager.GetPlayerInfo(characterID) - if err != nil { - return fmt.Errorf("failed to get player info: %w", err) - } - - // Validate restrictions - if !cm.canJoinChannel(playerInfo.Level, playerInfo.Race, playerInfo.Class, - channel.levelRestriction, channel.raceRestriction, channel.classRestriction) { - return fmt.Errorf("player does not meet channel requirements") - } - - // Add to channel - if err := channel.joinChannel(characterID); err != nil { - return err - } - - // Notify all channel members of the join - cm.notifyChannelUpdate(channelName, ChatChannelOtherJoin, playerInfo.CharacterName, characterID) - - return nil -} - -// LeaveChannel removes a character from a channel -func (cm *ChatManager) LeaveChannel(characterID int32, channelName string) error { - cm.mu.Lock() - defer cm.mu.Unlock() - - channel, exists := cm.channels[strings.ToLower(channelName)] - if !exists { - return fmt.Errorf("channel %s does not exist", channelName) - } - - // Get player info for notification - playerInfo, err := cm.playerManager.GetPlayerInfo(characterID) - if err != nil { - return fmt.Errorf("failed to get player info: %w", err) - } - - // Remove from channel - if err := channel.leaveChannel(characterID); err != nil { - return err - } - - // Delete custom channels with no members - if channel.channelType == ChannelTypeCustom && len(channel.members) == 0 { - delete(cm.channels, strings.ToLower(channelName)) - } - - // Notify all remaining channel members of the leave - cm.notifyChannelUpdate(channelName, ChatChannelOtherLeave, playerInfo.CharacterName, characterID) - - return nil -} - -// LeaveAllChannels removes a character from all channels they're in -func (cm *ChatManager) LeaveAllChannels(characterID int32) error { - cm.mu.Lock() - defer cm.mu.Unlock() - - playerInfo, err := cm.playerManager.GetPlayerInfo(characterID) - if err != nil { - return fmt.Errorf("failed to get player info: %w", err) - } - - // Find all channels the player is in and remove them - var channelsToDelete []string - for channelName, channel := range cm.channels { - if channel.isInChannel(characterID) { - channel.leaveChannel(characterID) - - // Mark custom channels with no members for deletion - if channel.channelType == ChannelTypeCustom && len(channel.members) == 0 { - channelsToDelete = append(channelsToDelete, channelName) - } else { - // Notify remaining members - cm.notifyChannelUpdate(channel.name, ChatChannelOtherLeave, playerInfo.CharacterName, characterID) - } - } - } - - // Delete empty custom channels - for _, channelName := range channelsToDelete { - delete(cm.channels, channelName) - } - - return nil -} - -// TellChannel sends a message to all members of a channel -func (cm *ChatManager) TellChannel(senderID int32, channelName, message string, customName ...string) error { - cm.mu.RLock() - defer cm.mu.RUnlock() - - channel, exists := cm.channels[strings.ToLower(channelName)] - if !exists { - return fmt.Errorf("channel %s does not exist", channelName) - } - - // Check if sender is in channel (unless it's a system message) - if senderID != 0 && !channel.isInChannel(senderID) { - return fmt.Errorf("sender is not in channel %s", channelName) - } - - // Get sender info - var senderName string - var languageID int32 - - if senderID != 0 { - playerInfo, err := cm.playerManager.GetPlayerInfo(senderID) - if err != nil { - return fmt.Errorf("failed to get sender info: %w", err) - } - senderName = playerInfo.CharacterName - - // Get sender's default language - if cm.languageProcessor != nil { - languageID = cm.languageProcessor.GetDefaultLanguage(senderID) - } - } - - // Use custom name if provided (for system messages) - if len(customName) > 0 && customName[0] != "" { - senderName = customName[0] - } - - // Create message - chatMessage := ChannelMessage{ - SenderID: senderID, - SenderName: senderName, - Message: message, - LanguageID: languageID, - ChannelName: channelName, - Timestamp: time.Now(), - } - - // Send to all channel members - return cm.deliverChannelMessage(channel, chatMessage) -} - -// SendChannelUserList sends the list of users in a channel to a client -func (cm *ChatManager) SendChannelUserList(requesterID int32, channelName string) error { - cm.mu.RLock() - defer cm.mu.RUnlock() - - channel, exists := cm.channels[strings.ToLower(channelName)] - if !exists { - return fmt.Errorf("channel %s does not exist", channelName) - } - - // Check if requester is in channel - if !channel.isInChannel(requesterID) { - return fmt.Errorf("requester is not in channel %s", channelName) - } - - // Build member list - var members []ChannelMember - for _, memberID := range channel.members { - if playerInfo, err := cm.playerManager.GetPlayerInfo(memberID); err == nil { - member := ChannelMember{ - CharacterID: memberID, - CharacterName: playerInfo.CharacterName, - Level: playerInfo.Level, - Race: playerInfo.Race, - Class: playerInfo.Class, - JoinedAt: time.Now(), // TODO: Track actual join time - } - members = append(members, member) - } - } - - // Send user list to requester - return cm.clientManager.SendChannelUserList(requesterID, channelName, members) -} - -// GetChannel returns a channel by name (for internal use) -func (cm *ChatManager) GetChannel(channelName string) *Channel { - cm.mu.RLock() - defer cm.mu.RUnlock() - - return cm.channels[strings.ToLower(channelName)] -} - -// GetStatistics returns chat system statistics -func (cm *ChatManager) GetStatistics() ChatStatistics { - cm.mu.RLock() - defer cm.mu.RUnlock() - - stats := ChatStatistics{ - TotalChannels: len(cm.channels), - } - - for _, channel := range cm.channels { - switch channel.channelType { - case ChannelTypeWorld: - stats.WorldChannels++ - case ChannelTypeCustom: - stats.CustomChannels++ - } - - stats.TotalMembers += len(channel.members) - if len(channel.members) > 0 { - stats.ActiveChannels++ - } - } - - return stats -} - -// Helper methods - -// canJoinChannel checks if a player meets channel requirements -func (cm *ChatManager) canJoinChannel(playerLevel, playerRace, playerClass, levelReq, raceReq, classReq int32) bool { - // Check level restriction - if levelReq > NoLevelRestriction && playerLevel < levelReq { - return false - } - - // Check race restriction (bitmask) - if raceReq > NoRaceRestriction && (raceReq&(1< NoClassRestriction && (classReq&(1< 0 && player.Level < levelReq { - return false - } - if raceReq > 0 && (raceReq&(1< 0 && (classReq&(1< 0 { - password = args[0] - } - return cs.manager.JoinChannel(characterID, channelName, password) - - case "leave": - return cs.manager.LeaveChannel(characterID, channelName) - - case "create": - password := "" - if len(args) > 0 { - password = args[0] - } - return cs.manager.CreateChannel(channelName, password) - - case "tell", "say": - if len(args) == 0 { - return fmt.Errorf("no message provided") - } - message := strings.Join(args, " ") - return cs.manager.TellChannel(characterID, channelName, message) - - case "who", "list": - return cs.manager.SendChannelUserList(characterID, channelName) - - default: - return fmt.Errorf("unknown channel command: %s", command) - } -} - -// SendChannelMessage sends a message to a channel -func (cs *ChatService) SendChannelMessage(senderID int32, channelName string, message string, customName ...string) error { - cs.mu.RLock() - defer cs.mu.RUnlock() - - return cs.manager.TellChannel(senderID, channelName, message, customName...) -} - -// JoinChannel adds a character to a channel -func (cs *ChatService) JoinChannel(characterID int32, channelName string, password ...string) error { - cs.mu.RLock() - defer cs.mu.RUnlock() - - return cs.manager.JoinChannel(characterID, channelName, password...) -} - -// LeaveChannel removes a character from a channel -func (cs *ChatService) LeaveChannel(characterID int32, channelName string) error { - cs.mu.RLock() - defer cs.mu.RUnlock() - - return cs.manager.LeaveChannel(characterID, channelName) -} - -// LeaveAllChannels removes a character from all channels -func (cs *ChatService) LeaveAllChannels(characterID int32) error { - cs.mu.RLock() - defer cs.mu.RUnlock() - - return cs.manager.LeaveAllChannels(characterID) -} - -// CreateChannel creates a new custom channel -func (cs *ChatService) CreateChannel(channelName string, password ...string) error { - cs.mu.RLock() - defer cs.mu.RUnlock() - - return cs.manager.CreateChannel(channelName, password...) -} - -// GetWorldChannelList returns available world channels for a character -func (cs *ChatService) GetWorldChannelList(characterID int32) ([]ChannelInfo, error) { - cs.mu.RLock() - defer cs.mu.RUnlock() - - return cs.manager.GetWorldChannelList(characterID) -} - -// ChannelExists checks if a channel exists -func (cs *ChatService) ChannelExists(channelName string) bool { - cs.mu.RLock() - defer cs.mu.RUnlock() - - return cs.manager.ChannelExists(channelName) -} - -// IsInChannel checks if a character is in a channel -func (cs *ChatService) IsInChannel(characterID int32, channelName string) bool { - cs.mu.RLock() - defer cs.mu.RUnlock() - - return cs.manager.IsInChannel(characterID, channelName) -} - -// GetChannelInfo returns information about a specific channel -func (cs *ChatService) GetChannelInfo(channelName string) (*ChannelInfo, error) { - cs.mu.RLock() - defer cs.mu.RUnlock() - - channel := cs.manager.GetChannel(channelName) - if channel == nil { - return nil, fmt.Errorf("channel %s not found", channelName) - } - - info := channel.GetChannelInfo() - return &info, nil -} - -// GetStatistics returns chat system statistics -func (cs *ChatService) GetStatistics() ChatStatistics { - cs.mu.RLock() - defer cs.mu.RUnlock() - - return cs.manager.GetStatistics() -} - -// SendChannelUserList sends the user list for a channel to a character -func (cs *ChatService) SendChannelUserList(requesterID int32, channelName string) error { - cs.mu.RLock() - defer cs.mu.RUnlock() - - return cs.manager.SendChannelUserList(requesterID, channelName) -} - -// ValidateChannelName checks if a channel name is valid -func (cs *ChatService) ValidateChannelName(channelName string) error { - if len(channelName) == 0 { - return fmt.Errorf("channel name cannot be empty") - } - - if len(channelName) > MaxChannelNameLength { - return fmt.Errorf("channel name too long: %d > %d", len(channelName), MaxChannelNameLength) - } - - // Check for invalid characters - if strings.ContainsAny(channelName, " \t\n\r") { - return fmt.Errorf("channel name cannot contain whitespace") - } - - return nil -} - -// ValidateChannelPassword checks if a channel password is valid -func (cs *ChatService) ValidateChannelPassword(password string) error { - if len(password) > MaxChannelPasswordLength { - return fmt.Errorf("channel password too long: %d > %d", len(password), MaxChannelPasswordLength) - } - - return nil -} - -// GetChannelMembers returns the list of members in a channel -func (cs *ChatService) GetChannelMembers(channelName string) ([]int32, error) { - cs.mu.RLock() - defer cs.mu.RUnlock() - - channel := cs.manager.GetChannel(channelName) - if channel == nil { - return nil, fmt.Errorf("channel %s not found", channelName) - } - - return channel.GetMembers(), nil -} - -// CleanupEmptyChannels removes empty custom channels (called periodically) -func (cs *ChatService) CleanupEmptyChannels() int { - cs.mu.Lock() - defer cs.mu.Unlock() - - removed := 0 - for name, channel := range cs.manager.channels { - if channel.channelType == ChannelTypeCustom && channel.IsEmpty() { - // Check if channel has been empty for a reasonable time - if time.Since(channel.created) > 5*time.Minute { - delete(cs.manager.channels, name) - removed++ - } - } - } - - return removed -} - -// BroadcastSystemMessage sends a system message to all members of a channel -func (cs *ChatService) BroadcastSystemMessage(channelName string, message string, systemName string) error { - cs.mu.RLock() - defer cs.mu.RUnlock() - - return cs.manager.TellChannel(0, channelName, message, systemName) -} - -// GetActiveChannels returns a list of channels that have active members -func (cs *ChatService) GetActiveChannels() []string { - cs.mu.RLock() - defer cs.mu.RUnlock() - - var activeChannels []string - for name, channel := range cs.manager.channels { - if !channel.IsEmpty() { - activeChannels = append(activeChannels, name) - } - } - - return activeChannels -} - -// GetChannelsByType returns channels of a specific type -func (cs *ChatService) GetChannelsByType(channelType int) []string { - cs.mu.RLock() - defer cs.mu.RUnlock() - - var channels []string - for name, channel := range cs.manager.channels { - if channel.channelType == channelType { - channels = append(channels, name) - } - } - - return channels -} - -// ProcessChannelFilter applies filtering to channel lists based on player criteria -func (cs *ChatService) ProcessChannelFilter(characterID int32, filter ChannelFilter) ([]ChannelInfo, error) { - cs.mu.RLock() - defer cs.mu.RUnlock() - - playerInfo, err := cs.manager.playerManager.GetPlayerInfo(characterID) - if err != nil { - return nil, fmt.Errorf("failed to get player info: %w", err) - } - - var filteredChannels []ChannelInfo - for _, channel := range cs.manager.channels { - // Apply type filters - if !filter.IncludeWorld && channel.channelType == ChannelTypeWorld { - continue - } - if !filter.IncludeCustom && channel.channelType == ChannelTypeCustom { - continue - } - - // Apply level range filters - if filter.MinLevel > 0 && playerInfo.Level < filter.MinLevel { - continue - } - if filter.MaxLevel > 0 && playerInfo.Level > filter.MaxLevel { - continue - } - - // Apply race filter - if filter.Race > 0 && playerInfo.Race != filter.Race { - continue - } - - // Apply class filter - if filter.Class > 0 && playerInfo.Class != filter.Class { - continue - } - - // Check if player can actually join the channel - if cs.manager.canJoinChannel(playerInfo.Level, playerInfo.Race, playerInfo.Class, - channel.levelRestriction, channel.raceRestriction, channel.classRestriction) { - filteredChannels = append(filteredChannels, channel.GetChannelInfo()) - } - } - - return filteredChannels, nil -} diff --git a/internal/chat/master.go b/internal/chat/master.go new file mode 100644 index 0000000..fbc6ade --- /dev/null +++ b/internal/chat/master.go @@ -0,0 +1,293 @@ +package chat + +import ( + "fmt" + "strings" + + "eq2emu/internal/common" + "eq2emu/internal/database" +) + +// MasterList manages a collection of channels using the generic MasterList base +type MasterList struct { + *common.MasterList[int32, *Channel] +} + +// NewMasterList creates a new channel master list +func NewMasterList() *MasterList { + return &MasterList{ + MasterList: common.NewMasterList[int32, *Channel](), + } +} + +// AddChannel adds a channel to the master list +func (ml *MasterList) AddChannel(channel *Channel) bool { + return ml.Add(channel) +} + +// GetChannel retrieves a channel by ID +func (ml *MasterList) GetChannel(id int32) *Channel { + return ml.Get(id) +} + +// GetChannelSafe retrieves a channel by ID with existence check +func (ml *MasterList) GetChannelSafe(id int32) (*Channel, bool) { + return ml.GetSafe(id) +} + +// HasChannel checks if a channel exists by ID +func (ml *MasterList) HasChannel(id int32) bool { + return ml.Exists(id) +} + +// RemoveChannel removes a channel by ID +func (ml *MasterList) RemoveChannel(id int32) bool { + return ml.Remove(id) +} + +// GetAllChannels returns all channels as a map +func (ml *MasterList) GetAllChannels() map[int32]*Channel { + return ml.GetAll() +} + +// GetAllChannelsList returns all channels as a slice +func (ml *MasterList) GetAllChannelsList() []*Channel { + return ml.GetAllSlice() +} + +// GetChannelCount returns the number of channels +func (ml *MasterList) GetChannelCount() int { + return ml.Size() +} + +// ClearChannels removes all channels from the list +func (ml *MasterList) ClearChannels() { + ml.Clear() +} + +// FindChannelsByName finds channels containing the given name substring +func (ml *MasterList) FindChannelsByName(nameSubstring string) []*Channel { + return ml.Filter(func(channel *Channel) bool { + return contains(channel.GetName(), nameSubstring) + }) +} + +// FindChannelsByType finds channels of a specific type +func (ml *MasterList) FindChannelsByType(channelType int) []*Channel { + return ml.Filter(func(channel *Channel) bool { + return channel.GetType() == channelType + }) +} + +// GetWorldChannels returns all world channels +func (ml *MasterList) GetWorldChannels() []*Channel { + return ml.FindChannelsByType(ChannelTypeWorld) +} + +// GetCustomChannels returns all custom channels +func (ml *MasterList) GetCustomChannels() []*Channel { + return ml.FindChannelsByType(ChannelTypeCustom) +} + +// GetActiveChannels returns channels that have members +func (ml *MasterList) GetActiveChannels() []*Channel { + return ml.Filter(func(channel *Channel) bool { + return !channel.IsEmpty() + }) +} + +// GetEmptyChannels returns channels that have no members +func (ml *MasterList) GetEmptyChannels() []*Channel { + return ml.Filter(func(channel *Channel) bool { + return channel.IsEmpty() + }) +} + +// GetChannelByName retrieves a channel by name (case-insensitive) +func (ml *MasterList) GetChannelByName(name string) *Channel { + name = strings.ToLower(name) + var foundChannel *Channel + ml.ForEach(func(id int32, channel *Channel) { + if strings.ToLower(channel.GetName()) == name { + foundChannel = channel + } + }) + return foundChannel +} + +// HasChannelByName checks if a channel exists by name (case-insensitive) +func (ml *MasterList) HasChannelByName(name string) bool { + return ml.GetChannelByName(name) != nil +} + +// GetCompatibleChannels returns channels compatible with player restrictions +func (ml *MasterList) GetCompatibleChannels(level, race, class int32) []*Channel { + return ml.Filter(func(channel *Channel) bool { + return channel.CanJoinChannelByLevel(level) && + channel.CanJoinChannelByRace(race) && + channel.CanJoinChannelByClass(class) + }) +} + +// ValidateChannels checks all channels for consistency +func (ml *MasterList) ValidateChannels() []string { + var issues []string + + ml.ForEach(func(id int32, channel *Channel) { + if channel == nil { + issues = append(issues, fmt.Sprintf("Channel ID %d is nil", id)) + return + } + + if channel.GetID() != id { + issues = append(issues, fmt.Sprintf("Channel ID mismatch: map key %d != channel ID %d", id, channel.GetID())) + } + + if len(channel.GetName()) == 0 { + issues = append(issues, fmt.Sprintf("Channel ID %d has empty name", id)) + } + + if channel.GetType() < ChannelTypeNone || channel.GetType() > ChannelTypeCustom { + issues = append(issues, fmt.Sprintf("Channel ID %d has invalid type: %d", id, channel.GetType())) + } + + if len(channel.GetName()) > MaxChannelNameLength { + issues = append(issues, fmt.Sprintf("Channel ID %d name too long: %d > %d", id, len(channel.GetName()), MaxChannelNameLength)) + } + + if len(channel.Password) > MaxChannelPasswordLength { + issues = append(issues, fmt.Sprintf("Channel ID %d password too long: %d > %d", id, len(channel.Password), MaxChannelPasswordLength)) + } + }) + + return issues +} + +// IsValid returns true if all channels are valid +func (ml *MasterList) IsValid() bool { + issues := ml.ValidateChannels() + return len(issues) == 0 +} + +// GetStatistics returns statistics about the channel collection +func (ml *MasterList) GetStatistics() map[string]any { + stats := make(map[string]any) + stats["total_channels"] = ml.Size() + + if ml.IsEmpty() { + return stats + } + + // Count by channel type + typeCounts := make(map[int]int) + var totalMembers int + var activeChannels int + var minID, maxID int32 + first := true + + ml.ForEach(func(id int32, channel *Channel) { + typeCounts[channel.GetType()]++ + totalMembers += channel.GetNumClients() + + if !channel.IsEmpty() { + activeChannels++ + } + + if first { + minID = id + maxID = id + first = false + } else { + if id < minID { + minID = id + } + if id > maxID { + maxID = id + } + } + }) + + stats["channels_by_type"] = typeCounts + stats["world_channels"] = typeCounts[ChannelTypeWorld] + stats["custom_channels"] = typeCounts[ChannelTypeCustom] + stats["total_members"] = totalMembers + stats["active_channels"] = activeChannels + stats["min_id"] = minID + stats["max_id"] = maxID + stats["id_range"] = maxID - minID + + return stats +} + +// LoadAllChannels loads all channels from the database into the master list +func (ml *MasterList) LoadAllChannels(db *database.Database) error { + if db == nil { + return fmt.Errorf("database connection is nil") + } + + // Clear existing channels + ml.Clear() + + query := `SELECT id, name, password, type, level_restriction, race_restriction, class_restriction, discord_enabled, created_at, updated_at FROM channels ORDER BY id` + rows, err := db.Query(query) + if err != nil { + return fmt.Errorf("failed to query channels: %w", err) + } + defer rows.Close() + + count := 0 + for rows.Next() { + channel := &Channel{ + db: db, + isNew: false, + members: make([]int32, 0), + } + + err := rows.Scan(&channel.ID, &channel.Name, &channel.Password, &channel.ChannelType, + &channel.LevelRestriction, &channel.RaceRestriction, &channel.ClassRestriction, + &channel.DiscordEnabled, &channel.Created, &channel.Updated) + if err != nil { + return fmt.Errorf("failed to scan channel: %w", err) + } + + if !ml.AddChannel(channel) { + return fmt.Errorf("failed to add channel %d to master list", channel.ID) + } + + count++ + } + + if err := rows.Err(); err != nil { + return fmt.Errorf("error iterating channel rows: %w", err) + } + + return nil +} + +// LoadAllChannelsFromDatabase is a convenience function that creates a master list and loads all channels +func LoadAllChannelsFromDatabase(db *database.Database) (*MasterList, error) { + masterList := NewMasterList() + err := masterList.LoadAllChannels(db) + if err != nil { + return nil, err + } + return masterList, nil +} + +// contains checks if a string contains a substring (case-sensitive) +func contains(str, substr string) bool { + if len(substr) == 0 { + return true + } + if len(str) < len(substr) { + return false + } + + for i := 0; i <= len(str)-len(substr); i++ { + if str[i:i+len(substr)] == substr { + return true + } + } + + return false +} \ No newline at end of file diff --git a/internal/chat/master_test.go b/internal/chat/master_test.go new file mode 100644 index 0000000..50f49b1 --- /dev/null +++ b/internal/chat/master_test.go @@ -0,0 +1,389 @@ +package chat + +import ( + "testing" + + "eq2emu/internal/database" +) + +func TestNewMasterList(t *testing.T) { + masterList := NewMasterList() + + if masterList == nil { + t.Fatal("NewMasterList returned nil") + } + + if masterList.GetChannelCount() != 0 { + t.Errorf("Expected count 0, got %d", masterList.GetChannelCount()) + } +} + +func TestMasterListBasicOperations(t *testing.T) { + db, _ := database.NewSQLite("file::memory:?mode=memory&cache=shared") + defer db.Close() + + masterList := NewMasterList() + + // Create test channels + channel1 := NewWithData(1001, "Auction", ChannelTypeWorld, db) + channel2 := NewWithData(1002, "Custom Channel", ChannelTypeCustom, db) + + // Test adding + if !masterList.AddChannel(channel1) { + t.Error("Should successfully add channel1") + } + + if !masterList.AddChannel(channel2) { + t.Error("Should successfully add channel2") + } + + // Test duplicate add (should fail) + if masterList.AddChannel(channel1) { + t.Error("Should not add duplicate channel") + } + + if masterList.GetChannelCount() != 2 { + t.Errorf("Expected count 2, got %d", masterList.GetChannelCount()) + } + + // Test retrieving + retrieved := masterList.GetChannel(1001) + if retrieved == nil { + t.Error("Should retrieve added channel") + } + + if retrieved.GetName() != "Auction" { + t.Errorf("Expected name 'Auction', got '%s'", retrieved.GetName()) + } + + // Test safe retrieval + retrieved, exists := masterList.GetChannelSafe(1001) + if !exists || retrieved == nil { + t.Error("GetChannelSafe should return channel and true") + } + + _, exists = masterList.GetChannelSafe(9999) + if exists { + t.Error("GetChannelSafe should return false for non-existent ID") + } + + // Test HasChannel + if !masterList.HasChannel(1001) { + t.Error("HasChannel should return true for existing ID") + } + + if masterList.HasChannel(9999) { + t.Error("HasChannel should return false for non-existent ID") + } + + // Test removing + if !masterList.RemoveChannel(1001) { + t.Error("Should successfully remove channel") + } + + if masterList.GetChannelCount() != 1 { + t.Errorf("Expected count 1, got %d", masterList.GetChannelCount()) + } + + if masterList.HasChannel(1001) { + t.Error("Channel should be removed") + } + + // Test clear + masterList.ClearChannels() + if masterList.GetChannelCount() != 0 { + t.Errorf("Expected count 0 after clear, got %d", masterList.GetChannelCount()) + } +} + +func TestMasterListFiltering(t *testing.T) { + db, _ := database.NewSQLite("file::memory:?mode=memory&cache=shared") + defer db.Close() + + masterList := NewMasterList() + + // Add test data + channels := []*Channel{ + NewWithData(1, "Auction", ChannelTypeWorld, db), + NewWithData(2, "Trade", ChannelTypeWorld, db), + NewWithData(3, "Custom Chat", ChannelTypeCustom, db), + NewWithData(4, "Player Channel", ChannelTypeCustom, db), + } + + for _, ch := range channels { + masterList.AddChannel(ch) + } + + // Test FindChannelsByName + auctionChannels := masterList.FindChannelsByName("Auction") + if len(auctionChannels) != 1 { + t.Errorf("FindChannelsByName('Auction') returned %v results, want 1", len(auctionChannels)) + } + + chatChannels := masterList.FindChannelsByName("Channel") + if len(chatChannels) != 1 { + t.Errorf("FindChannelsByName('Channel') returned %v results, want 1", len(chatChannels)) + } + + // Test FindChannelsByType + worldChannels := masterList.FindChannelsByType(ChannelTypeWorld) + if len(worldChannels) != 2 { + t.Errorf("FindChannelsByType(World) returned %v results, want 2", len(worldChannels)) + } + + customChannels := masterList.FindChannelsByType(ChannelTypeCustom) + if len(customChannels) != 2 { + t.Errorf("FindChannelsByType(Custom) returned %v results, want 2", len(customChannels)) + } + + // Test GetWorldChannels + worldList := masterList.GetWorldChannels() + if len(worldList) != 2 { + t.Errorf("GetWorldChannels() returned %v results, want 2", len(worldList)) + } + + // Test GetCustomChannels + customList := masterList.GetCustomChannels() + if len(customList) != 2 { + t.Errorf("GetCustomChannels() returned %v results, want 2", len(customList)) + } + + // Test GetActiveChannels (all channels are empty initially) + activeChannels := masterList.GetActiveChannels() + if len(activeChannels) != 0 { + t.Errorf("GetActiveChannels() returned %v results, want 0", len(activeChannels)) + } + + // Add members to make channels active + channels[0].JoinChannel(1001) + channels[1].JoinChannel(1002) + + activeChannels = masterList.GetActiveChannels() + if len(activeChannels) != 2 { + t.Errorf("GetActiveChannels() returned %v results, want 2", len(activeChannels)) + } + + // Test GetEmptyChannels + emptyChannels := masterList.GetEmptyChannels() + if len(emptyChannels) != 2 { + t.Errorf("GetEmptyChannels() returned %v results, want 2", len(emptyChannels)) + } +} + +func TestMasterListGetByName(t *testing.T) { + db, _ := database.NewSQLite("file::memory:?mode=memory&cache=shared") + defer db.Close() + + masterList := NewMasterList() + + // Add test channels + channel1 := NewWithData(100, "Auction", ChannelTypeWorld, db) + channel2 := NewWithData(200, "AUCTION", ChannelTypeWorld, db) // Different case + masterList.AddChannel(channel1) + masterList.AddChannel(channel2) + + // Test case-insensitive lookup + found := masterList.GetChannelByName("auction") + if found == nil { + t.Error("GetChannelByName should find channel (case insensitive)") + } + + found = masterList.GetChannelByName("AUCTION") + if found == nil { + t.Error("GetChannelByName should find channel (uppercase)") + } + + found = masterList.GetChannelByName("NonExistent") + if found != nil { + t.Error("GetChannelByName should return nil for non-existent channel") + } + + // Test HasChannelByName + if !masterList.HasChannelByName("auction") { + t.Error("HasChannelByName should return true for existing channel") + } + + if masterList.HasChannelByName("NonExistent") { + t.Error("HasChannelByName should return false for non-existent channel") + } +} + +func TestMasterListCompatibility(t *testing.T) { + db, _ := database.NewSQLite("file::memory:?mode=memory&cache=shared") + defer db.Close() + + masterList := NewMasterList() + + // Create channels with restrictions + channel1 := NewWithData(1, "LowLevel", ChannelTypeWorld, db) + channel1.SetLevelRestriction(5) + + channel2 := NewWithData(2, "HighLevel", ChannelTypeWorld, db) + channel2.SetLevelRestriction(50) + + channel3 := NewWithData(3, "RaceRestricted", ChannelTypeWorld, db) + channel3.SetRacesAllowed(1 << 1) // Only race 1 allowed + + masterList.AddChannel(channel1) + masterList.AddChannel(channel2) + masterList.AddChannel(channel3) + + // Test compatibility for level 10, race 1, class 1 player + compatible := masterList.GetCompatibleChannels(10, 1, 1) + if len(compatible) != 2 { // Should match channel1 and channel3 + t.Errorf("GetCompatibleChannels(10,1,1) returned %v results, want 2", len(compatible)) + } + + // Test compatibility for level 60, race 2, class 1 player + compatible = masterList.GetCompatibleChannels(60, 2, 1) + if len(compatible) != 2 { // Should match channel1 and channel2 (not channel3) + t.Errorf("GetCompatibleChannels(60,2,1) returned %v results, want 2", len(compatible)) + } + + // Test compatibility for level 1, race 1, class 1 player + compatible = masterList.GetCompatibleChannels(1, 1, 1) + if len(compatible) != 1 { // Should only match channel3 (no level restriction) + t.Errorf("GetCompatibleChannels(1,1,1) returned %v results, want 1", len(compatible)) + } +} + +func TestMasterListGetAll(t *testing.T) { + db, _ := database.NewSQLite("file::memory:?mode=memory&cache=shared") + defer db.Close() + + masterList := NewMasterList() + + // Add test channels + for i := int32(1); i <= 3; i++ { + ch := NewWithData(i*100, "Test", ChannelTypeWorld, db) + masterList.AddChannel(ch) + } + + // Test GetAllChannels (map) + allMap := masterList.GetAllChannels() + if len(allMap) != 3 { + t.Errorf("GetAllChannels() returned %v items, want 3", len(allMap)) + } + + // Verify it's a copy by modifying returned map + delete(allMap, 100) + if masterList.GetChannelCount() != 3 { + t.Error("Modifying returned map affected internal state") + } + + // Test GetAllChannelsList (slice) + allList := masterList.GetAllChannelsList() + if len(allList) != 3 { + t.Errorf("GetAllChannelsList() returned %v items, want 3", len(allList)) + } +} + +func TestMasterListValidation(t *testing.T) { + db, _ := database.NewSQLite("file::memory:?mode=memory&cache=shared") + defer db.Close() + + masterList := NewMasterList() + + // Add valid channels + ch1 := NewWithData(100, "Valid Channel", ChannelTypeWorld, db) + masterList.AddChannel(ch1) + + issues := masterList.ValidateChannels() + if len(issues) != 0 { + t.Errorf("ValidateChannels() returned issues for valid data: %v", issues) + } + + if !masterList.IsValid() { + t.Error("IsValid() should return true for valid data") + } + + // Add invalid channel (empty name) + ch2 := NewWithData(200, "", ChannelTypeWorld, db) + masterList.AddChannel(ch2) + + issues = masterList.ValidateChannels() + if len(issues) == 0 { + t.Error("ValidateChannels() should return issues for invalid data") + } + + if masterList.IsValid() { + t.Error("IsValid() should return false for invalid data") + } +} + +func TestMasterListStatistics(t *testing.T) { + db, _ := database.NewSQLite("file::memory:?mode=memory&cache=shared") + defer db.Close() + + masterList := NewMasterList() + + // Add channels with different types + masterList.AddChannel(NewWithData(10, "World1", ChannelTypeWorld, db)) + masterList.AddChannel(NewWithData(20, "World2", ChannelTypeWorld, db)) + masterList.AddChannel(NewWithData(30, "Custom1", ChannelTypeCustom, db)) + masterList.AddChannel(NewWithData(40, "Custom2", ChannelTypeCustom, db)) + masterList.AddChannel(NewWithData(50, "Custom3", ChannelTypeCustom, db)) + + // Add some members + masterList.GetChannel(10).JoinChannel(1001) + masterList.GetChannel(20).JoinChannel(1002) + + stats := masterList.GetStatistics() + + if total, ok := stats["total_channels"].(int); !ok || total != 5 { + t.Errorf("total_channels = %v, want 5", stats["total_channels"]) + } + + if worldChannels, ok := stats["world_channels"].(int); !ok || worldChannels != 2 { + t.Errorf("world_channels = %v, want 2", stats["world_channels"]) + } + + if customChannels, ok := stats["custom_channels"].(int); !ok || customChannels != 3 { + t.Errorf("custom_channels = %v, want 3", stats["custom_channels"]) + } + + if activeChannels, ok := stats["active_channels"].(int); !ok || activeChannels != 2 { + t.Errorf("active_channels = %v, want 2", stats["active_channels"]) + } + + if totalMembers, ok := stats["total_members"].(int); !ok || totalMembers != 2 { + t.Errorf("total_members = %v, want 2", stats["total_members"]) + } + + if minID, ok := stats["min_id"].(int32); !ok || minID != 10 { + t.Errorf("min_id = %v, want 10", stats["min_id"]) + } + + if maxID, ok := stats["max_id"].(int32); !ok || maxID != 50 { + t.Errorf("max_id = %v, want 50", stats["max_id"]) + } + + if idRange, ok := stats["id_range"].(int32); !ok || idRange != 40 { + t.Errorf("id_range = %v, want 40", stats["id_range"]) + } +} + +func TestContainsFunction(t *testing.T) { + tests := []struct { + str string + substr string + want bool + }{ + {"hello world", "world", true}, + {"hello world", "World", false}, // Case sensitive + {"hello", "hello world", false}, + {"hello", "", true}, + {"", "hello", false}, + {"", "", true}, + {"abcdef", "cde", true}, + {"abcdef", "xyz", false}, + } + + for _, tt := range tests { + t.Run("", func(t *testing.T) { + if got := contains(tt.str, tt.substr); got != tt.want { + t.Errorf("contains(%q, %q) = %v, want %v", tt.str, tt.substr, got, tt.want) + } + }) + } +} \ No newline at end of file diff --git a/internal/chat/types.go b/internal/chat/types.go index 750865b..07400b2 100644 --- a/internal/chat/types.go +++ b/internal/chat/types.go @@ -1,24 +1,10 @@ package chat import ( - "sync" + "context" "time" ) -// Channel represents a chat channel with membership and message routing capabilities -type Channel struct { - mu sync.RWMutex - name string - password string - channelType int - levelRestriction int32 - raceRestriction int32 - classRestriction int32 - members []int32 // Character IDs - discordEnabled bool - created time.Time -} - // ChannelMessage represents a message sent to a channel type ChannelMessage struct { SenderID int32 @@ -61,16 +47,68 @@ type ChatChannelData struct { RaceRestriction int32 } -// ChatManager manages all chat channels and operations -type ChatManager struct { - mu sync.RWMutex - channels map[string]*Channel - database ChannelDatabase +// ChannelDatabase defines database operations for chat channels +type ChannelDatabase interface { + // LoadWorldChannels retrieves all persistent world channels from database + LoadWorldChannels(ctx context.Context) ([]ChatChannelData, error) - // Integration interfaces - clientManager ClientManager - playerManager PlayerManager - languageProcessor LanguageProcessor + // SaveChannel persists a channel to database (world channels only) + SaveChannel(ctx context.Context, channel ChatChannelData) error + + // DeleteChannel removes a channel from database + DeleteChannel(ctx context.Context, channelName string) error +} + +// ClientManager handles client communication for chat system +type ClientManager interface { + // SendChannelList sends available channels to a client + SendChannelList(characterID int32, channels []ChannelInfo) error + + // SendChannelMessage delivers a message to a client + SendChannelMessage(characterID int32, message ChannelMessage) error + + // SendChannelUpdate notifies client of channel membership changes + SendChannelUpdate(characterID int32, channelName string, action int, characterName string) error + + // SendChannelUserList sends who list to client + SendChannelUserList(characterID int32, channelName string, members []ChannelMember) error + + // IsClientConnected checks if a character is currently online + IsClientConnected(characterID int32) bool +} + +// PlayerManager provides player information for chat system +type PlayerManager interface { + // GetPlayerInfo retrieves basic player information + GetPlayerInfo(characterID int32) (PlayerInfo, error) + + // ValidatePlayer checks if player meets channel requirements + ValidatePlayer(characterID int32, levelReq, raceReq, classReq int32) bool + + // GetPlayerLanguages returns languages known by player + GetPlayerLanguages(characterID int32) ([]int32, error) +} + +// LanguageProcessor handles multilingual chat processing +type LanguageProcessor interface { + // ProcessMessage processes a message for language comprehension + ProcessMessage(senderID, receiverID int32, message string, languageID int32) (string, error) + + // CanUnderstand checks if receiver can understand sender's language + CanUnderstand(senderID, receiverID int32, languageID int32) bool + + // GetDefaultLanguage returns the default language for a character + GetDefaultLanguage(characterID int32) int32 +} + +// PlayerInfo contains basic player information needed for chat +type PlayerInfo struct { + CharacterID int32 + CharacterName string + Level int32 + Race int32 + Class int32 + IsOnline bool } // ChatStatistics provides statistics about chat system usage