modernize chat package

This commit is contained in:
Sky Johnson 2025-08-07 17:09:30 -05:00
parent 1fc81eea95
commit 79ee999150
13 changed files with 1382 additions and 2754 deletions

View File

@ -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

View File

@ -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<<raceID)) != 0
return c.RaceRestriction == NoRaceRestriction || (c.RaceRestriction&(1<<raceID)) != 0
}
// CanJoinChannelByClass checks if a player's class is allowed in the channel
func (c *Channel) CanJoinChannelByClass(classID int32) bool {
c.mu.RLock()
defer c.mu.RUnlock()
return c.classRestriction == NoClassRestriction || (c.classRestriction&(1<<classID)) != 0
return c.ClassRestriction == NoClassRestriction || (c.ClassRestriction&(1<<classID)) != 0
}
// IsInChannel checks if a character is in the channel
func (c *Channel) IsInChannel(characterID int32) bool {
c.mu.RLock()
defer c.mu.RUnlock()
return c.isInChannel(characterID)
}
@ -125,8 +186,6 @@ func (c *Channel) isInChannel(characterID int32) bool {
// JoinChannel adds a character to the channel
func (c *Channel) JoinChannel(characterID int32) error {
c.mu.Lock()
defer c.mu.Unlock()
return c.joinChannel(characterID)
}
@ -134,7 +193,7 @@ func (c *Channel) JoinChannel(characterID int32) error {
func (c *Channel) joinChannel(characterID int32) error {
// Check if already in channel
if c.isInChannel(characterID) {
return fmt.Errorf("character %d is already in channel %s", characterID, c.name)
return fmt.Errorf("character %d is already in channel %s", characterID, c.Name)
}
// Add to members list
@ -144,8 +203,6 @@ func (c *Channel) joinChannel(characterID int32) error {
// LeaveChannel removes a character from the channel
func (c *Channel) LeaveChannel(characterID int32) error {
c.mu.Lock()
defer c.mu.Unlock()
return c.leaveChannel(characterID)
}
@ -161,14 +218,11 @@ func (c *Channel) leaveChannel(characterID int32) error {
}
}
return fmt.Errorf("character %d is not in channel %s", characterID, c.name)
return fmt.Errorf("character %d is not in channel %s", characterID, c.Name)
}
// GetMembers returns a copy of the current member list
func (c *Channel) GetMembers() []int32 {
c.mu.RLock()
defer c.mu.RUnlock()
// Return a copy to prevent external modification
members := make([]int32, len(c.members))
copy(members, c.members)
@ -177,44 +231,38 @@ func (c *Channel) GetMembers() []int32 {
// GetChannelInfo returns basic channel information
func (c *Channel) GetChannelInfo() ChannelInfo {
c.mu.RLock()
defer c.mu.RUnlock()
return ChannelInfo{
Name: c.name,
HasPassword: c.password != "",
Name: c.Name,
HasPassword: c.Password != "",
MemberCount: len(c.members),
LevelRestriction: c.levelRestriction,
RaceRestriction: c.raceRestriction,
ClassRestriction: c.classRestriction,
ChannelType: c.channelType,
LevelRestriction: c.LevelRestriction,
RaceRestriction: c.RaceRestriction,
ClassRestriction: c.ClassRestriction,
ChannelType: c.ChannelType,
}
}
// ValidateJoin checks if a character can join the channel based on restrictions
func (c *Channel) ValidateJoin(level, race, class int32, password string) error {
c.mu.RLock()
defer c.mu.RUnlock()
// Check password
if c.password != "" && c.password != password {
return fmt.Errorf("invalid password for channel %s", c.name)
if c.Password != "" && c.Password != password {
return fmt.Errorf("invalid password for channel %s", c.Name)
}
// Check level restriction
if !c.CanJoinChannelByLevel(level) {
return fmt.Errorf("level %d does not meet minimum requirement of %d for channel %s",
level, c.levelRestriction, c.name)
level, c.LevelRestriction, c.Name)
}
// Check race restriction
if !c.CanJoinChannelByRace(race) {
return fmt.Errorf("race %d is not allowed in channel %s", race, c.name)
return fmt.Errorf("race %d is not allowed in channel %s", race, c.Name)
}
// Check class restriction
if !c.CanJoinChannelByClass(class) {
return fmt.Errorf("class %d is not allowed in channel %s", class, c.name)
return fmt.Errorf("class %d is not allowed in channel %s", class, c.Name)
}
return nil
@ -222,28 +270,129 @@ func (c *Channel) ValidateJoin(level, race, class int32, password string) error
// IsEmpty returns true if the channel has no members
func (c *Channel) IsEmpty() bool {
c.mu.RLock()
defer c.mu.RUnlock()
return len(c.members) == 0
}
// Copy creates a deep copy of the channel (useful for serialization or backups)
func (c *Channel) Copy() *Channel {
c.mu.RLock()
defer c.mu.RUnlock()
newChannel := &Channel{
name: c.name,
password: c.password,
channelType: c.channelType,
levelRestriction: c.levelRestriction,
raceRestriction: c.raceRestriction,
classRestriction: c.classRestriction,
discordEnabled: c.discordEnabled,
created: c.created,
ID: c.ID,
Name: c.Name,
Password: c.Password,
ChannelType: c.ChannelType,
LevelRestriction: c.LevelRestriction,
RaceRestriction: c.RaceRestriction,
ClassRestriction: c.ClassRestriction,
DiscordEnabled: c.DiscordEnabled,
Created: c.Created,
Updated: c.Updated,
db: c.db,
isNew: true, // Copy is always new
members: make([]int32, len(c.members)),
}
copy(newChannel.members, c.members)
return newChannel
}
// IsNew returns true if this is a new channel not yet saved to database
func (c *Channel) IsNew() bool {
return c.isNew
}
// Save saves the channel to the database
func (c *Channel) Save() error {
if c.db == nil {
return fmt.Errorf("no database connection available")
}
if c.isNew {
return c.insert()
}
return c.update()
}
// Delete removes the channel from the database
func (c *Channel) Delete() error {
if c.db == nil {
return fmt.Errorf("no database connection available")
}
if c.isNew {
return fmt.Errorf("cannot delete unsaved channel")
}
query := `DELETE FROM channels WHERE id = ?`
_, err := c.db.Exec(query, c.ID)
if err != nil {
return fmt.Errorf("failed to delete channel %d: %w", c.ID, err)
}
return nil
}
// Reload reloads the channel data from the database
func (c *Channel) Reload() error {
if c.db == nil {
return fmt.Errorf("no database connection available")
}
if c.isNew {
return fmt.Errorf("cannot reload unsaved channel")
}
query := `SELECT name, password, type, level_restriction, race_restriction, class_restriction, discord_enabled, created_at, updated_at FROM channels WHERE id = ?`
row := c.db.QueryRow(query, c.ID)
err := row.Scan(&c.Name, &c.Password, &c.ChannelType, &c.LevelRestriction,
&c.RaceRestriction, &c.ClassRestriction, &c.DiscordEnabled, &c.Created, &c.Updated)
if err != nil {
return fmt.Errorf("failed to reload channel %d: %w", c.ID, err)
}
return nil
}
// insert inserts a new channel into the database
func (c *Channel) insert() error {
query := `INSERT INTO channels (name, password, type, level_restriction, race_restriction, class_restriction, discord_enabled, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
result, err := c.db.Exec(query, c.Name, c.Password, c.ChannelType, c.LevelRestriction,
c.RaceRestriction, c.ClassRestriction, c.DiscordEnabled, c.Created, c.Updated)
if err != nil {
return fmt.Errorf("failed to insert channel: %w", err)
}
// Get the generated ID
id, err := result.LastInsertId()
if err != nil {
return fmt.Errorf("failed to get inserted channel ID: %w", err)
}
c.ID = int32(id)
c.isNew = false
return nil
}
// update updates an existing channel in the database
func (c *Channel) update() error {
c.Updated = time.Now()
query := `UPDATE channels SET name = ?, password = ?, type = ?, level_restriction = ?,
race_restriction = ?, class_restriction = ?, discord_enabled = ?, updated_at = ?
WHERE id = ?`
result, err := c.db.Exec(query, c.Name, c.Password, c.ChannelType, c.LevelRestriction,
c.RaceRestriction, c.ClassRestriction, c.DiscordEnabled, c.Updated, c.ID)
if err != nil {
return fmt.Errorf("failed to update channel: %w", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("failed to get rows affected: %w", err)
}
if rowsAffected == 0 {
return fmt.Errorf("channel %d not found for update", c.ID)
}
return nil
}

View File

@ -1,359 +0,0 @@
package channel
import (
"fmt"
"slices"
"sync"
"time"
)
// Channel type constants
const (
TypeNone = 0
TypeWorld = 1 // Persistent, loaded from database
TypeCustom = 2 // Temporary, deleted when empty
)
// Channel actions for client communication
const (
ActionJoin = 0 // Player joins channel
ActionLeave = 1 // Player leaves channel
ActionOtherJoin = 2 // Another player joins
ActionOtherLeave = 3 // Another player leaves
)
// Channel name and password limits
const (
MaxNameLength = 100
MaxPasswordLength = 100
)
// Channel restrictions
const (
NoLevelRestriction = 0
NoRaceRestriction = 0
NoClassRestriction = 0
)
// 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
}
// ChannelInfo provides basic channel information for client lists
type ChannelInfo struct {
Name string
HasPassword bool
MemberCount int
LevelRestriction int32
RaceRestriction int32
ClassRestriction int32
ChannelType int
}
// ChannelMember represents a member in a channel
type ChannelMember struct {
CharacterID int32
CharacterName string
Level int32
Race int32
Class int32
JoinedAt time.Time
}
// NewChannel creates a new channel instance
func NewChannel(name string) *Channel {
return &Channel{
name: name,
members: make([]int32, 0),
created: time.Now(),
}
}
// SetName sets the channel name
func (c *Channel) SetName(name string) {
c.mu.Lock()
defer c.mu.Unlock()
c.name = name
}
// SetPassword sets the channel password
func (c *Channel) SetPassword(password string) {
c.mu.Lock()
defer c.mu.Unlock()
c.password = password
}
// SetType sets the channel type
func (c *Channel) SetType(channelType int) {
c.mu.Lock()
defer c.mu.Unlock()
c.channelType = channelType
}
// SetLevelRestriction sets the minimum level required to join
func (c *Channel) SetLevelRestriction(level int32) {
c.mu.Lock()
defer c.mu.Unlock()
c.levelRestriction = level
}
// SetRacesAllowed sets the race bitmask for allowed races
func (c *Channel) SetRacesAllowed(races int32) {
c.mu.Lock()
defer c.mu.Unlock()
c.raceRestriction = races
}
// SetClassesAllowed sets the class bitmask for allowed classes
func (c *Channel) SetClassesAllowed(classes int32) {
c.mu.Lock()
defer c.mu.Unlock()
c.classRestriction = classes
}
// SetDiscordEnabled enables or disables Discord integration for this channel
func (c *Channel) SetDiscordEnabled(enabled bool) {
c.mu.Lock()
defer c.mu.Unlock()
c.discordEnabled = enabled
}
// GetName returns the channel name
func (c *Channel) GetName() string {
c.mu.RLock()
defer c.mu.RUnlock()
return c.name
}
// GetType returns the channel type
func (c *Channel) GetType() int {
c.mu.RLock()
defer c.mu.RUnlock()
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)
}
// GetCreatedTime returns when the channel was created
func (c *Channel) GetCreatedTime() time.Time {
c.mu.RLock()
defer c.mu.RUnlock()
return c.created
}
// HasPassword returns true if the channel has a password
func (c *Channel) HasPassword() bool {
c.mu.RLock()
defer c.mu.RUnlock()
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
}
// 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
}
// 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<<raceID)) != 0
}
// CanJoinChannelByClass checks if a player's class is allowed in the channel
func (c *Channel) CanJoinChannelByClass(classID int32) bool {
c.mu.RLock()
defer c.mu.RUnlock()
return c.classRestriction == NoClassRestriction || (c.classRestriction&(1<<classID)) != 0
}
// IsInChannel checks if a character is in the channel
func (c *Channel) IsInChannel(characterID int32) bool {
c.mu.RLock()
defer c.mu.RUnlock()
return c.isInChannel(characterID)
}
// isInChannel is the internal implementation without locking
func (c *Channel) isInChannel(characterID int32) bool {
return slices.Contains(c.members, characterID)
}
// JoinChannel adds a character to the channel
func (c *Channel) JoinChannel(characterID int32) error {
c.mu.Lock()
defer c.mu.Unlock()
return c.joinChannel(characterID)
}
// joinChannel is the internal implementation without locking
func (c *Channel) joinChannel(characterID int32) error {
// Check if already in channel
if c.isInChannel(characterID) {
return fmt.Errorf("character %d is already in channel %s", characterID, c.name)
}
// Add to members list
c.members = append(c.members, characterID)
return nil
}
// LeaveChannel removes a character from the channel
func (c *Channel) LeaveChannel(characterID int32) error {
c.mu.Lock()
defer c.mu.Unlock()
return c.leaveChannel(characterID)
}
// leaveChannel is the internal implementation without locking
func (c *Channel) leaveChannel(characterID int32) error {
// Find and remove the character
for i, memberID := range c.members {
if memberID == characterID {
// Remove member by swapping with last element and truncating
c.members[i] = c.members[len(c.members)-1]
c.members = c.members[:len(c.members)-1]
return nil
}
}
return fmt.Errorf("character %d is not in channel %s", characterID, c.name)
}
// GetMembers returns a copy of the current member list
func (c *Channel) GetMembers() []int32 {
c.mu.RLock()
defer c.mu.RUnlock()
// Return a copy to prevent external modification
members := make([]int32, len(c.members))
copy(members, c.members)
return members
}
// GetChannelInfo returns basic channel information
func (c *Channel) GetChannelInfo() ChannelInfo {
c.mu.RLock()
defer c.mu.RUnlock()
return ChannelInfo{
Name: c.name,
HasPassword: c.password != "",
MemberCount: len(c.members),
LevelRestriction: c.levelRestriction,
RaceRestriction: c.raceRestriction,
ClassRestriction: c.classRestriction,
ChannelType: c.channelType,
}
}
// ValidateJoin checks if a character can join the channel based on restrictions
func (c *Channel) ValidateJoin(level, race, class int32, password string) error {
c.mu.RLock()
defer c.mu.RUnlock()
// Check password
if c.password != "" && c.password != password {
return fmt.Errorf("invalid password for channel %s", c.name)
}
// Check level restriction
if !c.CanJoinChannelByLevel(level) {
return fmt.Errorf("level %d does not meet minimum requirement of %d for channel %s",
level, c.levelRestriction, c.name)
}
// Check race restriction
if !c.CanJoinChannelByRace(race) {
return fmt.Errorf("race %d is not allowed in channel %s", race, c.name)
}
// Check class restriction
if !c.CanJoinChannelByClass(class) {
return fmt.Errorf("class %d is not allowed in channel %s", class, c.name)
}
return nil
}
// IsEmpty returns true if the channel has no members
func (c *Channel) IsEmpty() bool {
c.mu.RLock()
defer c.mu.RUnlock()
return len(c.members) == 0
}
// IsDiscordEnabled returns true if Discord integration is enabled for this channel
func (c *Channel) IsDiscordEnabled() bool {
c.mu.RLock()
defer c.mu.RUnlock()
return c.discordEnabled
}
// Copy creates a deep copy of the channel (useful for serialization or backups)
func (c *Channel) Copy() *Channel {
c.mu.RLock()
defer c.mu.RUnlock()
newChannel := &Channel{
name: c.name,
password: c.password,
channelType: c.channelType,
levelRestriction: c.levelRestriction,
raceRestriction: c.raceRestriction,
classRestriction: c.classRestriction,
discordEnabled: c.discordEnabled,
created: c.created,
members: make([]int32, len(c.members)),
}
copy(newChannel.members, c.members)
return newChannel
}
// GetRestrictions returns the channel's access restrictions
func (c *Channel) GetRestrictions() (level, race, class int32) {
c.mu.RLock()
defer c.mu.RUnlock()
return c.levelRestriction, c.raceRestriction, c.classRestriction
}
// GetPassword returns the channel password (for admin purposes)
func (c *Channel) GetPassword() string {
c.mu.RLock()
defer c.mu.RUnlock()
return c.password
}
// UpdateRestrictions updates all access restrictions at once
func (c *Channel) UpdateRestrictions(level, race, class int32) {
c.mu.Lock()
defer c.mu.Unlock()
c.levelRestriction = level
c.raceRestriction = race
c.classRestriction = class
}

View File

@ -0,0 +1,341 @@
package chat
import (
"testing"
"eq2emu/internal/database"
)
func TestNew(t *testing.T) {
db, err := database.NewSQLite("file::memory:?mode=memory&cache=shared")
if err != nil {
t.Fatalf("Failed to create test database: %v", err)
}
defer db.Close()
// Test creating a new channel
channel := New(db)
if channel == nil {
t.Fatal("New returned nil")
}
if !channel.IsNew() {
t.Error("New channel should be marked as new")
}
// Test setting values
channel.ID = 1001
channel.Name = "Test Channel"
channel.ChannelType = ChannelTypeCustom
if channel.GetID() != 1001 {
t.Errorf("Expected GetID() to return 1001, got %d", channel.GetID())
}
if channel.GetName() != "Test Channel" {
t.Errorf("Expected GetName() to return 'Test Channel', got %s", channel.GetName())
}
if channel.GetType() != ChannelTypeCustom {
t.Errorf("Expected GetType() to return %d, got %d", ChannelTypeCustom, channel.GetType())
}
}
func TestNewWithData(t *testing.T) {
db, err := database.NewSQLite("file::memory:?mode=memory&cache=shared")
if err != nil {
t.Fatalf("Failed to create test database: %v", err)
}
defer db.Close()
channel := NewWithData(100, "Auction", ChannelTypeWorld, db)
if channel == nil {
t.Fatal("NewWithData returned nil")
}
if channel.GetID() != 100 {
t.Errorf("Expected ID 100, got %d", channel.GetID())
}
if channel.GetName() != "Auction" {
t.Errorf("Expected name 'Auction', got '%s'", channel.GetName())
}
if channel.GetType() != ChannelTypeWorld {
t.Errorf("Expected type %d, got %d", ChannelTypeWorld, channel.GetType())
}
if !channel.IsNew() {
t.Error("NewWithData should create new channel")
}
}
func TestChannelGettersAndSetters(t *testing.T) {
db, _ := database.NewSQLite("file::memory:?mode=memory&cache=shared")
defer db.Close()
channel := NewWithData(123, "Test Channel", ChannelTypeCustom, db)
// Test getters
if id := channel.GetID(); id != 123 {
t.Errorf("GetID() = %v, want 123", id)
}
if name := channel.GetName(); name != "Test Channel" {
t.Errorf("GetName() = %v, want Test Channel", name)
}
if channelType := channel.GetType(); channelType != ChannelTypeCustom {
t.Errorf("GetType() = %v, want %d", channelType, ChannelTypeCustom)
}
// Test setters
channel.SetName("Modified Channel")
if channel.GetName() != "Modified Channel" {
t.Errorf("SetName failed: got %v, want Modified Channel", channel.GetName())
}
channel.SetType(ChannelTypeWorld)
if channel.GetType() != ChannelTypeWorld {
t.Errorf("SetType failed: got %v, want %d", channel.GetType(), ChannelTypeWorld)
}
channel.SetLevelRestriction(10)
if channel.LevelRestriction != 10 {
t.Errorf("SetLevelRestriction failed: got %v, want 10", channel.LevelRestriction)
}
channel.SetPassword("secret")
if !channel.HasPassword() {
t.Error("HasPassword should return true after setting password")
}
if !channel.PasswordMatches("secret") {
t.Error("PasswordMatches should return true for correct password")
}
if channel.PasswordMatches("wrong") {
t.Error("PasswordMatches should return false for incorrect password")
}
}
func TestChannelMembership(t *testing.T) {
db, _ := database.NewSQLite("file::memory:?mode=memory&cache=shared")
defer db.Close()
channel := NewWithData(100, "Test", ChannelTypeCustom, db)
// Test empty channel
if !channel.IsEmpty() {
t.Error("New channel should be empty")
}
if channel.GetNumClients() != 0 {
t.Errorf("GetNumClients() = %v, want 0", channel.GetNumClients())
}
// Test joining channel
err := channel.JoinChannel(1001)
if err != nil {
t.Errorf("JoinChannel failed: %v", err)
}
if !channel.IsInChannel(1001) {
t.Error("IsInChannel should return true after joining")
}
if channel.IsEmpty() {
t.Error("Channel should not be empty after member joins")
}
if channel.GetNumClients() != 1 {
t.Errorf("GetNumClients() = %v, want 1", channel.GetNumClients())
}
// Test duplicate join
err = channel.JoinChannel(1001)
if err == nil {
t.Error("JoinChannel should fail for duplicate member")
}
// Test adding another member
err = channel.JoinChannel(1002)
if err != nil {
t.Errorf("JoinChannel failed: %v", err)
}
if channel.GetNumClients() != 2 {
t.Errorf("GetNumClients() = %v, want 2", channel.GetNumClients())
}
// Test getting members
members := channel.GetMembers()
if len(members) != 2 {
t.Errorf("GetMembers() returned %d members, want 2", len(members))
}
// Test leaving channel
err = channel.LeaveChannel(1001)
if err != nil {
t.Errorf("LeaveChannel failed: %v", err)
}
if channel.IsInChannel(1001) {
t.Error("IsInChannel should return false after leaving")
}
if channel.GetNumClients() != 1 {
t.Errorf("GetNumClients() = %v, want 1", channel.GetNumClients())
}
// Test leaving non-member
err = channel.LeaveChannel(9999)
if err == nil {
t.Error("LeaveChannel should fail for non-member")
}
}
func TestChannelRestrictions(t *testing.T) {
db, _ := database.NewSQLite("file::memory:?mode=memory&cache=shared")
defer db.Close()
channel := NewWithData(100, "Restricted", ChannelTypeWorld, db)
// Test level restrictions
channel.SetLevelRestriction(10)
if !channel.CanJoinChannelByLevel(10) {
t.Error("CanJoinChannelByLevel should return true for exact minimum")
}
if !channel.CanJoinChannelByLevel(15) {
t.Error("CanJoinChannelByLevel should return true for above minimum")
}
if channel.CanJoinChannelByLevel(5) {
t.Error("CanJoinChannelByLevel should return false for below minimum")
}
// Test race restrictions (bitmask)
channel.SetRacesAllowed(1 << 1) // Only race ID 1 allowed
if !channel.CanJoinChannelByRace(1) {
t.Error("CanJoinChannelByRace should return true for allowed race")
}
if channel.CanJoinChannelByRace(2) {
t.Error("CanJoinChannelByRace should return false for disallowed race")
}
// Test class restrictions (bitmask)
channel.SetClassesAllowed(1 << 5) // Only class ID 5 allowed
if !channel.CanJoinChannelByClass(5) {
t.Error("CanJoinChannelByClass should return true for allowed class")
}
if channel.CanJoinChannelByClass(1) {
t.Error("CanJoinChannelByClass should return false for disallowed class")
}
// Test ValidateJoin
err := channel.ValidateJoin(15, 1, 5, "")
if err != nil {
t.Errorf("ValidateJoin should succeed for valid player: %v", err)
}
err = channel.ValidateJoin(5, 1, 5, "")
if err == nil {
t.Error("ValidateJoin should fail for insufficient level")
}
err = channel.ValidateJoin(15, 2, 5, "")
if err == nil {
t.Error("ValidateJoin should fail for disallowed race")
}
err = channel.ValidateJoin(15, 1, 1, "")
if err == nil {
t.Error("ValidateJoin should fail for disallowed class")
}
// Test password validation
channel.SetPassword("secret")
err = channel.ValidateJoin(15, 1, 5, "secret")
if err != nil {
t.Errorf("ValidateJoin should succeed with correct password: %v", err)
}
err = channel.ValidateJoin(15, 1, 5, "wrong")
if err == nil {
t.Error("ValidateJoin should fail with incorrect password")
}
}
func TestChannelInfo(t *testing.T) {
db, _ := database.NewSQLite("file::memory:?mode=memory&cache=shared")
defer db.Close()
channel := NewWithData(100, "Info Test", ChannelTypeWorld, db)
channel.SetPassword("secret")
channel.SetLevelRestriction(10)
channel.JoinChannel(1001)
channel.JoinChannel(1002)
info := channel.GetChannelInfo()
if info.Name != "Info Test" {
t.Errorf("ChannelInfo.Name = %v, want Info Test", info.Name)
}
if !info.HasPassword {
t.Error("ChannelInfo.HasPassword should be true")
}
if info.MemberCount != 2 {
t.Errorf("ChannelInfo.MemberCount = %v, want 2", info.MemberCount)
}
if info.LevelRestriction != 10 {
t.Errorf("ChannelInfo.LevelRestriction = %v, want 10", info.LevelRestriction)
}
if info.ChannelType != ChannelTypeWorld {
t.Errorf("ChannelInfo.ChannelType = %v, want %d", info.ChannelType, ChannelTypeWorld)
}
}
func TestChannelCopy(t *testing.T) {
db, _ := database.NewSQLite("file::memory:?mode=memory&cache=shared")
defer db.Close()
original := NewWithData(500, "Original Channel", ChannelTypeWorld, db)
original.SetPassword("secret")
original.SetLevelRestriction(15)
original.JoinChannel(1001)
copy := original.Copy()
if copy == nil {
t.Fatal("Copy returned nil")
}
if copy == original {
t.Error("Copy returned same pointer as original")
}
if copy.GetID() != original.GetID() {
t.Errorf("Copy ID = %v, want %v", copy.GetID(), original.GetID())
}
if copy.GetName() != original.GetName() {
t.Errorf("Copy Name = %v, want %v", copy.GetName(), original.GetName())
}
if copy.Password != original.Password {
t.Errorf("Copy Password = %v, want %v", copy.Password, original.Password)
}
if !copy.IsNew() {
t.Error("Copy should always be marked as new")
}
// Verify modification independence
copy.SetName("Modified Copy")
if original.GetName() == "Modified Copy" {
t.Error("Modifying copy affected original")
}
}

View File

@ -1,455 +0,0 @@
package chat
import (
"context"
"fmt"
"strings"
"time"
)
// NewChatManager creates a new chat manager instance
func NewChatManager(database ChannelDatabase, clientManager ClientManager, playerManager PlayerManager, languageProcessor LanguageProcessor) *ChatManager {
return &ChatManager{
channels: make(map[string]*Channel),
database: database,
clientManager: clientManager,
playerManager: playerManager,
languageProcessor: languageProcessor,
}
}
// Initialize loads world channels from database and prepares the chat system
func (cm *ChatManager) Initialize(ctx context.Context) error {
cm.mu.Lock()
defer cm.mu.Unlock()
// Load world channels from database
worldChannels, err := cm.database.LoadWorldChannels(ctx)
if err != nil {
return fmt.Errorf("failed to load world channels: %w", err)
}
// Create world channels
for _, channelData := range worldChannels {
channel := &Channel{
name: channelData.Name,
password: channelData.Password,
channelType: ChannelTypeWorld,
levelRestriction: channelData.LevelRestriction,
raceRestriction: channelData.RaceRestriction,
classRestriction: channelData.ClassRestriction,
members: make([]int32, 0),
created: time.Now(),
}
cm.channels[strings.ToLower(channelData.Name)] = channel
}
return nil
}
// AddChannel adds a new channel to the manager (used for world channels loaded from database)
func (cm *ChatManager) AddChannel(channel *Channel) {
cm.mu.Lock()
defer cm.mu.Unlock()
cm.channels[strings.ToLower(channel.name)] = channel
}
// GetNumChannels returns the total number of channels
func (cm *ChatManager) GetNumChannels() int {
cm.mu.RLock()
defer cm.mu.RUnlock()
return len(cm.channels)
}
// GetWorldChannelList returns filtered list of world channels for a client
func (cm *ChatManager) GetWorldChannelList(characterID int32) ([]ChannelInfo, error) {
cm.mu.RLock()
defer cm.mu.RUnlock()
playerInfo, err := cm.playerManager.GetPlayerInfo(characterID)
if err != nil {
return nil, fmt.Errorf("failed to get player info: %w", err)
}
var channelList []ChannelInfo
for _, channel := range cm.channels {
if channel.channelType == ChannelTypeWorld {
// Check if player can join based on restrictions
if cm.canJoinChannel(playerInfo.Level, playerInfo.Race, playerInfo.Class,
channel.levelRestriction, channel.raceRestriction, channel.classRestriction) {
channelInfo := ChannelInfo{
Name: channel.name,
HasPassword: channel.password != "",
MemberCount: len(channel.members),
LevelRestriction: channel.levelRestriction,
RaceRestriction: channel.raceRestriction,
ClassRestriction: channel.classRestriction,
ChannelType: channel.channelType,
}
channelList = append(channelList, channelInfo)
}
}
}
return channelList, nil
}
// ChannelExists checks if a channel with the given name exists
func (cm *ChatManager) ChannelExists(channelName string) bool {
cm.mu.RLock()
defer cm.mu.RUnlock()
_, exists := cm.channels[strings.ToLower(channelName)]
return exists
}
// HasPassword checks if a channel has a password
func (cm *ChatManager) HasPassword(channelName string) bool {
cm.mu.RLock()
defer cm.mu.RUnlock()
if channel, exists := cm.channels[strings.ToLower(channelName)]; exists {
return channel.password != ""
}
return false
}
// PasswordMatches checks if the provided password matches the channel password
func (cm *ChatManager) PasswordMatches(channelName, password string) bool {
cm.mu.RLock()
defer cm.mu.RUnlock()
if channel, exists := cm.channels[strings.ToLower(channelName)]; exists {
return channel.password == password
}
return false
}
// CreateChannel creates a new custom channel
func (cm *ChatManager) CreateChannel(channelName string, password ...string) error {
if len(channelName) > 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<<playerRace)) == 0 {
return false
}
// Check class restriction (bitmask)
if classReq > NoClassRestriction && (classReq&(1<<playerClass)) == 0 {
return false
}
return true
}
// notifyChannelUpdate sends channel update notifications to all members
func (cm *ChatManager) notifyChannelUpdate(channelName string, action int, characterName string, excludeCharacterID int32) {
if channel, exists := cm.channels[strings.ToLower(channelName)]; exists {
for _, memberID := range channel.members {
if memberID != excludeCharacterID {
cm.clientManager.SendChannelUpdate(memberID, channelName, action, characterName)
}
}
}
}
// deliverChannelMessage processes and delivers a message to all channel members
func (cm *ChatManager) deliverChannelMessage(channel *Channel, message ChannelMessage) error {
for _, memberID := range channel.members {
// Process message for language if language processor is available
processedMessage := message
if cm.languageProcessor != nil && message.SenderID != 0 {
if processedText, err := cm.languageProcessor.ProcessMessage(
message.SenderID, memberID, message.Message, message.LanguageID); err == nil {
processedMessage.Message = processedText
}
}
// Send message to member
if err := cm.clientManager.SendChannelMessage(memberID, processedMessage); err != nil {
// Log error but continue sending to other members
// TODO: Add proper logging
}
}
return nil
}

View File

@ -1,906 +0,0 @@
package chat
import (
"context"
"fmt"
"sync"
"testing"
"time"
)
// EntityWithGetID helper type for testing
type EntityWithGetID struct {
id int32
}
func (e *EntityWithGetID) GetID() int32 {
return e.id
}
// Mock implementations for testing
type MockChannelDatabase struct {
channels map[string]ChatChannelData
mu sync.RWMutex
loadErr error
saveErr error
delErr error
}
func NewMockChannelDatabase() *MockChannelDatabase {
return &MockChannelDatabase{
channels: make(map[string]ChatChannelData),
}
}
func (m *MockChannelDatabase) LoadWorldChannels(ctx context.Context) ([]ChatChannelData, error) {
if m.loadErr != nil {
return nil, m.loadErr
}
m.mu.RLock()
defer m.mu.RUnlock()
var channels []ChatChannelData
for _, channel := range m.channels {
channels = append(channels, channel)
}
return channels, nil
}
func (m *MockChannelDatabase) SaveChannel(ctx context.Context, channel ChatChannelData) error {
if m.saveErr != nil {
return m.saveErr
}
m.mu.Lock()
defer m.mu.Unlock()
m.channels[channel.Name] = channel
return nil
}
func (m *MockChannelDatabase) DeleteChannel(ctx context.Context, channelName string) error {
if m.delErr != nil {
return m.delErr
}
m.mu.Lock()
defer m.mu.Unlock()
if _, exists := m.channels[channelName]; !exists {
return fmt.Errorf("channel not found")
}
delete(m.channels, channelName)
return nil
}
func (m *MockChannelDatabase) SetLoadError(err error) {
m.loadErr = err
}
func (m *MockChannelDatabase) SetSaveError(err error) {
m.saveErr = err
}
func (m *MockChannelDatabase) SetDeleteError(err error) {
m.delErr = err
}
type MockClientManager struct {
sentMessages []ChannelMessage
sentLists map[int32][]ChannelInfo
sentUpdates []ChatUpdate
sentUserLists map[int32][]ChannelMember
connectedClients map[int32]bool
mu sync.RWMutex
}
type ChatUpdate struct {
CharacterID int32
ChannelName string
Action int
CharacterName string
}
func NewMockClientManager() *MockClientManager {
return &MockClientManager{
sentLists: make(map[int32][]ChannelInfo),
sentUserLists: make(map[int32][]ChannelMember),
connectedClients: make(map[int32]bool),
}
}
func (m *MockClientManager) SendChannelList(characterID int32, channels []ChannelInfo) error {
m.mu.Lock()
defer m.mu.Unlock()
m.sentLists[characterID] = channels
return nil
}
func (m *MockClientManager) SendChannelMessage(characterID int32, message ChannelMessage) error {
m.mu.Lock()
defer m.mu.Unlock()
m.sentMessages = append(m.sentMessages, message)
return nil
}
func (m *MockClientManager) SendChannelUpdate(characterID int32, channelName string, action int, characterName string) error {
m.mu.Lock()
defer m.mu.Unlock()
m.sentUpdates = append(m.sentUpdates, ChatUpdate{
CharacterID: characterID,
ChannelName: channelName,
Action: action,
CharacterName: characterName,
})
return nil
}
func (m *MockClientManager) SendChannelUserList(characterID int32, channelName string, members []ChannelMember) error {
m.mu.Lock()
defer m.mu.Unlock()
m.sentUserLists[characterID] = members
return nil
}
func (m *MockClientManager) IsClientConnected(characterID int32) bool {
m.mu.RLock()
defer m.mu.RUnlock()
return m.connectedClients[characterID]
}
func (m *MockClientManager) SetClientConnected(characterID int32, connected bool) {
m.mu.Lock()
defer m.mu.Unlock()
m.connectedClients[characterID] = connected
}
func (m *MockClientManager) GetSentMessages() []ChannelMessage {
m.mu.RLock()
defer m.mu.RUnlock()
return append([]ChannelMessage{}, m.sentMessages...)
}
type MockPlayerManager struct {
players map[int32]PlayerInfo
mu sync.RWMutex
}
func NewMockPlayerManager() *MockPlayerManager {
return &MockPlayerManager{
players: make(map[int32]PlayerInfo),
}
}
func (m *MockPlayerManager) GetPlayerInfo(characterID int32) (PlayerInfo, error) {
m.mu.RLock()
defer m.mu.RUnlock()
if player, exists := m.players[characterID]; exists {
return player, nil
}
return PlayerInfo{}, fmt.Errorf("player not found")
}
func (m *MockPlayerManager) ValidatePlayer(characterID int32, levelReq, raceReq, classReq int32) bool {
player, err := m.GetPlayerInfo(characterID)
if err != nil {
return false
}
if levelReq > 0 && player.Level < levelReq {
return false
}
if raceReq > 0 && (raceReq&(1<<player.Race)) == 0 {
return false
}
if classReq > 0 && (classReq&(1<<player.Class)) == 0 {
return false
}
return true
}
func (m *MockPlayerManager) GetPlayerLanguages(characterID int32) ([]int32, error) {
return []int32{0, 1}, nil // Default languages
}
func (m *MockPlayerManager) AddPlayer(player PlayerInfo) {
m.mu.Lock()
defer m.mu.Unlock()
m.players[player.CharacterID] = player
}
type MockLanguageProcessor struct{}
func (m *MockLanguageProcessor) ProcessMessage(senderID, receiverID int32, message string, languageID int32) (string, error) {
return message, nil // No scrambling for tests
}
func (m *MockLanguageProcessor) CanUnderstand(senderID, receiverID int32, languageID int32) bool {
return true // Everyone understands everything in tests
}
func (m *MockLanguageProcessor) GetDefaultLanguage(characterID int32) int32 {
return 0 // Common tongue
}
// Channel tests
func TestNewChannel(t *testing.T) {
channel := NewChannel("test")
if channel == nil {
t.Fatal("NewChannel returned nil")
}
if channel.GetName() != "test" {
t.Errorf("Channel name = %v, want test", channel.GetName())
}
if channel.GetNumClients() != 0 {
t.Errorf("New channel should have 0 clients, got %v", channel.GetNumClients())
}
}
func TestChannelSettersAndGetters(t *testing.T) {
channel := NewChannel("test")
// Test name
channel.SetName("newname")
if channel.GetName() != "newname" {
t.Errorf("SetName failed: got %v, want newname", channel.GetName())
}
// Test password
channel.SetPassword("secret")
if !channel.HasPassword() {
t.Error("HasPassword should return true after SetPassword")
}
if !channel.PasswordMatches("secret") {
t.Error("PasswordMatches should return true for correct password")
}
if channel.PasswordMatches("wrong") {
t.Error("PasswordMatches should return false for incorrect password")
}
// Test type
channel.SetType(ChannelTypeWorld)
if channel.GetType() != ChannelTypeWorld {
t.Errorf("SetType failed: got %v, want %v", channel.GetType(), ChannelTypeWorld)
}
// Test restrictions
channel.SetLevelRestriction(50)
if !channel.CanJoinChannelByLevel(50) {
t.Error("CanJoinChannelByLevel should allow level 50")
}
if channel.CanJoinChannelByLevel(49) {
t.Error("CanJoinChannelByLevel should not allow level 49")
}
// Test race restriction (bitmask)
channel.SetRacesAllowed(1 << 1) // Only race ID 1 allowed
if !channel.CanJoinChannelByRace(1) {
t.Error("CanJoinChannelByRace should allow race 1")
}
if channel.CanJoinChannelByRace(2) {
t.Error("CanJoinChannelByRace should not allow race 2")
}
// Test class restriction (bitmask)
channel.SetClassesAllowed(1 << 3) // Only class ID 3 allowed
if !channel.CanJoinChannelByClass(3) {
t.Error("CanJoinChannelByClass should allow class 3")
}
if channel.CanJoinChannelByClass(4) {
t.Error("CanJoinChannelByClass should not allow class 4")
}
}
func TestChannelMembership(t *testing.T) {
channel := NewChannel("test")
// Test initial state
if channel.IsInChannel(100) {
t.Error("IsInChannel should return false for new channel")
}
if !channel.IsEmpty() {
t.Error("New channel should be empty")
}
// Test joining
err := channel.JoinChannel(100)
if err != nil {
t.Errorf("JoinChannel failed: %v", err)
}
if !channel.IsInChannel(100) {
t.Error("IsInChannel should return true after joining")
}
if channel.GetNumClients() != 1 {
t.Errorf("GetNumClients = %v, want 1", channel.GetNumClients())
}
if channel.IsEmpty() {
t.Error("Channel should not be empty after joining")
}
// Test duplicate join
err = channel.JoinChannel(100)
if err == nil {
t.Error("JoinChannel should fail for duplicate member")
}
// Test multiple members
err = channel.JoinChannel(200)
if err != nil {
t.Errorf("JoinChannel failed for second member: %v", err)
}
if channel.GetNumClients() != 2 {
t.Errorf("GetNumClients = %v, want 2", channel.GetNumClients())
}
// Test GetMembers
members := channel.GetMembers()
if len(members) != 2 {
t.Errorf("GetMembers returned %v members, want 2", len(members))
}
// Verify members contains both IDs
found100, found200 := false, false
for _, id := range members {
if id == 100 {
found100 = true
}
if id == 200 {
found200 = true
}
}
if !found100 || !found200 {
t.Error("GetMembers should contain both member IDs")
}
// Test leaving
err = channel.LeaveChannel(100)
if err != nil {
t.Errorf("LeaveChannel failed: %v", err)
}
if channel.IsInChannel(100) {
t.Error("IsInChannel should return false after leaving")
}
if channel.GetNumClients() != 1 {
t.Errorf("GetNumClients = %v, want 1 after leaving", channel.GetNumClients())
}
// Test leaving non-member
err = channel.LeaveChannel(300)
if err == nil {
t.Error("LeaveChannel should fail for non-member")
}
// Test leaving last member
err = channel.LeaveChannel(200)
if err != nil {
t.Errorf("LeaveChannel failed for last member: %v", err)
}
if !channel.IsEmpty() {
t.Error("Channel should be empty after all members leave")
}
}
func TestChannelValidateJoin(t *testing.T) {
channel := NewChannel("test")
channel.SetPassword("secret")
channel.SetLevelRestriction(10)
channel.SetRacesAllowed(1 << 1) // Race 1 only
channel.SetClassesAllowed(1 << 2) // Class 2 only
tests := []struct {
name string
level int32
race int32
class int32
password string
wantErr bool
}{
{
name: "valid join",
level: 15,
race: 1,
class: 2,
password: "secret",
wantErr: false,
},
{
name: "wrong password",
level: 15,
race: 1,
class: 2,
password: "wrong",
wantErr: true,
},
{
name: "level too low",
level: 5,
race: 1,
class: 2,
password: "secret",
wantErr: true,
},
{
name: "wrong race",
level: 15,
race: 2,
class: 2,
password: "secret",
wantErr: true,
},
{
name: "wrong class",
level: 15,
race: 1,
class: 3,
password: "secret",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := channel.ValidateJoin(tt.level, tt.race, tt.class, tt.password)
if (err != nil) != tt.wantErr {
t.Errorf("ValidateJoin() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
func TestChannelGetChannelInfo(t *testing.T) {
channel := NewChannel("testchannel")
channel.SetType(ChannelTypeWorld)
channel.SetPassword("secret")
channel.SetLevelRestriction(20)
channel.SetRacesAllowed(15) // Multiple races
channel.SetClassesAllowed(31) // Multiple classes
channel.JoinChannel(100)
channel.JoinChannel(200)
info := channel.GetChannelInfo()
if info.Name != "testchannel" {
t.Errorf("ChannelInfo Name = %v, want testchannel", info.Name)
}
if !info.HasPassword {
t.Error("ChannelInfo should indicate has password")
}
if info.MemberCount != 2 {
t.Errorf("ChannelInfo MemberCount = %v, want 2", info.MemberCount)
}
if info.LevelRestriction != 20 {
t.Errorf("ChannelInfo LevelRestriction = %v, want 20", info.LevelRestriction)
}
if info.ChannelType != ChannelTypeWorld {
t.Errorf("ChannelInfo ChannelType = %v, want %v", info.ChannelType, ChannelTypeWorld)
}
}
func TestChannelCopy(t *testing.T) {
original := NewChannel("original")
original.SetPassword("secret")
original.SetType(ChannelTypeCustom)
original.SetLevelRestriction(25)
original.JoinChannel(100)
original.JoinChannel(200)
copy := original.Copy()
if copy == original {
t.Error("Copy should return different instance")
}
if copy.GetName() != original.GetName() {
t.Error("Copy should have same name")
}
if copy.GetType() != original.GetType() {
t.Error("Copy should have same type")
}
if copy.GetNumClients() != original.GetNumClients() {
t.Error("Copy should have same member count")
}
// Test that modifying copy doesn't affect original
copy.JoinChannel(300)
if original.GetNumClients() == copy.GetNumClients() {
t.Error("Modifying copy should not affect original")
}
}
// Database tests
func TestMockChannelDatabase(t *testing.T) {
db := NewMockChannelDatabase()
ctx := context.Background()
// Test empty database
channels, err := db.LoadWorldChannels(ctx)
if err != nil {
t.Errorf("LoadWorldChannels failed: %v", err)
}
if len(channels) != 0 {
t.Errorf("Expected 0 channels, got %v", len(channels))
}
// Test saving channel
testChannel := ChatChannelData{
Name: "testchannel",
Password: "secret",
LevelRestriction: 10,
ClassRestriction: 15,
RaceRestriction: 7,
}
err = db.SaveChannel(ctx, testChannel)
if err != nil {
t.Errorf("SaveChannel failed: %v", err)
}
// Test loading after save
channels, err = db.LoadWorldChannels(ctx)
if err != nil {
t.Errorf("LoadWorldChannels failed: %v", err)
}
if len(channels) != 1 {
t.Errorf("Expected 1 channel, got %v", len(channels))
}
if channels[0].Name != testChannel.Name {
t.Errorf("Channel name = %v, want %v", channels[0].Name, testChannel.Name)
}
// Test delete
err = db.DeleteChannel(ctx, "testchannel")
if err != nil {
t.Errorf("DeleteChannel failed: %v", err)
}
// Test delete non-existent
err = db.DeleteChannel(ctx, "nonexistent")
if err == nil {
t.Error("DeleteChannel should fail for non-existent channel")
}
// Test error conditions
db.SetLoadError(fmt.Errorf("load error"))
_, err = db.LoadWorldChannels(ctx)
if err == nil {
t.Error("LoadWorldChannels should return error when set")
}
db.SetSaveError(fmt.Errorf("save error"))
err = db.SaveChannel(ctx, testChannel)
if err == nil {
t.Error("SaveChannel should return error when set")
}
db.SetDeleteError(fmt.Errorf("delete error"))
err = db.DeleteChannel(ctx, "test")
if err == nil {
t.Error("DeleteChannel should return error when set")
}
}
// Interface tests
func TestEntityChatAdapter(t *testing.T) {
playerManager := NewMockPlayerManager()
playerManager.AddPlayer(PlayerInfo{
CharacterID: 100,
CharacterName: "TestPlayer",
Level: 25,
Race: 1,
Class: 2,
IsOnline: true,
})
entityWithGetID := &EntityWithGetID{id: 100}
adapter := &EntityChatAdapter{
entity: entityWithGetID,
playerManager: playerManager,
}
if adapter.GetCharacterID() != 100 {
t.Errorf("GetCharacterID() = %v, want 100", adapter.GetCharacterID())
}
if adapter.GetCharacterName() != "TestPlayer" {
t.Errorf("GetCharacterName() = %v, want TestPlayer", adapter.GetCharacterName())
}
if adapter.GetLevel() != 25 {
t.Errorf("GetLevel() = %v, want 25", adapter.GetLevel())
}
if adapter.GetRace() != 1 {
t.Errorf("GetRace() = %v, want 1", adapter.GetRace())
}
if adapter.GetClass() != 2 {
t.Errorf("GetClass() = %v, want 2", adapter.GetClass())
}
// Test with non-existent player
entityWithGetID2 := &EntityWithGetID{id: 999}
adapter2 := &EntityChatAdapter{
entity: entityWithGetID2,
playerManager: playerManager,
}
if adapter2.GetCharacterName() != "" {
t.Error("GetCharacterName should return empty string for non-existent player")
}
if adapter2.GetLevel() != 0 {
t.Error("GetLevel should return 0 for non-existent player")
}
}
// Constants tests
func TestConstants(t *testing.T) {
// Test channel types
if ChannelTypeNone != 0 {
t.Errorf("ChannelTypeNone = %v, want 0", ChannelTypeNone)
}
if ChannelTypeWorld != 1 {
t.Errorf("ChannelTypeWorld = %v, want 1", ChannelTypeWorld)
}
if ChannelTypeCustom != 2 {
t.Errorf("ChannelTypeCustom = %v, want 2", ChannelTypeCustom)
}
// Test chat actions
if ChatChannelJoin != 0 {
t.Errorf("ChatChannelJoin = %v, want 0", ChatChannelJoin)
}
if ChatChannelLeave != 1 {
t.Errorf("ChatChannelLeave = %v, want 1", ChatChannelLeave)
}
// Test restrictions
if NoLevelRestriction != 0 {
t.Errorf("NoLevelRestriction = %v, want 0", NoLevelRestriction)
}
if NoRaceRestriction != 0 {
t.Errorf("NoRaceRestriction = %v, want 0", NoRaceRestriction)
}
if NoClassRestriction != 0 {
t.Errorf("NoClassRestriction = %v, want 0", NoClassRestriction)
}
}
// Concurrency tests
func TestChannelConcurrency(t *testing.T) {
channel := NewChannel("concurrent")
var wg sync.WaitGroup
// Concurrent joins
for i := range 100 {
wg.Add(1)
go func(id int32) {
defer wg.Done()
channel.JoinChannel(id)
}(int32(i))
}
// Concurrent reads
for i := range 50 {
wg.Add(1)
go func(id int32) {
defer wg.Done()
channel.IsInChannel(id)
channel.GetMembers()
channel.GetChannelInfo()
}(int32(i))
}
wg.Wait()
// Verify final state
if channel.GetNumClients() != 100 {
t.Errorf("After concurrent joins, got %v members, want 100", channel.GetNumClients())
}
// Concurrent leaves
for i := range 50 {
wg.Add(1)
go func(id int32) {
defer wg.Done()
channel.LeaveChannel(id)
}(int32(i))
}
wg.Wait()
if channel.GetNumClients() != 50 {
t.Errorf("After concurrent leaves, got %v members, want 50", channel.GetNumClients())
}
}
func TestMockClientManager(t *testing.T) {
client := NewMockClientManager()
// Test client connection status
if client.IsClientConnected(100) {
t.Error("IsClientConnected should return false initially")
}
client.SetClientConnected(100, true)
if !client.IsClientConnected(100) {
t.Error("IsClientConnected should return true after setting")
}
// Test channel list
channels := []ChannelInfo{
{Name: "test1", MemberCount: 5},
{Name: "test2", MemberCount: 10},
}
err := client.SendChannelList(100, channels)
if err != nil {
t.Errorf("SendChannelList failed: %v", err)
}
// Test channel message
message := ChannelMessage{
SenderID: 200,
SenderName: "TestSender",
Message: "Hello world",
ChannelName: "test",
Timestamp: time.Now(),
}
err = client.SendChannelMessage(100, message)
if err != nil {
t.Errorf("SendChannelMessage failed: %v", err)
}
sentMessages := client.GetSentMessages()
if len(sentMessages) != 1 {
t.Errorf("Expected 1 sent message, got %v", len(sentMessages))
}
if sentMessages[0].Message != "Hello world" {
t.Errorf("Sent message = %v, want Hello world", sentMessages[0].Message)
}
}
func TestMockPlayerManager(t *testing.T) {
playerMgr := NewMockPlayerManager()
// Test non-existent player
_, err := playerMgr.GetPlayerInfo(999)
if err == nil {
t.Error("GetPlayerInfo should fail for non-existent player")
}
// Add player
player := PlayerInfo{
CharacterID: 100,
CharacterName: "TestPlayer",
Level: 30,
Race: 2,
Class: 4,
IsOnline: true,
}
playerMgr.AddPlayer(player)
// Test existing player
retrieved, err := playerMgr.GetPlayerInfo(100)
if err != nil {
t.Errorf("GetPlayerInfo failed: %v", err)
}
if retrieved.CharacterName != "TestPlayer" {
t.Errorf("Player name = %v, want TestPlayer", retrieved.CharacterName)
}
// Test validation
if !playerMgr.ValidatePlayer(100, 25, 0, 0) {
t.Error("ValidatePlayer should pass for level requirement")
}
if playerMgr.ValidatePlayer(100, 35, 0, 0) {
t.Error("ValidatePlayer should fail for level requirement")
}
// Test languages
languages, err := playerMgr.GetPlayerLanguages(100)
if err != nil {
t.Errorf("GetPlayerLanguages failed: %v", err)
}
if len(languages) == 0 {
t.Error("GetPlayerLanguages should return some languages")
}
}
func TestMockLanguageProcessor(t *testing.T) {
processor := &MockLanguageProcessor{}
// Test message processing
processed, err := processor.ProcessMessage(100, 200, "test message", 0)
if err != nil {
t.Errorf("ProcessMessage failed: %v", err)
}
if processed != "test message" {
t.Errorf("ProcessMessage = %v, want test message", processed)
}
// Test understanding
if !processor.CanUnderstand(100, 200, 0) {
t.Error("CanUnderstand should return true in mock")
}
// Test default language
if processor.GetDefaultLanguage(100) != 0 {
t.Error("GetDefaultLanguage should return 0 in mock")
}
}
// Benchmarks
func BenchmarkChannelJoin(b *testing.B) {
channel := NewChannel("benchmark")
for i := 0; i < b.N; i++ {
channel.JoinChannel(int32(i))
}
}
func BenchmarkChannelIsInChannel(b *testing.B) {
channel := NewChannel("benchmark")
for i := range 1000 {
channel.JoinChannel(int32(i))
}
for i := 0; i < b.N; i++ {
channel.IsInChannel(int32(i % 1000))
}
}
func BenchmarkChannelGetMembers(b *testing.B) {
channel := NewChannel("benchmark")
for i := range 1000 {
channel.JoinChannel(int32(i))
}
for i := 0; i < b.N; i++ {
channel.GetMembers()
}
}

View File

@ -1,270 +0,0 @@
package chat
import (
"context"
"fmt"
"zombiezen.com/go/sqlite"
"zombiezen.com/go/sqlite/sqlitex"
)
// DatabaseChannelManager implements ChannelDatabase interface using sqlitex.Pool
type DatabaseChannelManager struct {
pool *sqlitex.Pool
}
// NewDatabaseChannelManager creates a new database channel manager using sqlitex.Pool
func NewDatabaseChannelManager(pool *sqlitex.Pool) *DatabaseChannelManager {
return &DatabaseChannelManager{
pool: pool,
}
}
// LoadWorldChannels retrieves all persistent world channels from database
func (dcm *DatabaseChannelManager) LoadWorldChannels(ctx context.Context) ([]ChatChannelData, error) {
conn, err := dcm.pool.Take(context.Background())
if err != nil {
return nil, fmt.Errorf("failed to get connection: %w", err)
}
defer dcm.pool.Put(conn)
query := "SELECT `name`, `password`, `level_restriction`, `classes`, `races` FROM `channels`"
var channels []ChatChannelData
err = sqlitex.Execute(conn, query, &sqlitex.ExecOptions{
ResultFunc: func(stmt *sqlite.Stmt) error {
var channel ChatChannelData
channel.Name = stmt.ColumnText(0)
if stmt.ColumnType(1) != sqlite.TypeNull {
channel.Password = stmt.ColumnText(1)
}
channel.LevelRestriction = int32(stmt.ColumnInt64(2))
channel.ClassRestriction = int32(stmt.ColumnInt64(3))
channel.RaceRestriction = int32(stmt.ColumnInt64(4))
channels = append(channels, channel)
return nil
},
})
if err != nil {
return nil, fmt.Errorf("failed to query channels: %w", err)
}
return channels, nil
}
// SaveChannel persists a channel to database (world channels only)
func (dcm *DatabaseChannelManager) SaveChannel(ctx context.Context, channel ChatChannelData) error {
conn, err := dcm.pool.Take(context.Background())
if err != nil {
return fmt.Errorf("failed to get connection: %w", err)
}
defer dcm.pool.Put(conn)
// Insert or update channel
query := `
INSERT OR REPLACE INTO channels
(name, password, level_restriction, classes, races)
VALUES (?, ?, ?, ?, ?)`
var password any
if channel.Password != "" {
password = channel.Password
}
err = sqlitex.Execute(conn, query, &sqlitex.ExecOptions{
Args: []any{
channel.Name,
password,
channel.LevelRestriction,
channel.ClassRestriction,
channel.RaceRestriction,
},
})
if err != nil {
return fmt.Errorf("failed to save channel %s: %w", channel.Name, err)
}
return nil
}
// DeleteChannel removes a channel from database
func (dcm *DatabaseChannelManager) DeleteChannel(ctx context.Context, channelName string) error {
conn, err := dcm.pool.Take(context.Background())
if err != nil {
return fmt.Errorf("failed to get connection: %w", err)
}
defer dcm.pool.Put(conn)
query := "DELETE FROM channels WHERE name = ?"
err = sqlitex.Execute(conn, query, &sqlitex.ExecOptions{
Args: []any{channelName},
})
if err != nil {
return fmt.Errorf("failed to delete channel %s: %w", channelName, err)
}
return nil
}
// EnsureChannelsTable creates the channels table if it doesn't exist
func (dcm *DatabaseChannelManager) EnsureChannelsTable(ctx context.Context) error {
conn, err := dcm.pool.Take(context.Background())
if err != nil {
return fmt.Errorf("failed to get connection: %w", err)
}
defer dcm.pool.Put(conn)
query := `
CREATE TABLE IF NOT EXISTS 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
)`
err = sqlitex.Execute(conn, query, nil)
if err != nil {
return fmt.Errorf("failed to create channels table: %w", err)
}
return nil
}
// GetChannelCount returns the total number of channels in the database
func (dcm *DatabaseChannelManager) GetChannelCount(ctx context.Context) (int, error) {
conn, err := dcm.pool.Take(context.Background())
if err != nil {
return 0, fmt.Errorf("failed to get connection: %w", err)
}
defer dcm.pool.Put(conn)
query := "SELECT COUNT(*) FROM channels"
var count int
err = sqlitex.Execute(conn, query, &sqlitex.ExecOptions{
ResultFunc: func(stmt *sqlite.Stmt) error {
count = int(stmt.ColumnInt64(0))
return nil
},
})
if err != nil {
return 0, fmt.Errorf("failed to query channel count: %w", err)
}
return count, nil
}
// GetChannelByName retrieves a specific channel by name
func (dcm *DatabaseChannelManager) GetChannelByName(ctx context.Context, channelName string) (*ChatChannelData, error) {
conn, err := dcm.pool.Take(context.Background())
if err != nil {
return nil, fmt.Errorf("failed to get connection: %w", err)
}
defer dcm.pool.Put(conn)
query := "SELECT `name`, `password`, `level_restriction`, `classes`, `races` FROM `channels` WHERE `name` = ?"
var channel *ChatChannelData
err = sqlitex.Execute(conn, query, &sqlitex.ExecOptions{
Args: []any{channelName},
ResultFunc: func(stmt *sqlite.Stmt) error {
channel = &ChatChannelData{}
channel.Name = stmt.ColumnText(0)
if stmt.ColumnType(1) != sqlite.TypeNull {
channel.Password = stmt.ColumnText(1)
}
channel.LevelRestriction = int32(stmt.ColumnInt64(2))
channel.ClassRestriction = int32(stmt.ColumnInt64(3))
channel.RaceRestriction = int32(stmt.ColumnInt64(4))
return nil
},
})
if err != nil {
return nil, fmt.Errorf("failed to query channel %s: %w", channelName, err)
}
if channel == nil {
return nil, fmt.Errorf("channel %s not found", channelName)
}
return channel, nil
}
// ListChannelNames returns a list of all channel names in the database
func (dcm *DatabaseChannelManager) ListChannelNames(ctx context.Context) ([]string, error) {
conn, err := dcm.pool.Take(context.Background())
if err != nil {
return nil, fmt.Errorf("failed to get connection: %w", err)
}
defer dcm.pool.Put(conn)
query := "SELECT name FROM channels ORDER BY name"
var names []string
err = sqlitex.Execute(conn, query, &sqlitex.ExecOptions{
ResultFunc: func(stmt *sqlite.Stmt) error {
name := stmt.ColumnText(0)
names = append(names, name)
return nil
},
})
if err != nil {
return nil, fmt.Errorf("failed to query channel names: %w", err)
}
return names, nil
}
// UpdateChannelPassword updates just the password for a channel
func (dcm *DatabaseChannelManager) UpdateChannelPassword(ctx context.Context, channelName, password string) error {
conn, err := dcm.pool.Take(context.Background())
if err != nil {
return fmt.Errorf("failed to get connection: %w", err)
}
defer dcm.pool.Put(conn)
query := "UPDATE channels SET password = ?, updated_at = CURRENT_TIMESTAMP WHERE name = ?"
var passwordParam any
if password != "" {
passwordParam = password
}
err = sqlitex.Execute(conn, query, &sqlitex.ExecOptions{
Args: []any{passwordParam, channelName},
})
if err != nil {
return fmt.Errorf("failed to update password for channel %s: %w", channelName, err)
}
return nil
}
// UpdateChannelRestrictions updates the level, race, and class restrictions for a channel
func (dcm *DatabaseChannelManager) UpdateChannelRestrictions(ctx context.Context, channelName string, levelRestriction, classRestriction, raceRestriction int32) error {
conn, err := dcm.pool.Take(context.Background())
if err != nil {
return fmt.Errorf("failed to get connection: %w", err)
}
defer dcm.pool.Put(conn)
query := "UPDATE channels SET level_restriction = ?, classes = ?, races = ?, updated_at = CURRENT_TIMESTAMP WHERE name = ?"
err = sqlitex.Execute(conn, query, &sqlitex.ExecOptions{
Args: []any{levelRestriction, classRestriction, raceRestriction, channelName},
})
if err != nil {
return fmt.Errorf("failed to update restrictions for channel %s: %w", channelName, err)
}
return nil
}

63
internal/chat/doc.go Normal file
View File

@ -0,0 +1,63 @@
// Package chat provides chat channel management for the EverQuest II server emulator.
//
// The chat system provides comprehensive channel-based communication with membership,
// access control, and message routing capabilities. It supports both persistent world
// channels (loaded from database) and temporary custom channels (created by players).
//
// Basic Usage:
//
// db, _ := database.NewSQLite("chat.db")
//
// // Create new channel
// channel := chat.New(db)
// channel.ID = 1001
// channel.Name = "Auction"
// channel.ChannelType = chat.ChannelTypeWorld
// channel.Save()
//
// // Load existing channel
// loaded, _ := chat.Load(db, 1001)
// loaded.Delete()
//
// // Load by name
// auction, _ := chat.LoadByName(db, "Auction")
//
// Master List:
//
// masterList := chat.NewMasterList()
// masterList.LoadAllChannels(db)
// masterList.AddChannel(channel)
//
// // Find channels
// found := masterList.GetChannel(1001)
// byName := masterList.GetChannelByName("Auction")
// worldChannels := masterList.GetWorldChannels()
// activeChannels := masterList.GetActiveChannels()
//
// Channel Management:
//
// // Channel membership
// err := channel.JoinChannel(characterID)
// inChannel := channel.IsInChannel(characterID)
// members := channel.GetMembers()
// err = channel.LeaveChannel(characterID)
//
// // Access control
// channel.SetLevelRestriction(10)
// channel.SetRacesAllowed(raceFlags)
// channel.SetPassword("secret")
// canJoin := channel.CanJoinChannelByLevel(playerLevel)
//
// Channel Types:
//
// // World channels - persistent, loaded from database
// channel.ChannelType = chat.ChannelTypeWorld
//
// // Custom channels - temporary, created by players
// channel.ChannelType = chat.ChannelTypeCustom
//
// The package includes comprehensive access control with level, race, and class
// restrictions using bitmask filtering, optional password protection, and
// integration interfaces for client communication, player management, and
// multilingual chat processing.
package chat

View File

@ -1,122 +0,0 @@
package chat
import "context"
// ChannelDatabase defines database operations for chat channels
type ChannelDatabase interface {
// LoadWorldChannels retrieves all persistent world channels from database
LoadWorldChannels(ctx context.Context) ([]ChatChannelData, error)
// 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
}
// ChatAware interface for entities that can participate in chat
type ChatAware interface {
GetCharacterID() int32
GetCharacterName() string
GetLevel() int32
GetRace() int32
GetClass() int32
}
// EntityChatAdapter adapts entities to work with chat system
type EntityChatAdapter struct {
entity interface {
GetID() int32
// Add other entity methods as needed
}
playerManager PlayerManager
}
// GetCharacterID returns the character ID from the adapted entity
func (a *EntityChatAdapter) GetCharacterID() int32 {
return a.entity.GetID()
}
// GetCharacterName returns the character name from player manager
func (a *EntityChatAdapter) GetCharacterName() string {
if info, err := a.playerManager.GetPlayerInfo(a.entity.GetID()); err == nil {
return info.CharacterName
}
return ""
}
// GetLevel returns the character level from player manager
func (a *EntityChatAdapter) GetLevel() int32 {
if info, err := a.playerManager.GetPlayerInfo(a.entity.GetID()); err == nil {
return info.Level
}
return 0
}
// GetRace returns the character race from player manager
func (a *EntityChatAdapter) GetRace() int32 {
if info, err := a.playerManager.GetPlayerInfo(a.entity.GetID()); err == nil {
return info.Race
}
return 0
}
// GetClass returns the character class from player manager
func (a *EntityChatAdapter) GetClass() int32 {
if info, err := a.playerManager.GetPlayerInfo(a.entity.GetID()); err == nil {
return info.Class
}
return 0
}

View File

@ -1,307 +0,0 @@
package chat
import (
"context"
"fmt"
"strings"
"sync"
"time"
)
// ChatService provides high-level chat system management
type ChatService struct {
manager *ChatManager
mu sync.RWMutex
}
// NewChatService creates a new chat service instance
func NewChatService(database ChannelDatabase, clientManager ClientManager, playerManager PlayerManager, languageProcessor LanguageProcessor) *ChatService {
return &ChatService{
manager: NewChatManager(database, clientManager, playerManager, languageProcessor),
}
}
// Initialize initializes the chat service and loads world channels
func (cs *ChatService) Initialize(ctx context.Context) error {
cs.mu.Lock()
defer cs.mu.Unlock()
return cs.manager.Initialize(ctx)
}
// ProcessChannelCommand processes chat channel commands (join, leave, create, etc.)
func (cs *ChatService) ProcessChannelCommand(characterID int32, command, channelName string, args ...string) error {
cs.mu.RLock()
defer cs.mu.RUnlock()
switch strings.ToLower(command) {
case "join":
password := ""
if len(args) > 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
}

293
internal/chat/master.go Normal file
View File

@ -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
}

View File

@ -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)
}
})
}
}

View File

@ -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