modernize chat package
This commit is contained in:
parent
1fc81eea95
commit
79ee999150
@ -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
|
|
@ -3,118 +3,179 @@ package chat
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"slices"
|
"slices"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"eq2emu/internal/database"
|
||||||
)
|
)
|
||||||
|
|
||||||
// NewChannel creates a new channel instance
|
// Channel represents a chat channel with membership and message routing capabilities
|
||||||
func NewChannel(name string) *Channel {
|
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{
|
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),
|
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
|
// SetName sets the channel name
|
||||||
func (c *Channel) SetName(name string) {
|
func (c *Channel) SetName(name string) {
|
||||||
c.mu.Lock()
|
c.Name = name
|
||||||
defer c.mu.Unlock()
|
c.Updated = time.Now()
|
||||||
c.name = name
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetPassword sets the channel password
|
// SetPassword sets the channel password
|
||||||
func (c *Channel) SetPassword(password string) {
|
func (c *Channel) SetPassword(password string) {
|
||||||
c.mu.Lock()
|
c.Password = password
|
||||||
defer c.mu.Unlock()
|
c.Updated = time.Now()
|
||||||
c.password = password
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetType sets the channel type
|
// SetType sets the channel type
|
||||||
func (c *Channel) SetType(channelType int) {
|
func (c *Channel) SetType(channelType int) {
|
||||||
c.mu.Lock()
|
c.ChannelType = channelType
|
||||||
defer c.mu.Unlock()
|
c.Updated = time.Now()
|
||||||
c.channelType = channelType
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetLevelRestriction sets the minimum level required to join
|
// SetLevelRestriction sets the minimum level required to join
|
||||||
func (c *Channel) SetLevelRestriction(level int32) {
|
func (c *Channel) SetLevelRestriction(level int32) {
|
||||||
c.mu.Lock()
|
c.LevelRestriction = level
|
||||||
defer c.mu.Unlock()
|
c.Updated = time.Now()
|
||||||
c.levelRestriction = level
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetRacesAllowed sets the race bitmask for allowed races
|
// SetRacesAllowed sets the race bitmask for allowed races
|
||||||
func (c *Channel) SetRacesAllowed(races int32) {
|
func (c *Channel) SetRacesAllowed(races int32) {
|
||||||
c.mu.Lock()
|
c.RaceRestriction = races
|
||||||
defer c.mu.Unlock()
|
c.Updated = time.Now()
|
||||||
c.raceRestriction = races
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetClassesAllowed sets the class bitmask for allowed classes
|
// SetClassesAllowed sets the class bitmask for allowed classes
|
||||||
func (c *Channel) SetClassesAllowed(classes int32) {
|
func (c *Channel) SetClassesAllowed(classes int32) {
|
||||||
c.mu.Lock()
|
c.ClassRestriction = classes
|
||||||
defer c.mu.Unlock()
|
c.Updated = time.Now()
|
||||||
c.classRestriction = classes
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetName returns the channel name
|
// GetName returns the channel name
|
||||||
func (c *Channel) GetName() string {
|
func (c *Channel) GetName() string {
|
||||||
c.mu.RLock()
|
return c.Name
|
||||||
defer c.mu.RUnlock()
|
|
||||||
return c.name
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetType returns the channel type
|
// GetType returns the channel type
|
||||||
func (c *Channel) GetType() int {
|
func (c *Channel) GetType() int {
|
||||||
c.mu.RLock()
|
return c.ChannelType
|
||||||
defer c.mu.RUnlock()
|
|
||||||
return c.channelType
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetNumClients returns the number of clients in the channel
|
// GetNumClients returns the number of clients in the channel
|
||||||
func (c *Channel) GetNumClients() int {
|
func (c *Channel) GetNumClients() int {
|
||||||
c.mu.RLock()
|
|
||||||
defer c.mu.RUnlock()
|
|
||||||
return len(c.members)
|
return len(c.members)
|
||||||
}
|
}
|
||||||
|
|
||||||
// HasPassword returns true if the channel has a password
|
// HasPassword returns true if the channel has a password
|
||||||
func (c *Channel) HasPassword() bool {
|
func (c *Channel) HasPassword() bool {
|
||||||
c.mu.RLock()
|
return c.Password != ""
|
||||||
defer c.mu.RUnlock()
|
|
||||||
return c.password != ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// PasswordMatches checks if the provided password matches the channel password
|
// PasswordMatches checks if the provided password matches the channel password
|
||||||
func (c *Channel) PasswordMatches(password string) bool {
|
func (c *Channel) PasswordMatches(password string) bool {
|
||||||
c.mu.RLock()
|
return c.Password == password
|
||||||
defer c.mu.RUnlock()
|
|
||||||
return c.password == password
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// CanJoinChannelByLevel checks if a player's level meets the channel requirements
|
// CanJoinChannelByLevel checks if a player's level meets the channel requirements
|
||||||
func (c *Channel) CanJoinChannelByLevel(level int32) bool {
|
func (c *Channel) CanJoinChannelByLevel(level int32) bool {
|
||||||
c.mu.RLock()
|
return level >= c.LevelRestriction
|
||||||
defer c.mu.RUnlock()
|
|
||||||
return level >= c.levelRestriction
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// CanJoinChannelByRace checks if a player's race is allowed in the channel
|
// CanJoinChannelByRace checks if a player's race is allowed in the channel
|
||||||
func (c *Channel) CanJoinChannelByRace(raceID int32) bool {
|
func (c *Channel) CanJoinChannelByRace(raceID int32) bool {
|
||||||
c.mu.RLock()
|
return c.RaceRestriction == NoRaceRestriction || (c.RaceRestriction&(1<<raceID)) != 0
|
||||||
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
|
// CanJoinChannelByClass checks if a player's class is allowed in the channel
|
||||||
func (c *Channel) CanJoinChannelByClass(classID int32) bool {
|
func (c *Channel) CanJoinChannelByClass(classID int32) bool {
|
||||||
c.mu.RLock()
|
return c.ClassRestriction == NoClassRestriction || (c.ClassRestriction&(1<<classID)) != 0
|
||||||
defer c.mu.RUnlock()
|
|
||||||
return c.classRestriction == NoClassRestriction || (c.classRestriction&(1<<classID)) != 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsInChannel checks if a character is in the channel
|
// IsInChannel checks if a character is in the channel
|
||||||
func (c *Channel) IsInChannel(characterID int32) bool {
|
func (c *Channel) IsInChannel(characterID int32) bool {
|
||||||
c.mu.RLock()
|
|
||||||
defer c.mu.RUnlock()
|
|
||||||
return c.isInChannel(characterID)
|
return c.isInChannel(characterID)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -125,8 +186,6 @@ func (c *Channel) isInChannel(characterID int32) bool {
|
|||||||
|
|
||||||
// JoinChannel adds a character to the channel
|
// JoinChannel adds a character to the channel
|
||||||
func (c *Channel) JoinChannel(characterID int32) error {
|
func (c *Channel) JoinChannel(characterID int32) error {
|
||||||
c.mu.Lock()
|
|
||||||
defer c.mu.Unlock()
|
|
||||||
return c.joinChannel(characterID)
|
return c.joinChannel(characterID)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -134,7 +193,7 @@ func (c *Channel) JoinChannel(characterID int32) error {
|
|||||||
func (c *Channel) joinChannel(characterID int32) error {
|
func (c *Channel) joinChannel(characterID int32) error {
|
||||||
// Check if already in channel
|
// Check if already in channel
|
||||||
if c.isInChannel(characterID) {
|
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
|
// Add to members list
|
||||||
@ -144,8 +203,6 @@ func (c *Channel) joinChannel(characterID int32) error {
|
|||||||
|
|
||||||
// LeaveChannel removes a character from the channel
|
// LeaveChannel removes a character from the channel
|
||||||
func (c *Channel) LeaveChannel(characterID int32) error {
|
func (c *Channel) LeaveChannel(characterID int32) error {
|
||||||
c.mu.Lock()
|
|
||||||
defer c.mu.Unlock()
|
|
||||||
return c.leaveChannel(characterID)
|
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
|
// GetMembers returns a copy of the current member list
|
||||||
func (c *Channel) GetMembers() []int32 {
|
func (c *Channel) GetMembers() []int32 {
|
||||||
c.mu.RLock()
|
|
||||||
defer c.mu.RUnlock()
|
|
||||||
|
|
||||||
// Return a copy to prevent external modification
|
// Return a copy to prevent external modification
|
||||||
members := make([]int32, len(c.members))
|
members := make([]int32, len(c.members))
|
||||||
copy(members, c.members)
|
copy(members, c.members)
|
||||||
@ -177,44 +231,38 @@ func (c *Channel) GetMembers() []int32 {
|
|||||||
|
|
||||||
// GetChannelInfo returns basic channel information
|
// GetChannelInfo returns basic channel information
|
||||||
func (c *Channel) GetChannelInfo() ChannelInfo {
|
func (c *Channel) GetChannelInfo() ChannelInfo {
|
||||||
c.mu.RLock()
|
|
||||||
defer c.mu.RUnlock()
|
|
||||||
|
|
||||||
return ChannelInfo{
|
return ChannelInfo{
|
||||||
Name: c.name,
|
Name: c.Name,
|
||||||
HasPassword: c.password != "",
|
HasPassword: c.Password != "",
|
||||||
MemberCount: len(c.members),
|
MemberCount: len(c.members),
|
||||||
LevelRestriction: c.levelRestriction,
|
LevelRestriction: c.LevelRestriction,
|
||||||
RaceRestriction: c.raceRestriction,
|
RaceRestriction: c.RaceRestriction,
|
||||||
ClassRestriction: c.classRestriction,
|
ClassRestriction: c.ClassRestriction,
|
||||||
ChannelType: c.channelType,
|
ChannelType: c.ChannelType,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ValidateJoin checks if a character can join the channel based on restrictions
|
// ValidateJoin checks if a character can join the channel based on restrictions
|
||||||
func (c *Channel) ValidateJoin(level, race, class int32, password string) error {
|
func (c *Channel) ValidateJoin(level, race, class int32, password string) error {
|
||||||
c.mu.RLock()
|
|
||||||
defer c.mu.RUnlock()
|
|
||||||
|
|
||||||
// Check password
|
// Check password
|
||||||
if c.password != "" && c.password != password {
|
if c.Password != "" && c.Password != password {
|
||||||
return fmt.Errorf("invalid password for channel %s", c.name)
|
return fmt.Errorf("invalid password for channel %s", c.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check level restriction
|
// Check level restriction
|
||||||
if !c.CanJoinChannelByLevel(level) {
|
if !c.CanJoinChannelByLevel(level) {
|
||||||
return fmt.Errorf("level %d does not meet minimum requirement of %d for channel %s",
|
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
|
// Check race restriction
|
||||||
if !c.CanJoinChannelByRace(race) {
|
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
|
// Check class restriction
|
||||||
if !c.CanJoinChannelByClass(class) {
|
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
|
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
|
// IsEmpty returns true if the channel has no members
|
||||||
func (c *Channel) IsEmpty() bool {
|
func (c *Channel) IsEmpty() bool {
|
||||||
c.mu.RLock()
|
|
||||||
defer c.mu.RUnlock()
|
|
||||||
return len(c.members) == 0
|
return len(c.members) == 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copy creates a deep copy of the channel (useful for serialization or backups)
|
// Copy creates a deep copy of the channel (useful for serialization or backups)
|
||||||
func (c *Channel) Copy() *Channel {
|
func (c *Channel) Copy() *Channel {
|
||||||
c.mu.RLock()
|
|
||||||
defer c.mu.RUnlock()
|
|
||||||
|
|
||||||
newChannel := &Channel{
|
newChannel := &Channel{
|
||||||
name: c.name,
|
ID: c.ID,
|
||||||
password: c.password,
|
Name: c.Name,
|
||||||
channelType: c.channelType,
|
Password: c.Password,
|
||||||
levelRestriction: c.levelRestriction,
|
ChannelType: c.ChannelType,
|
||||||
raceRestriction: c.raceRestriction,
|
LevelRestriction: c.LevelRestriction,
|
||||||
classRestriction: c.classRestriction,
|
RaceRestriction: c.RaceRestriction,
|
||||||
discordEnabled: c.discordEnabled,
|
ClassRestriction: c.ClassRestriction,
|
||||||
created: c.created,
|
DiscordEnabled: c.DiscordEnabled,
|
||||||
|
Created: c.Created,
|
||||||
|
Updated: c.Updated,
|
||||||
|
db: c.db,
|
||||||
|
isNew: true, // Copy is always new
|
||||||
members: make([]int32, len(c.members)),
|
members: make([]int32, len(c.members)),
|
||||||
}
|
}
|
||||||
|
|
||||||
copy(newChannel.members, c.members)
|
copy(newChannel.members, c.members)
|
||||||
return newChannel
|
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
|
||||||
|
}
|
||||||
|
@ -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
|
|
||||||
}
|
|
341
internal/chat/channel_test.go
Normal file
341
internal/chat/channel_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
@ -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
|
|
||||||
}
|
|
@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
@ -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
63
internal/chat/doc.go
Normal 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
|
@ -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
|
|
||||||
}
|
|
@ -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
293
internal/chat/master.go
Normal 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
|
||||||
|
}
|
389
internal/chat/master_test.go
Normal file
389
internal/chat/master_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
@ -1,24 +1,10 @@
|
|||||||
package chat
|
package chat
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"sync"
|
"context"
|
||||||
"time"
|
"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
|
// ChannelMessage represents a message sent to a channel
|
||||||
type ChannelMessage struct {
|
type ChannelMessage struct {
|
||||||
SenderID int32
|
SenderID int32
|
||||||
@ -61,16 +47,68 @@ type ChatChannelData struct {
|
|||||||
RaceRestriction int32
|
RaceRestriction int32
|
||||||
}
|
}
|
||||||
|
|
||||||
// ChatManager manages all chat channels and operations
|
// ChannelDatabase defines database operations for chat channels
|
||||||
type ChatManager struct {
|
type ChannelDatabase interface {
|
||||||
mu sync.RWMutex
|
// LoadWorldChannels retrieves all persistent world channels from database
|
||||||
channels map[string]*Channel
|
LoadWorldChannels(ctx context.Context) ([]ChatChannelData, error)
|
||||||
database ChannelDatabase
|
|
||||||
|
|
||||||
// Integration interfaces
|
// SaveChannel persists a channel to database (world channels only)
|
||||||
clientManager ClientManager
|
SaveChannel(ctx context.Context, channel ChatChannelData) error
|
||||||
playerManager PlayerManager
|
|
||||||
languageProcessor LanguageProcessor
|
// 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
|
// ChatStatistics provides statistics about chat system usage
|
||||||
|
Loading…
x
Reference in New Issue
Block a user