Compare commits
2 Commits
1fc81eea95
...
a47ad4f737
Author | SHA1 | Date | |
---|---|---|---|
a47ad4f737 | |||
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
|
||||||
|
359
internal/classes/class.go
Normal file
359
internal/classes/class.go
Normal file
@ -0,0 +1,359 @@
|
|||||||
|
package classes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// classMap contains the mapping of class names to IDs
|
||||||
|
var classMap = map[string]int8{
|
||||||
|
"COMMONER": ClassCommoner,
|
||||||
|
"FIGHTER": ClassFighter,
|
||||||
|
"WARRIOR": ClassWarrior,
|
||||||
|
"GUARDIAN": ClassGuardian,
|
||||||
|
"BERSERKER": ClassBerserker,
|
||||||
|
"BRAWLER": ClassBrawler,
|
||||||
|
"MONK": ClassMonk,
|
||||||
|
"BRUISER": ClassBruiser,
|
||||||
|
"CRUSADER": ClassCrusader,
|
||||||
|
"SHADOWKNIGHT": ClassShadowknight,
|
||||||
|
"PALADIN": ClassPaladin,
|
||||||
|
"PRIEST": ClassPriest,
|
||||||
|
"CLERIC": ClassCleric,
|
||||||
|
"TEMPLAR": ClassTemplar,
|
||||||
|
"INQUISITOR": ClassInquisitor,
|
||||||
|
"DRUID": ClassDruid,
|
||||||
|
"WARDEN": ClassWarden,
|
||||||
|
"FURY": ClassFury,
|
||||||
|
"SHAMAN": ClassShaman,
|
||||||
|
"MYSTIC": ClassMystic,
|
||||||
|
"DEFILER": ClassDefiler,
|
||||||
|
"MAGE": ClassMage,
|
||||||
|
"SORCERER": ClassSorcerer,
|
||||||
|
"WIZARD": ClassWizard,
|
||||||
|
"WARLOCK": ClassWarlock,
|
||||||
|
"ENCHANTER": ClassEnchanter,
|
||||||
|
"ILLUSIONIST": ClassIllusionist,
|
||||||
|
"COERCER": ClassCoercer,
|
||||||
|
"SUMMONER": ClassSummoner,
|
||||||
|
"CONJUROR": ClassConjuror,
|
||||||
|
"NECROMANCER": ClassNecromancer,
|
||||||
|
"SCOUT": ClassScout,
|
||||||
|
"ROGUE": ClassRogue,
|
||||||
|
"SWASHBUCKLER": ClassSwashbuckler,
|
||||||
|
"BRIGAND": ClassBrigand,
|
||||||
|
"BARD": ClassBard,
|
||||||
|
"TROUBADOR": ClassTroubador,
|
||||||
|
"DIRGE": ClassDirge,
|
||||||
|
"PREDATOR": ClassPredator,
|
||||||
|
"RANGER": ClassRanger,
|
||||||
|
"ASSASSIN": ClassAssassin,
|
||||||
|
"ANIMALIST": ClassAnimalist,
|
||||||
|
"BEASTLORD": ClassBeastlord,
|
||||||
|
"SHAPER": ClassShaper,
|
||||||
|
"CHANNELER": ClassChanneler,
|
||||||
|
"ARTISAN": ClassArtisan,
|
||||||
|
"CRAFTSMAN": ClassCraftsman,
|
||||||
|
"PROVISIONER": ClassProvisioner,
|
||||||
|
"WOODWORKER": ClassWoodworker,
|
||||||
|
"CARPENTER": ClassCarpenter,
|
||||||
|
"OUTFITTER": ClassOutfitter,
|
||||||
|
"ARMORER": ClassArmorer,
|
||||||
|
"WEAPONSMITH": ClassWeaponsmith,
|
||||||
|
"TAILOR": ClassTailor,
|
||||||
|
"SCHOLAR": ClassScholar,
|
||||||
|
"JEWELER": ClassJeweler,
|
||||||
|
"SAGE": ClassSage,
|
||||||
|
"ALCHEMIST": ClassAlchemist,
|
||||||
|
}
|
||||||
|
|
||||||
|
// displayNameMap contains the display names for each class ID
|
||||||
|
var displayNameMap = map[int8]string{
|
||||||
|
ClassCommoner: "Commoner",
|
||||||
|
ClassFighter: "Fighter",
|
||||||
|
ClassWarrior: "Warrior",
|
||||||
|
ClassGuardian: "Guardian",
|
||||||
|
ClassBerserker: "Berserker",
|
||||||
|
ClassBrawler: "Brawler",
|
||||||
|
ClassMonk: "Monk",
|
||||||
|
ClassBruiser: "Bruiser",
|
||||||
|
ClassCrusader: "Crusader",
|
||||||
|
ClassShadowknight: "Shadowknight",
|
||||||
|
ClassPaladin: "Paladin",
|
||||||
|
ClassPriest: "Priest",
|
||||||
|
ClassCleric: "Cleric",
|
||||||
|
ClassTemplar: "Templar",
|
||||||
|
ClassInquisitor: "Inquisitor",
|
||||||
|
ClassDruid: "Druid",
|
||||||
|
ClassWarden: "Warden",
|
||||||
|
ClassFury: "Fury",
|
||||||
|
ClassShaman: "Shaman",
|
||||||
|
ClassMystic: "Mystic",
|
||||||
|
ClassDefiler: "Defiler",
|
||||||
|
ClassMage: "Mage",
|
||||||
|
ClassSorcerer: "Sorcerer",
|
||||||
|
ClassWizard: "Wizard",
|
||||||
|
ClassWarlock: "Warlock",
|
||||||
|
ClassEnchanter: "Enchanter",
|
||||||
|
ClassIllusionist: "Illusionist",
|
||||||
|
ClassCoercer: "Coercer",
|
||||||
|
ClassSummoner: "Summoner",
|
||||||
|
ClassConjuror: "Conjuror",
|
||||||
|
ClassNecromancer: "Necromancer",
|
||||||
|
ClassScout: "Scout",
|
||||||
|
ClassRogue: "Rogue",
|
||||||
|
ClassSwashbuckler: "Swashbuckler",
|
||||||
|
ClassBrigand: "Brigand",
|
||||||
|
ClassBard: "Bard",
|
||||||
|
ClassTroubador: "Troubador",
|
||||||
|
ClassDirge: "Dirge",
|
||||||
|
ClassPredator: "Predator",
|
||||||
|
ClassRanger: "Ranger",
|
||||||
|
ClassAssassin: "Assassin",
|
||||||
|
ClassAnimalist: "Animalist",
|
||||||
|
ClassBeastlord: "Beastlord",
|
||||||
|
ClassShaper: "Shaper",
|
||||||
|
ClassChanneler: "Channeler",
|
||||||
|
ClassArtisan: "Artisan",
|
||||||
|
ClassCraftsman: "Craftsman",
|
||||||
|
ClassProvisioner: "Provisioner",
|
||||||
|
ClassWoodworker: "Woodworker",
|
||||||
|
ClassCarpenter: "Carpenter",
|
||||||
|
ClassOutfitter: "Outfitter",
|
||||||
|
ClassArmorer: "Armorer",
|
||||||
|
ClassWeaponsmith: "Weaponsmith",
|
||||||
|
ClassTailor: "Tailor",
|
||||||
|
ClassScholar: "Scholar",
|
||||||
|
ClassJeweler: "Jeweler",
|
||||||
|
ClassSage: "Sage",
|
||||||
|
ClassAlchemist: "Alchemist",
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetClassID returns the class ID for a given class name
|
||||||
|
// Converted from C++ Classes::GetClassID
|
||||||
|
func GetClassID(name string) int8 {
|
||||||
|
className := strings.ToUpper(strings.TrimSpace(name))
|
||||||
|
if classID, exists := classMap[className]; exists {
|
||||||
|
return classID
|
||||||
|
}
|
||||||
|
return -1 // Invalid class
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetClassName returns the uppercase class name for a given ID
|
||||||
|
// Converted from C++ Classes::GetClassName
|
||||||
|
func GetClassName(classID int8) string {
|
||||||
|
// Search through class map to find the name
|
||||||
|
for name, id := range classMap {
|
||||||
|
if id == classID {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "" // Invalid class ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetClassNameCase returns the friendly display name for a given class ID
|
||||||
|
// Converted from C++ Classes::GetClassNameCase
|
||||||
|
func GetClassNameCase(classID int8) string {
|
||||||
|
if displayName, exists := displayNameMap[classID]; exists {
|
||||||
|
return displayName
|
||||||
|
}
|
||||||
|
return "" // Invalid class ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetBaseClass returns the base class ID for a given class
|
||||||
|
// Converted from C++ Classes::GetBaseClass
|
||||||
|
func GetBaseClass(classID int8) int8 {
|
||||||
|
if classID >= ClassWarrior && classID <= ClassPaladin {
|
||||||
|
return ClassFighter
|
||||||
|
}
|
||||||
|
if (classID >= ClassCleric && classID <= ClassDefiler) || (classID == ClassShaper || classID == ClassChanneler) {
|
||||||
|
return ClassPriest
|
||||||
|
}
|
||||||
|
if classID >= ClassSorcerer && classID <= ClassNecromancer {
|
||||||
|
return ClassMage
|
||||||
|
}
|
||||||
|
if classID >= ClassRogue && classID <= ClassBeastlord {
|
||||||
|
return ClassScout
|
||||||
|
}
|
||||||
|
return ClassCommoner // Default for unknown classes
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSecondaryBaseClass returns the secondary base class ID for specialized classes
|
||||||
|
// Converted from C++ Classes::GetSecondaryBaseClass
|
||||||
|
func GetSecondaryBaseClass(classID int8) int8 {
|
||||||
|
switch classID {
|
||||||
|
case ClassGuardian, ClassBerserker:
|
||||||
|
return ClassWarrior
|
||||||
|
case ClassMonk, ClassBruiser:
|
||||||
|
return ClassBrawler
|
||||||
|
case ClassShadowknight, ClassPaladin:
|
||||||
|
return ClassCrusader
|
||||||
|
case ClassTemplar, ClassInquisitor:
|
||||||
|
return ClassCleric
|
||||||
|
case ClassWarden, ClassFury:
|
||||||
|
return ClassDruid
|
||||||
|
case ClassMystic, ClassDefiler:
|
||||||
|
return ClassShaman
|
||||||
|
case ClassWizard, ClassWarlock:
|
||||||
|
return ClassSorcerer
|
||||||
|
case ClassIllusionist, ClassCoercer:
|
||||||
|
return ClassEnchanter
|
||||||
|
case ClassConjuror, ClassNecromancer:
|
||||||
|
return ClassSummoner
|
||||||
|
case ClassSwashbuckler, ClassBrigand:
|
||||||
|
return ClassRogue
|
||||||
|
case ClassTroubador, ClassDirge:
|
||||||
|
return ClassBard
|
||||||
|
case ClassRanger, ClassAssassin:
|
||||||
|
return ClassPredator
|
||||||
|
case ClassBeastlord:
|
||||||
|
return ClassAnimalist
|
||||||
|
case ClassChanneler:
|
||||||
|
return ClassShaper
|
||||||
|
}
|
||||||
|
return ClassCommoner // Default for unknown classes
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTSBaseClass returns the tradeskill base class ID
|
||||||
|
// Converted from C++ Classes::GetTSBaseClass
|
||||||
|
func GetTSBaseClass(classID int8) int8 {
|
||||||
|
// This function maps tradeskill class IDs to their base tradeskill progression levels
|
||||||
|
// The C++ code uses offset of 42 between adventure and tradeskill class systems
|
||||||
|
if classID+42 >= ClassArtisan {
|
||||||
|
return ClassArtisan - 44 // Returns 1 (base artisan level)
|
||||||
|
}
|
||||||
|
return classID // For non-tradeskill classes, return as-is
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSecondaryTSBaseClass returns the secondary tradeskill base class ID
|
||||||
|
// Converted from C++ Classes::GetSecondaryTSBaseClass
|
||||||
|
func GetSecondaryTSBaseClass(classID int8) int8 {
|
||||||
|
ret := classID + 42
|
||||||
|
|
||||||
|
if ret == ClassArtisan {
|
||||||
|
return ClassArtisan - 44
|
||||||
|
} else if ret >= ClassCraftsman && ret < ClassOutfitter {
|
||||||
|
return ClassCraftsman - 44
|
||||||
|
} else if ret >= ClassOutfitter && ret < ClassScholar {
|
||||||
|
return ClassOutfitter - 44
|
||||||
|
} else if ret >= ClassScholar {
|
||||||
|
return ClassScholar - 44
|
||||||
|
}
|
||||||
|
|
||||||
|
return classID
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsValidClassID checks if a class ID is valid
|
||||||
|
func IsValidClassID(classID int8) bool {
|
||||||
|
return classID >= MinClassID && classID <= MaxClassID
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsAdventureClass checks if a class is an adventure class
|
||||||
|
func IsAdventureClass(classID int8) bool {
|
||||||
|
return classID >= ClassCommoner && classID <= ClassChanneler
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsTradeskillClass checks if a class is a tradeskill class
|
||||||
|
func IsTradeskillClass(classID int8) bool {
|
||||||
|
return classID >= ClassArtisan && classID <= ClassAlchemist
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetClassType returns the type of class (adventure, tradeskill, etc.)
|
||||||
|
func GetClassType(classID int8) string {
|
||||||
|
if IsAdventureClass(classID) {
|
||||||
|
return ClassTypeAdventure
|
||||||
|
}
|
||||||
|
if IsTradeskillClass(classID) {
|
||||||
|
return ClassTypeTradeskill
|
||||||
|
}
|
||||||
|
return ClassTypeSpecial
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAllClasses returns all class IDs and their display names
|
||||||
|
func GetAllClasses() map[int8]string {
|
||||||
|
result := make(map[int8]string)
|
||||||
|
for classID, displayName := range displayNameMap {
|
||||||
|
result[classID] = displayName
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetClassInfo returns comprehensive information about a class
|
||||||
|
func GetClassInfo(classID int8) map[string]any {
|
||||||
|
info := make(map[string]any)
|
||||||
|
|
||||||
|
if !IsValidClassID(classID) {
|
||||||
|
info["valid"] = false
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
|
||||||
|
info["valid"] = true
|
||||||
|
info["class_id"] = classID
|
||||||
|
info["name"] = GetClassName(classID)
|
||||||
|
info["display_name"] = GetClassNameCase(classID)
|
||||||
|
info["base_class"] = GetBaseClass(classID)
|
||||||
|
info["secondary_base_class"] = GetSecondaryBaseClass(classID)
|
||||||
|
info["type"] = GetClassType(classID)
|
||||||
|
info["is_adventure"] = IsAdventureClass(classID)
|
||||||
|
info["is_tradeskill"] = IsTradeskillClass(classID)
|
||||||
|
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetClassHierarchy returns the full class hierarchy for a given class
|
||||||
|
func GetClassHierarchy(classID int8) []int8 {
|
||||||
|
if !IsValidClassID(classID) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
hierarchy := []int8{classID}
|
||||||
|
|
||||||
|
// Add secondary base class if it exists
|
||||||
|
secondary := GetSecondaryBaseClass(classID)
|
||||||
|
if secondary != ClassCommoner && secondary != classID {
|
||||||
|
hierarchy = append(hierarchy, secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add base class
|
||||||
|
base := GetBaseClass(classID)
|
||||||
|
if base != ClassCommoner && base != classID {
|
||||||
|
// Check if base is already in hierarchy (from secondary)
|
||||||
|
found := false
|
||||||
|
for _, id := range hierarchy {
|
||||||
|
if id == base {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
hierarchy = append(hierarchy, base)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always add Commoner as the root
|
||||||
|
if classID != ClassCommoner {
|
||||||
|
hierarchy = append(hierarchy, ClassCommoner)
|
||||||
|
}
|
||||||
|
|
||||||
|
return hierarchy
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsSameArchetype checks if two classes share the same base archetype
|
||||||
|
func IsSameArchetype(classID1, classID2 int8) bool {
|
||||||
|
if !IsValidClassID(classID1) || !IsValidClassID(classID2) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return GetBaseClass(classID1) == GetBaseClass(classID2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetArchetypeClasses returns all classes of a given archetype
|
||||||
|
func GetArchetypeClasses(archetypeID int8) []int8 {
|
||||||
|
var classes []int8
|
||||||
|
|
||||||
|
for classID := MinClassID; classID <= MaxClassID; classID++ {
|
||||||
|
if GetBaseClass(classID) == archetypeID {
|
||||||
|
classes = append(classes, classID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return classes
|
||||||
|
}
|
372
internal/classes/class_test.go
Normal file
372
internal/classes/class_test.go
Normal file
@ -0,0 +1,372 @@
|
|||||||
|
package classes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGetClassID(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
expected int8
|
||||||
|
}{
|
||||||
|
{"Uppercase", "WARRIOR", ClassWarrior},
|
||||||
|
{"Lowercase", "warrior", ClassWarrior},
|
||||||
|
{"Mixed case", "WaRrIoR", ClassWarrior},
|
||||||
|
{"With spaces", " WARRIOR ", ClassWarrior},
|
||||||
|
{"Invalid", "INVALID_CLASS", -1},
|
||||||
|
{"Empty", "", -1},
|
||||||
|
{"Tradeskill", "CARPENTER", ClassCarpenter},
|
||||||
|
{"Special", "CHANNELER", ClassChanneler},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := GetClassID(tt.input)
|
||||||
|
if result != tt.expected {
|
||||||
|
t.Errorf("GetClassID(%q) = %d, want %d", tt.input, result, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetClassName(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
classID int8
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{ClassWarrior, "WARRIOR"},
|
||||||
|
{ClassPriest, "PRIEST"},
|
||||||
|
{ClassCarpenter, "CARPENTER"},
|
||||||
|
{ClassChanneler, "CHANNELER"},
|
||||||
|
{-1, ""},
|
||||||
|
{100, ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run("", func(t *testing.T) {
|
||||||
|
result := GetClassName(tt.classID)
|
||||||
|
if result != tt.expected {
|
||||||
|
t.Errorf("GetClassName(%d) = %q, want %q", tt.classID, result, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetClassNameCase(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
classID int8
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{ClassWarrior, "Warrior"},
|
||||||
|
{ClassShadowknight, "Shadowknight"},
|
||||||
|
{ClassTroubador, "Troubador"},
|
||||||
|
{ClassCarpenter, "Carpenter"},
|
||||||
|
{-1, ""},
|
||||||
|
{100, ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run("", func(t *testing.T) {
|
||||||
|
result := GetClassNameCase(tt.classID)
|
||||||
|
if result != tt.expected {
|
||||||
|
t.Errorf("GetClassNameCase(%d) = %q, want %q", tt.classID, result, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetBaseClass(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
classID int8
|
||||||
|
expected int8
|
||||||
|
}{
|
||||||
|
{ClassGuardian, ClassFighter},
|
||||||
|
{ClassBerserker, ClassFighter},
|
||||||
|
{ClassTemplar, ClassPriest},
|
||||||
|
{ClassWizard, ClassMage},
|
||||||
|
{ClassRanger, ClassScout},
|
||||||
|
{ClassChanneler, ClassPriest},
|
||||||
|
{ClassCommoner, ClassCommoner},
|
||||||
|
{ClassFighter, ClassCommoner},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run("", func(t *testing.T) {
|
||||||
|
result := GetBaseClass(tt.classID)
|
||||||
|
if result != tt.expected {
|
||||||
|
t.Errorf("GetBaseClass(%d) = %d, want %d", tt.classID, result, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetSecondaryBaseClass(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
classID int8
|
||||||
|
expected int8
|
||||||
|
}{
|
||||||
|
{ClassGuardian, ClassWarrior},
|
||||||
|
{ClassBerserker, ClassWarrior},
|
||||||
|
{ClassMonk, ClassBrawler},
|
||||||
|
{ClassTemplar, ClassCleric},
|
||||||
|
{ClassWizard, ClassSorcerer},
|
||||||
|
{ClassRanger, ClassPredator},
|
||||||
|
{ClassBeastlord, ClassAnimalist},
|
||||||
|
{ClassChanneler, ClassShaper},
|
||||||
|
{ClassWarrior, ClassCommoner}, // No secondary
|
||||||
|
{ClassFighter, ClassCommoner}, // No secondary
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run("", func(t *testing.T) {
|
||||||
|
result := GetSecondaryBaseClass(tt.classID)
|
||||||
|
if result != tt.expected {
|
||||||
|
t.Errorf("GetSecondaryBaseClass(%d) = %d, want %d", tt.classID, result, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetTSBaseClass(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
classID int8
|
||||||
|
expected int8
|
||||||
|
}{
|
||||||
|
{3, 1}, // Guardian (3+42=45 >= ClassArtisan) returns ClassArtisan-44 = 1
|
||||||
|
{10, 1}, // Paladin (10+42=52 >= ClassArtisan) returns ClassArtisan-44 = 1
|
||||||
|
{0, 0}, // Commoner (0+42=42 < ClassArtisan) returns 0
|
||||||
|
{1, 1}, // Fighter (1+42=43 < ClassArtisan) returns 1
|
||||||
|
{2, 2}, // Warrior (2+42=44 < ClassArtisan) returns 2
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run("", func(t *testing.T) {
|
||||||
|
result := GetTSBaseClass(tt.classID)
|
||||||
|
if result != tt.expected {
|
||||||
|
t.Errorf("GetTSBaseClass(%d) = %d, want %d", tt.classID, result, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsValidClassID(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
classID int8
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{ClassCommoner, true},
|
||||||
|
{ClassWarrior, true},
|
||||||
|
{ClassAlchemist, true},
|
||||||
|
{-1, false},
|
||||||
|
{100, false},
|
||||||
|
{58, false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run("", func(t *testing.T) {
|
||||||
|
result := IsValidClassID(tt.classID)
|
||||||
|
if result != tt.expected {
|
||||||
|
t.Errorf("IsValidClassID(%d) = %v, want %v", tt.classID, result, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsAdventureClass(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
classID int8
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{ClassCommoner, true},
|
||||||
|
{ClassWarrior, true},
|
||||||
|
{ClassChanneler, true},
|
||||||
|
{ClassArtisan, false},
|
||||||
|
{ClassCarpenter, false},
|
||||||
|
{-1, false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run("", func(t *testing.T) {
|
||||||
|
result := IsAdventureClass(tt.classID)
|
||||||
|
if result != tt.expected {
|
||||||
|
t.Errorf("IsAdventureClass(%d) = %v, want %v", tt.classID, result, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsTradeskillClass(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
classID int8
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{ClassArtisan, true},
|
||||||
|
{ClassCarpenter, true},
|
||||||
|
{ClassAlchemist, true},
|
||||||
|
{ClassWarrior, false},
|
||||||
|
{ClassCommoner, false},
|
||||||
|
{-1, false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run("", func(t *testing.T) {
|
||||||
|
result := IsTradeskillClass(tt.classID)
|
||||||
|
if result != tt.expected {
|
||||||
|
t.Errorf("IsTradeskillClass(%d) = %v, want %v", tt.classID, result, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetClassType(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
classID int8
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{ClassWarrior, ClassTypeAdventure},
|
||||||
|
{ClassChanneler, ClassTypeAdventure},
|
||||||
|
{ClassArtisan, ClassTypeTradeskill},
|
||||||
|
{ClassCarpenter, ClassTypeTradeskill},
|
||||||
|
{-1, ClassTypeSpecial},
|
||||||
|
{100, ClassTypeSpecial},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run("", func(t *testing.T) {
|
||||||
|
result := GetClassType(tt.classID)
|
||||||
|
if result != tt.expected {
|
||||||
|
t.Errorf("GetClassType(%d) = %q, want %q", tt.classID, result, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetAllClasses(t *testing.T) {
|
||||||
|
classes := GetAllClasses()
|
||||||
|
|
||||||
|
// Check we have the right number of classes
|
||||||
|
if len(classes) != 58 {
|
||||||
|
t.Errorf("GetAllClasses() returned %d classes, want 58", len(classes))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check a few specific classes
|
||||||
|
if name, ok := classes[ClassWarrior]; !ok || name != "Warrior" {
|
||||||
|
t.Errorf("GetAllClasses()[ClassWarrior] = %q, %v; want 'Warrior', true", name, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
if name, ok := classes[ClassCarpenter]; !ok || name != "Carpenter" {
|
||||||
|
t.Errorf("GetAllClasses()[ClassCarpenter] = %q, %v; want 'Carpenter', true", name, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetClassInfo(t *testing.T) {
|
||||||
|
// Test valid class
|
||||||
|
info := GetClassInfo(ClassGuardian)
|
||||||
|
if !info["valid"].(bool) {
|
||||||
|
t.Error("GetClassInfo(ClassGuardian) should be valid")
|
||||||
|
}
|
||||||
|
if info["class_id"].(int8) != ClassGuardian {
|
||||||
|
t.Errorf("GetClassInfo(ClassGuardian).class_id = %v, want %d", info["class_id"], ClassGuardian)
|
||||||
|
}
|
||||||
|
if info["name"].(string) != "GUARDIAN" {
|
||||||
|
t.Errorf("GetClassInfo(ClassGuardian).name = %v, want 'GUARDIAN'", info["name"])
|
||||||
|
}
|
||||||
|
if info["display_name"].(string) != "Guardian" {
|
||||||
|
t.Errorf("GetClassInfo(ClassGuardian).display_name = %v, want 'Guardian'", info["display_name"])
|
||||||
|
}
|
||||||
|
if info["base_class"].(int8) != ClassFighter {
|
||||||
|
t.Errorf("GetClassInfo(ClassGuardian).base_class = %v, want %d", info["base_class"], ClassFighter)
|
||||||
|
}
|
||||||
|
if info["secondary_base_class"].(int8) != ClassWarrior {
|
||||||
|
t.Errorf("GetClassInfo(ClassGuardian).secondary_base_class = %v, want %d", info["secondary_base_class"], ClassWarrior)
|
||||||
|
}
|
||||||
|
if !info["is_adventure"].(bool) {
|
||||||
|
t.Error("GetClassInfo(ClassGuardian).is_adventure should be true")
|
||||||
|
}
|
||||||
|
if info["is_tradeskill"].(bool) {
|
||||||
|
t.Error("GetClassInfo(ClassGuardian).is_tradeskill should be false")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test invalid class
|
||||||
|
info = GetClassInfo(-1)
|
||||||
|
if info["valid"].(bool) {
|
||||||
|
t.Error("GetClassInfo(-1) should be invalid")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetClassHierarchy(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
classID int8
|
||||||
|
expected []int8
|
||||||
|
}{
|
||||||
|
{ClassGuardian, []int8{ClassGuardian, ClassWarrior, ClassFighter, ClassCommoner}},
|
||||||
|
{ClassWizard, []int8{ClassWizard, ClassSorcerer, ClassMage, ClassCommoner}},
|
||||||
|
{ClassFighter, []int8{ClassFighter, ClassCommoner}},
|
||||||
|
{ClassCommoner, []int8{ClassCommoner}},
|
||||||
|
{-1, nil},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run("", func(t *testing.T) {
|
||||||
|
result := GetClassHierarchy(tt.classID)
|
||||||
|
if len(result) != len(tt.expected) {
|
||||||
|
t.Errorf("GetClassHierarchy(%d) = %v, want %v", tt.classID, result, tt.expected)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for i, id := range result {
|
||||||
|
if id != tt.expected[i] {
|
||||||
|
t.Errorf("GetClassHierarchy(%d)[%d] = %d, want %d", tt.classID, i, id, tt.expected[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsSameArchetype(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
classID1 int8
|
||||||
|
classID2 int8
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{ClassGuardian, ClassBerserker, true}, // Both Fighter archetype
|
||||||
|
{ClassGuardian, ClassMonk, true}, // Both Fighter archetype
|
||||||
|
{ClassWizard, ClassWarlock, true}, // Both Mage archetype
|
||||||
|
{ClassGuardian, ClassWizard, false}, // Different archetypes
|
||||||
|
{ClassCommoner, ClassCommoner, true}, // Same class
|
||||||
|
{-1, ClassGuardian, false}, // Invalid class
|
||||||
|
{ClassGuardian, -1, false}, // Invalid class
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run("", func(t *testing.T) {
|
||||||
|
result := IsSameArchetype(tt.classID1, tt.classID2)
|
||||||
|
if result != tt.expected {
|
||||||
|
t.Errorf("IsSameArchetype(%d, %d) = %v, want %v", tt.classID1, tt.classID2, result, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetArchetypeClasses(t *testing.T) {
|
||||||
|
// Test Fighter archetype
|
||||||
|
fighterClasses := GetArchetypeClasses(ClassFighter)
|
||||||
|
expectedFighterCount := 9 // Warrior through Paladin (2-10)
|
||||||
|
if len(fighterClasses) != expectedFighterCount {
|
||||||
|
t.Errorf("GetArchetypeClasses(ClassFighter) returned %d classes, want %d", len(fighterClasses), expectedFighterCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify all returned classes are fighters
|
||||||
|
for _, classID := range fighterClasses {
|
||||||
|
if GetBaseClass(classID) != ClassFighter {
|
||||||
|
t.Errorf("GetArchetypeClasses(ClassFighter) returned non-fighter class %d", classID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test Mage archetype
|
||||||
|
mageClasses := GetArchetypeClasses(ClassMage)
|
||||||
|
expectedMageCount := 9 // Sorcerer through Necromancer (22-30)
|
||||||
|
if len(mageClasses) != expectedMageCount {
|
||||||
|
t.Errorf("GetArchetypeClasses(ClassMage) returned %d classes, want %d", len(mageClasses), expectedMageCount)
|
||||||
|
}
|
||||||
|
}
|
@ -1,366 +0,0 @@
|
|||||||
package classes
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Classes manages class information and lookups
|
|
||||||
// Converted from C++ Classes class
|
|
||||||
type Classes struct {
|
|
||||||
// Class name to ID mapping (uppercase keys)
|
|
||||||
classMap map[string]int8
|
|
||||||
|
|
||||||
// ID to display name mapping for friendly names
|
|
||||||
displayNameMap map[int8]string
|
|
||||||
|
|
||||||
// Thread safety
|
|
||||||
mutex sync.RWMutex
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewClasses creates a new classes manager with all EQ2 classes
|
|
||||||
// Converted from C++ Classes::Classes constructor
|
|
||||||
func NewClasses() *Classes {
|
|
||||||
classes := &Classes{
|
|
||||||
classMap: make(map[string]int8),
|
|
||||||
displayNameMap: make(map[int8]string),
|
|
||||||
}
|
|
||||||
|
|
||||||
classes.initializeClasses()
|
|
||||||
return classes
|
|
||||||
}
|
|
||||||
|
|
||||||
// initializeClasses sets up all class mappings
|
|
||||||
func (c *Classes) initializeClasses() {
|
|
||||||
// Initialize class name to ID mappings (from C++ constructor)
|
|
||||||
c.classMap[ClassNameCommoner] = ClassCommoner
|
|
||||||
c.classMap[ClassNameFighter] = ClassFighter
|
|
||||||
c.classMap[ClassNameWarrior] = ClassWarrior
|
|
||||||
c.classMap[ClassNameGuardian] = ClassGuardian
|
|
||||||
c.classMap[ClassNameBerserker] = ClassBerserker
|
|
||||||
c.classMap[ClassNameBrawler] = ClassBrawler
|
|
||||||
c.classMap[ClassNameMonk] = ClassMonk
|
|
||||||
c.classMap[ClassNameBruiser] = ClassBruiser
|
|
||||||
c.classMap[ClassNameCrusader] = ClassCrusader
|
|
||||||
c.classMap[ClassNameShadowknight] = ClassShadowknight
|
|
||||||
c.classMap[ClassNamePaladin] = ClassPaladin
|
|
||||||
c.classMap[ClassNamePriest] = ClassPriest
|
|
||||||
c.classMap[ClassNameCleric] = ClassCleric
|
|
||||||
c.classMap[ClassNameTemplar] = ClassTemplar
|
|
||||||
c.classMap[ClassNameInquisitor] = ClassInquisitor
|
|
||||||
c.classMap[ClassNameDruid] = ClassDruid
|
|
||||||
c.classMap[ClassNameWarden] = ClassWarden
|
|
||||||
c.classMap[ClassNameFury] = ClassFury
|
|
||||||
c.classMap[ClassNameShaman] = ClassShaman
|
|
||||||
c.classMap[ClassNameMystic] = ClassMystic
|
|
||||||
c.classMap[ClassNameDefiler] = ClassDefiler
|
|
||||||
c.classMap[ClassNameMage] = ClassMage
|
|
||||||
c.classMap[ClassNameSorcerer] = ClassSorcerer
|
|
||||||
c.classMap[ClassNameWizard] = ClassWizard
|
|
||||||
c.classMap[ClassNameWarlock] = ClassWarlock
|
|
||||||
c.classMap[ClassNameEnchanter] = ClassEnchanter
|
|
||||||
c.classMap[ClassNameIllusionist] = ClassIllusionist
|
|
||||||
c.classMap[ClassNameCoercer] = ClassCoercer
|
|
||||||
c.classMap[ClassNameSummoner] = ClassSummoner
|
|
||||||
c.classMap[ClassNameConjuror] = ClassConjuror
|
|
||||||
c.classMap[ClassNameNecromancer] = ClassNecromancer
|
|
||||||
c.classMap[ClassNameScout] = ClassScout
|
|
||||||
c.classMap[ClassNameRogue] = ClassRogue
|
|
||||||
c.classMap[ClassNameSwashbuckler] = ClassSwashbuckler
|
|
||||||
c.classMap[ClassNameBrigand] = ClassBrigand
|
|
||||||
c.classMap[ClassNameBard] = ClassBard
|
|
||||||
c.classMap[ClassNameTroubador] = ClassTroubador
|
|
||||||
c.classMap[ClassNameDirge] = ClassDirge
|
|
||||||
c.classMap[ClassNamePredator] = ClassPredator
|
|
||||||
c.classMap[ClassNameRanger] = ClassRanger
|
|
||||||
c.classMap[ClassNameAssassin] = ClassAssassin
|
|
||||||
c.classMap[ClassNameAnimalist] = ClassAnimalist
|
|
||||||
c.classMap[ClassNameBeastlord] = ClassBeastlord
|
|
||||||
c.classMap[ClassNameShaper] = ClassShaper
|
|
||||||
c.classMap[ClassNameChanneler] = ClassChanneler
|
|
||||||
c.classMap[ClassNameArtisan] = ClassArtisan
|
|
||||||
c.classMap[ClassNameCraftsman] = ClassCraftsman
|
|
||||||
c.classMap[ClassNameProvisioner] = ClassProvisioner
|
|
||||||
c.classMap[ClassNameWoodworker] = ClassWoodworker
|
|
||||||
c.classMap[ClassNameCarpenter] = ClassCarpenter
|
|
||||||
c.classMap[ClassNameOutfitter] = ClassOutfitter
|
|
||||||
c.classMap[ClassNameArmorer] = ClassArmorer
|
|
||||||
c.classMap[ClassNameWeaponsmith] = ClassWeaponsmith
|
|
||||||
c.classMap[ClassNameTailor] = ClassTailor
|
|
||||||
c.classMap[ClassNameScholar] = ClassScholar
|
|
||||||
c.classMap[ClassNameJeweler] = ClassJeweler
|
|
||||||
c.classMap[ClassNameSage] = ClassSage
|
|
||||||
c.classMap[ClassNameAlchemist] = ClassAlchemist
|
|
||||||
|
|
||||||
// Initialize display names
|
|
||||||
c.displayNameMap[ClassCommoner] = DisplayNameCommoner
|
|
||||||
c.displayNameMap[ClassFighter] = DisplayNameFighter
|
|
||||||
c.displayNameMap[ClassWarrior] = DisplayNameWarrior
|
|
||||||
c.displayNameMap[ClassGuardian] = DisplayNameGuardian
|
|
||||||
c.displayNameMap[ClassBerserker] = DisplayNameBerserker
|
|
||||||
c.displayNameMap[ClassBrawler] = DisplayNameBrawler
|
|
||||||
c.displayNameMap[ClassMonk] = DisplayNameMonk
|
|
||||||
c.displayNameMap[ClassBruiser] = DisplayNameBruiser
|
|
||||||
c.displayNameMap[ClassCrusader] = DisplayNameCrusader
|
|
||||||
c.displayNameMap[ClassShadowknight] = DisplayNameShadowknight
|
|
||||||
c.displayNameMap[ClassPaladin] = DisplayNamePaladin
|
|
||||||
c.displayNameMap[ClassPriest] = DisplayNamePriest
|
|
||||||
c.displayNameMap[ClassCleric] = DisplayNameCleric
|
|
||||||
c.displayNameMap[ClassTemplar] = DisplayNameTemplar
|
|
||||||
c.displayNameMap[ClassInquisitor] = DisplayNameInquisitor
|
|
||||||
c.displayNameMap[ClassDruid] = DisplayNameDruid
|
|
||||||
c.displayNameMap[ClassWarden] = DisplayNameWarden
|
|
||||||
c.displayNameMap[ClassFury] = DisplayNameFury
|
|
||||||
c.displayNameMap[ClassShaman] = DisplayNameShaman
|
|
||||||
c.displayNameMap[ClassMystic] = DisplayNameMystic
|
|
||||||
c.displayNameMap[ClassDefiler] = DisplayNameDefiler
|
|
||||||
c.displayNameMap[ClassMage] = DisplayNameMage
|
|
||||||
c.displayNameMap[ClassSorcerer] = DisplayNameSorcerer
|
|
||||||
c.displayNameMap[ClassWizard] = DisplayNameWizard
|
|
||||||
c.displayNameMap[ClassWarlock] = DisplayNameWarlock
|
|
||||||
c.displayNameMap[ClassEnchanter] = DisplayNameEnchanter
|
|
||||||
c.displayNameMap[ClassIllusionist] = DisplayNameIllusionist
|
|
||||||
c.displayNameMap[ClassCoercer] = DisplayNameCoercer
|
|
||||||
c.displayNameMap[ClassSummoner] = DisplayNameSummoner
|
|
||||||
c.displayNameMap[ClassConjuror] = DisplayNameConjuror
|
|
||||||
c.displayNameMap[ClassNecromancer] = DisplayNameNecromancer
|
|
||||||
c.displayNameMap[ClassScout] = DisplayNameScout
|
|
||||||
c.displayNameMap[ClassRogue] = DisplayNameRogue
|
|
||||||
c.displayNameMap[ClassSwashbuckler] = DisplayNameSwashbuckler
|
|
||||||
c.displayNameMap[ClassBrigand] = DisplayNameBrigand
|
|
||||||
c.displayNameMap[ClassBard] = DisplayNameBard
|
|
||||||
c.displayNameMap[ClassTroubador] = DisplayNameTroubador
|
|
||||||
c.displayNameMap[ClassDirge] = DisplayNameDirge
|
|
||||||
c.displayNameMap[ClassPredator] = DisplayNamePredator
|
|
||||||
c.displayNameMap[ClassRanger] = DisplayNameRanger
|
|
||||||
c.displayNameMap[ClassAssassin] = DisplayNameAssassin
|
|
||||||
c.displayNameMap[ClassAnimalist] = DisplayNameAnimalist
|
|
||||||
c.displayNameMap[ClassBeastlord] = DisplayNameBeastlord
|
|
||||||
c.displayNameMap[ClassShaper] = DisplayNameShaper
|
|
||||||
c.displayNameMap[ClassChanneler] = DisplayNameChanneler
|
|
||||||
c.displayNameMap[ClassArtisan] = DisplayNameArtisan
|
|
||||||
c.displayNameMap[ClassCraftsman] = DisplayNameCraftsman
|
|
||||||
c.displayNameMap[ClassProvisioner] = DisplayNameProvisioner
|
|
||||||
c.displayNameMap[ClassWoodworker] = DisplayNameWoodworker
|
|
||||||
c.displayNameMap[ClassCarpenter] = DisplayNameCarpenter
|
|
||||||
c.displayNameMap[ClassOutfitter] = DisplayNameOutfitter
|
|
||||||
c.displayNameMap[ClassArmorer] = DisplayNameArmorer
|
|
||||||
c.displayNameMap[ClassWeaponsmith] = DisplayNameWeaponsmith
|
|
||||||
c.displayNameMap[ClassTailor] = DisplayNameTailor
|
|
||||||
c.displayNameMap[ClassScholar] = DisplayNameScholar
|
|
||||||
c.displayNameMap[ClassJeweler] = DisplayNameJeweler
|
|
||||||
c.displayNameMap[ClassSage] = DisplayNameSage
|
|
||||||
c.displayNameMap[ClassAlchemist] = DisplayNameAlchemist
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetClassID returns the class ID for a given class name
|
|
||||||
// Converted from C++ Classes::GetClassID
|
|
||||||
func (c *Classes) GetClassID(name string) int8 {
|
|
||||||
c.mutex.RLock()
|
|
||||||
defer c.mutex.RUnlock()
|
|
||||||
|
|
||||||
className := strings.ToUpper(strings.TrimSpace(name))
|
|
||||||
if classID, exists := c.classMap[className]; exists {
|
|
||||||
return classID
|
|
||||||
}
|
|
||||||
|
|
||||||
return -1 // Invalid class
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetClassName returns the uppercase class name for a given ID
|
|
||||||
// Converted from C++ Classes::GetClassName
|
|
||||||
func (c *Classes) GetClassName(classID int8) string {
|
|
||||||
c.mutex.RLock()
|
|
||||||
defer c.mutex.RUnlock()
|
|
||||||
|
|
||||||
// Search through class map to find the name
|
|
||||||
for name, id := range c.classMap {
|
|
||||||
if id == classID {
|
|
||||||
return name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return "" // Invalid class ID
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetClassNameCase returns the friendly display name for a given class ID
|
|
||||||
// Converted from C++ Classes::GetClassNameCase
|
|
||||||
func (c *Classes) GetClassNameCase(classID int8) string {
|
|
||||||
c.mutex.RLock()
|
|
||||||
defer c.mutex.RUnlock()
|
|
||||||
|
|
||||||
if displayName, exists := c.displayNameMap[classID]; exists {
|
|
||||||
return displayName
|
|
||||||
}
|
|
||||||
|
|
||||||
return "" // Invalid class ID
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetBaseClass returns the base class ID for a given class
|
|
||||||
// Converted from C++ Classes::GetBaseClass
|
|
||||||
func (c *Classes) GetBaseClass(classID int8) int8 {
|
|
||||||
if classID >= ClassWarrior && classID <= ClassPaladin {
|
|
||||||
return ClassFighter
|
|
||||||
}
|
|
||||||
if (classID >= ClassCleric && classID <= ClassDefiler) || (classID == ClassShaper || classID == ClassChanneler) {
|
|
||||||
return ClassPriest
|
|
||||||
}
|
|
||||||
if classID >= ClassSorcerer && classID <= ClassNecromancer {
|
|
||||||
return ClassMage
|
|
||||||
}
|
|
||||||
if classID >= ClassRogue && classID <= ClassBeastlord {
|
|
||||||
return ClassScout
|
|
||||||
}
|
|
||||||
|
|
||||||
return ClassCommoner // Default for unknown classes
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetSecondaryBaseClass returns the secondary base class ID for specialized classes
|
|
||||||
// Converted from C++ Classes::GetSecondaryBaseClass
|
|
||||||
func (c *Classes) GetSecondaryBaseClass(classID int8) int8 {
|
|
||||||
switch classID {
|
|
||||||
case ClassGuardian, ClassBerserker:
|
|
||||||
return ClassWarrior
|
|
||||||
case ClassMonk, ClassBruiser:
|
|
||||||
return ClassBrawler
|
|
||||||
case ClassShadowknight, ClassPaladin:
|
|
||||||
return ClassCrusader
|
|
||||||
case ClassTemplar, ClassInquisitor:
|
|
||||||
return ClassCleric
|
|
||||||
case ClassWarden, ClassFury:
|
|
||||||
return ClassDruid
|
|
||||||
case ClassMystic, ClassDefiler:
|
|
||||||
return ClassShaman
|
|
||||||
case ClassWizard, ClassWarlock:
|
|
||||||
return ClassSorcerer
|
|
||||||
case ClassIllusionist, ClassCoercer:
|
|
||||||
return ClassEnchanter
|
|
||||||
case ClassConjuror, ClassNecromancer:
|
|
||||||
return ClassSummoner
|
|
||||||
case ClassSwashbuckler, ClassBrigand:
|
|
||||||
return ClassRogue
|
|
||||||
case ClassTroubador, ClassDirge:
|
|
||||||
return ClassBard
|
|
||||||
case ClassRanger, ClassAssassin:
|
|
||||||
return ClassPredator
|
|
||||||
case ClassBeastlord:
|
|
||||||
return ClassAnimalist
|
|
||||||
case ClassChanneler:
|
|
||||||
return ClassShaper
|
|
||||||
}
|
|
||||||
|
|
||||||
return ClassCommoner // Default for unknown classes
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetTSBaseClass returns the tradeskill base class ID
|
|
||||||
// Converted from C++ Classes::GetTSBaseClass
|
|
||||||
func (c *Classes) GetTSBaseClass(classID int8) int8 {
|
|
||||||
if classID+42 >= ClassArtisan {
|
|
||||||
return ClassArtisan - 44
|
|
||||||
}
|
|
||||||
|
|
||||||
return classID
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetSecondaryTSBaseClass returns the secondary tradeskill base class ID
|
|
||||||
// Converted from C++ Classes::GetSecondaryTSBaseClass
|
|
||||||
func (c *Classes) GetSecondaryTSBaseClass(classID int8) int8 {
|
|
||||||
ret := classID + 42
|
|
||||||
|
|
||||||
if ret == ClassArtisan {
|
|
||||||
return ClassArtisan - 44
|
|
||||||
} else if ret >= ClassCraftsman && ret < ClassOutfitter {
|
|
||||||
return ClassCraftsman - 44
|
|
||||||
} else if ret >= ClassOutfitter && ret < ClassScholar {
|
|
||||||
return ClassOutfitter - 44
|
|
||||||
} else if ret >= ClassScholar {
|
|
||||||
return ClassScholar - 44
|
|
||||||
}
|
|
||||||
|
|
||||||
return classID
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsValidClassID checks if a class ID is valid
|
|
||||||
func (c *Classes) IsValidClassID(classID int8) bool {
|
|
||||||
return classID >= MinClassID && classID <= MaxClassID
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetAllClasses returns all class IDs and their display names
|
|
||||||
func (c *Classes) GetAllClasses() map[int8]string {
|
|
||||||
c.mutex.RLock()
|
|
||||||
defer c.mutex.RUnlock()
|
|
||||||
|
|
||||||
result := make(map[int8]string)
|
|
||||||
for classID, displayName := range c.displayNameMap {
|
|
||||||
result[classID] = displayName
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsAdventureClass checks if a class is an adventure class
|
|
||||||
func (c *Classes) IsAdventureClass(classID int8) bool {
|
|
||||||
return classID >= ClassCommoner && classID <= ClassChanneler
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsTradeskillClass checks if a class is a tradeskill class
|
|
||||||
func (c *Classes) IsTradeskillClass(classID int8) bool {
|
|
||||||
return classID >= ClassArtisan && classID <= ClassAlchemist
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetClassType returns the type of class (adventure, tradeskill, etc.)
|
|
||||||
func (c *Classes) GetClassType(classID int8) string {
|
|
||||||
if c.IsAdventureClass(classID) {
|
|
||||||
return ClassTypeAdventure
|
|
||||||
}
|
|
||||||
if c.IsTradeskillClass(classID) {
|
|
||||||
return ClassTypeTradeskill
|
|
||||||
}
|
|
||||||
|
|
||||||
return ClassTypeSpecial
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetClassCount returns the total number of classes
|
|
||||||
func (c *Classes) GetClassCount() int {
|
|
||||||
c.mutex.RLock()
|
|
||||||
defer c.mutex.RUnlock()
|
|
||||||
|
|
||||||
return len(c.displayNameMap)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetClassInfo returns comprehensive information about a class
|
|
||||||
func (c *Classes) GetClassInfo(classID int8) map[string]any {
|
|
||||||
c.mutex.RLock()
|
|
||||||
defer c.mutex.RUnlock()
|
|
||||||
|
|
||||||
info := make(map[string]any)
|
|
||||||
|
|
||||||
if !c.IsValidClassID(classID) {
|
|
||||||
info["valid"] = false
|
|
||||||
return info
|
|
||||||
}
|
|
||||||
|
|
||||||
info["valid"] = true
|
|
||||||
info["class_id"] = classID
|
|
||||||
info["name"] = c.GetClassName(classID)
|
|
||||||
info["display_name"] = c.GetClassNameCase(classID)
|
|
||||||
info["base_class"] = c.GetBaseClass(classID)
|
|
||||||
info["secondary_base_class"] = c.GetSecondaryBaseClass(classID)
|
|
||||||
info["type"] = c.GetClassType(classID)
|
|
||||||
info["is_adventure"] = c.IsAdventureClass(classID)
|
|
||||||
info["is_tradeskill"] = c.IsTradeskillClass(classID)
|
|
||||||
|
|
||||||
return info
|
|
||||||
}
|
|
||||||
|
|
||||||
// Global classes instance
|
|
||||||
var globalClasses *Classes
|
|
||||||
var initClassesOnce sync.Once
|
|
||||||
|
|
||||||
// GetGlobalClasses returns the global classes manager (singleton)
|
|
||||||
func GetGlobalClasses() *Classes {
|
|
||||||
initClassesOnce.Do(func() {
|
|
||||||
globalClasses = NewClasses()
|
|
||||||
})
|
|
||||||
return globalClasses
|
|
||||||
}
|
|
File diff suppressed because it is too large
Load Diff
@ -101,126 +101,3 @@ const (
|
|||||||
ClassTypeSpecial = "special"
|
ClassTypeSpecial = "special"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Class name constants for lookup (uppercase keys from C++)
|
|
||||||
const (
|
|
||||||
ClassNameCommoner = "COMMONER"
|
|
||||||
ClassNameFighter = "FIGHTER"
|
|
||||||
ClassNameWarrior = "WARRIOR"
|
|
||||||
ClassNameGuardian = "GUARDIAN"
|
|
||||||
ClassNameBerserker = "BERSERKER"
|
|
||||||
ClassNameBrawler = "BRAWLER"
|
|
||||||
ClassNameMonk = "MONK"
|
|
||||||
ClassNameBruiser = "BRUISER"
|
|
||||||
ClassNameCrusader = "CRUSADER"
|
|
||||||
ClassNameShadowknight = "SHADOWKNIGHT"
|
|
||||||
ClassNamePaladin = "PALADIN"
|
|
||||||
ClassNamePriest = "PRIEST"
|
|
||||||
ClassNameCleric = "CLERIC"
|
|
||||||
ClassNameTemplar = "TEMPLAR"
|
|
||||||
ClassNameInquisitor = "INQUISITOR"
|
|
||||||
ClassNameDruid = "DRUID"
|
|
||||||
ClassNameWarden = "WARDEN"
|
|
||||||
ClassNameFury = "FURY"
|
|
||||||
ClassNameShaman = "SHAMAN"
|
|
||||||
ClassNameMystic = "MYSTIC"
|
|
||||||
ClassNameDefiler = "DEFILER"
|
|
||||||
ClassNameMage = "MAGE"
|
|
||||||
ClassNameSorcerer = "SORCERER"
|
|
||||||
ClassNameWizard = "WIZARD"
|
|
||||||
ClassNameWarlock = "WARLOCK"
|
|
||||||
ClassNameEnchanter = "ENCHANTER"
|
|
||||||
ClassNameIllusionist = "ILLUSIONIST"
|
|
||||||
ClassNameCoercer = "COERCER"
|
|
||||||
ClassNameSummoner = "SUMMONER"
|
|
||||||
ClassNameConjuror = "CONJUROR"
|
|
||||||
ClassNameNecromancer = "NECROMANCER"
|
|
||||||
ClassNameScout = "SCOUT"
|
|
||||||
ClassNameRogue = "ROGUE"
|
|
||||||
ClassNameSwashbuckler = "SWASHBUCKLER"
|
|
||||||
ClassNameBrigand = "BRIGAND"
|
|
||||||
ClassNameBard = "BARD"
|
|
||||||
ClassNameTroubador = "TROUBADOR"
|
|
||||||
ClassNameDirge = "DIRGE"
|
|
||||||
ClassNamePredator = "PREDATOR"
|
|
||||||
ClassNameRanger = "RANGER"
|
|
||||||
ClassNameAssassin = "ASSASSIN"
|
|
||||||
ClassNameAnimalist = "ANIMALIST"
|
|
||||||
ClassNameBeastlord = "BEASTLORD"
|
|
||||||
ClassNameShaper = "SHAPER"
|
|
||||||
ClassNameChanneler = "CHANNELER"
|
|
||||||
ClassNameArtisan = "ARTISAN"
|
|
||||||
ClassNameCraftsman = "CRAFTSMAN"
|
|
||||||
ClassNameProvisioner = "PROVISIONER"
|
|
||||||
ClassNameWoodworker = "WOODWORKER"
|
|
||||||
ClassNameCarpenter = "CARPENTER"
|
|
||||||
ClassNameOutfitter = "OUTFITTER"
|
|
||||||
ClassNameArmorer = "ARMORER"
|
|
||||||
ClassNameWeaponsmith = "WEAPONSMITH"
|
|
||||||
ClassNameTailor = "TAILOR"
|
|
||||||
ClassNameScholar = "SCHOLAR"
|
|
||||||
ClassNameJeweler = "JEWELER"
|
|
||||||
ClassNameSage = "SAGE"
|
|
||||||
ClassNameAlchemist = "ALCHEMIST"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Class display names (proper case)
|
|
||||||
const (
|
|
||||||
DisplayNameCommoner = "Commoner"
|
|
||||||
DisplayNameFighter = "Fighter"
|
|
||||||
DisplayNameWarrior = "Warrior"
|
|
||||||
DisplayNameGuardian = "Guardian"
|
|
||||||
DisplayNameBerserker = "Berserker"
|
|
||||||
DisplayNameBrawler = "Brawler"
|
|
||||||
DisplayNameMonk = "Monk"
|
|
||||||
DisplayNameBruiser = "Bruiser"
|
|
||||||
DisplayNameCrusader = "Crusader"
|
|
||||||
DisplayNameShadowknight = "Shadowknight"
|
|
||||||
DisplayNamePaladin = "Paladin"
|
|
||||||
DisplayNamePriest = "Priest"
|
|
||||||
DisplayNameCleric = "Cleric"
|
|
||||||
DisplayNameTemplar = "Templar"
|
|
||||||
DisplayNameInquisitor = "Inquisitor"
|
|
||||||
DisplayNameDruid = "Druid"
|
|
||||||
DisplayNameWarden = "Warden"
|
|
||||||
DisplayNameFury = "Fury"
|
|
||||||
DisplayNameShaman = "Shaman"
|
|
||||||
DisplayNameMystic = "Mystic"
|
|
||||||
DisplayNameDefiler = "Defiler"
|
|
||||||
DisplayNameMage = "Mage"
|
|
||||||
DisplayNameSorcerer = "Sorcerer"
|
|
||||||
DisplayNameWizard = "Wizard"
|
|
||||||
DisplayNameWarlock = "Warlock"
|
|
||||||
DisplayNameEnchanter = "Enchanter"
|
|
||||||
DisplayNameIllusionist = "Illusionist"
|
|
||||||
DisplayNameCoercer = "Coercer"
|
|
||||||
DisplayNameSummoner = "Summoner"
|
|
||||||
DisplayNameConjuror = "Conjuror"
|
|
||||||
DisplayNameNecromancer = "Necromancer"
|
|
||||||
DisplayNameScout = "Scout"
|
|
||||||
DisplayNameRogue = "Rogue"
|
|
||||||
DisplayNameSwashbuckler = "Swashbuckler"
|
|
||||||
DisplayNameBrigand = "Brigand"
|
|
||||||
DisplayNameBard = "Bard"
|
|
||||||
DisplayNameTroubador = "Troubador"
|
|
||||||
DisplayNameDirge = "Dirge"
|
|
||||||
DisplayNamePredator = "Predator"
|
|
||||||
DisplayNameRanger = "Ranger"
|
|
||||||
DisplayNameAssassin = "Assassin"
|
|
||||||
DisplayNameAnimalist = "Animalist"
|
|
||||||
DisplayNameBeastlord = "Beastlord"
|
|
||||||
DisplayNameShaper = "Shaper"
|
|
||||||
DisplayNameChanneler = "Channeler"
|
|
||||||
DisplayNameArtisan = "Artisan"
|
|
||||||
DisplayNameCraftsman = "Craftsman"
|
|
||||||
DisplayNameProvisioner = "Provisioner"
|
|
||||||
DisplayNameWoodworker = "Woodworker"
|
|
||||||
DisplayNameCarpenter = "Carpenter"
|
|
||||||
DisplayNameOutfitter = "Outfitter"
|
|
||||||
DisplayNameArmorer = "Armorer"
|
|
||||||
DisplayNameWeaponsmith = "Weaponsmith"
|
|
||||||
DisplayNameTailor = "Tailor"
|
|
||||||
DisplayNameScholar = "Scholar"
|
|
||||||
DisplayNameJeweler = "Jeweler"
|
|
||||||
DisplayNameSage = "Sage"
|
|
||||||
DisplayNameAlchemist = "Alchemist"
|
|
||||||
)
|
|
||||||
|
47
internal/classes/doc.go
Normal file
47
internal/classes/doc.go
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
// Package classes provides EverQuest II class definitions and lookup functions.
|
||||||
|
//
|
||||||
|
// This package manages all adventure and tradeskill class information including
|
||||||
|
// class IDs, names, hierarchies, and relationships. It provides static lookups
|
||||||
|
// for class data without requiring database access.
|
||||||
|
//
|
||||||
|
// Basic Usage:
|
||||||
|
//
|
||||||
|
// // Get class ID from name
|
||||||
|
// classID := classes.GetClassID("WARRIOR")
|
||||||
|
//
|
||||||
|
// // Get class display name
|
||||||
|
// name := classes.GetClassNameCase(classes.ClassWarrior)
|
||||||
|
//
|
||||||
|
// // Check class hierarchy
|
||||||
|
// baseClass := classes.GetBaseClass(classes.ClassGuardian) // Returns ClassFighter
|
||||||
|
// secondary := classes.GetSecondaryBaseClass(classes.ClassGuardian) // Returns ClassWarrior
|
||||||
|
//
|
||||||
|
// Class Hierarchy:
|
||||||
|
//
|
||||||
|
// Fighter -> Warrior -> Guardian/Berserker
|
||||||
|
// -> Brawler -> Monk/Bruiser
|
||||||
|
// -> Crusader -> Shadowknight/Paladin
|
||||||
|
//
|
||||||
|
// Priest -> Cleric -> Templar/Inquisitor
|
||||||
|
// -> Druid -> Warden/Fury
|
||||||
|
// -> Shaman -> Mystic/Defiler
|
||||||
|
//
|
||||||
|
// Mage -> Sorcerer -> Wizard/Warlock
|
||||||
|
// -> Enchanter -> Illusionist/Coercer
|
||||||
|
// -> Summoner -> Conjuror/Necromancer
|
||||||
|
//
|
||||||
|
// Scout -> Rogue -> Swashbuckler/Brigand
|
||||||
|
// -> Bard -> Troubador/Dirge
|
||||||
|
// -> Predator -> Ranger/Assassin
|
||||||
|
// -> Animalist -> Beastlord
|
||||||
|
//
|
||||||
|
// Tradeskill Classes:
|
||||||
|
//
|
||||||
|
// Artisan -> Craftsman -> Provisioner
|
||||||
|
// -> Woodworker -> Carpenter
|
||||||
|
// -> Outfitter -> Armorer/Weaponsmith/Tailor
|
||||||
|
// -> Scholar -> Jeweler/Sage/Alchemist
|
||||||
|
//
|
||||||
|
// The package includes all 58 class definitions from EverQuest II including
|
||||||
|
// adventure classes (0-44) and tradeskill classes (45-57).
|
||||||
|
package classes
|
@ -1,352 +0,0 @@
|
|||||||
package classes
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ClassAware interface for entities that have class information
|
|
||||||
type ClassAware interface {
|
|
||||||
GetClass() int8
|
|
||||||
SetClass(int8)
|
|
||||||
}
|
|
||||||
|
|
||||||
// EntityWithClass interface extends ClassAware with additional entity properties
|
|
||||||
type EntityWithClass interface {
|
|
||||||
ClassAware
|
|
||||||
GetID() int32
|
|
||||||
GetName() string
|
|
||||||
GetLevel() int8
|
|
||||||
}
|
|
||||||
|
|
||||||
// ClassIntegration provides class-related functionality for other systems
|
|
||||||
type ClassIntegration struct {
|
|
||||||
classes *Classes
|
|
||||||
utils *ClassUtils
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewClassIntegration creates a new class integration helper
|
|
||||||
func NewClassIntegration() *ClassIntegration {
|
|
||||||
return &ClassIntegration{
|
|
||||||
classes: GetGlobalClasses(),
|
|
||||||
utils: NewClassUtils(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ValidateEntityClass validates an entity's class and provides detailed information
|
|
||||||
func (ci *ClassIntegration) ValidateEntityClass(entity ClassAware) (bool, string, map[string]any) {
|
|
||||||
classID := entity.GetClass()
|
|
||||||
|
|
||||||
if !ci.classes.IsValidClassID(classID) {
|
|
||||||
return false, fmt.Sprintf("Invalid class ID: %d", classID), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
classInfo := ci.classes.GetClassInfo(classID)
|
|
||||||
return true, "Valid class", classInfo
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetEntityClassInfo returns comprehensive class information for an entity
|
|
||||||
func (ci *ClassIntegration) GetEntityClassInfo(entity EntityWithClass) map[string]any {
|
|
||||||
info := make(map[string]any)
|
|
||||||
|
|
||||||
// Basic entity info
|
|
||||||
info["entity_id"] = entity.GetID()
|
|
||||||
info["entity_name"] = entity.GetName()
|
|
||||||
info["entity_level"] = entity.GetLevel()
|
|
||||||
|
|
||||||
// Class information
|
|
||||||
classID := entity.GetClass()
|
|
||||||
classInfo := ci.classes.GetClassInfo(classID)
|
|
||||||
info["class"] = classInfo
|
|
||||||
|
|
||||||
// Additional class-specific info
|
|
||||||
info["description"] = ci.utils.GetClassDescription(classID)
|
|
||||||
info["eq_class_name"] = ci.utils.GetEQClassName(classID, entity.GetLevel())
|
|
||||||
info["progression"] = ci.utils.GetClassProgression(classID)
|
|
||||||
info["aliases"] = ci.utils.GetClassAliases(classID)
|
|
||||||
info["is_base_class"] = ci.utils.IsBaseClass(classID)
|
|
||||||
info["is_secondary_base"] = ci.utils.IsSecondaryBaseClass(classID)
|
|
||||||
|
|
||||||
return info
|
|
||||||
}
|
|
||||||
|
|
||||||
// ChangeEntityClass changes an entity's class with validation
|
|
||||||
func (ci *ClassIntegration) ChangeEntityClass(entity ClassAware, newClassID int8) error {
|
|
||||||
if !ci.classes.IsValidClassID(newClassID) {
|
|
||||||
return fmt.Errorf("invalid class ID: %d", newClassID)
|
|
||||||
}
|
|
||||||
|
|
||||||
oldClassID := entity.GetClass()
|
|
||||||
|
|
||||||
// Validate the class transition
|
|
||||||
if valid, reason := ci.utils.ValidateClassTransition(oldClassID, newClassID); !valid {
|
|
||||||
return fmt.Errorf("class change not allowed: %s", reason)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Perform the class change
|
|
||||||
entity.SetClass(newClassID)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetRandomClassForEntity returns a random class appropriate for an entity
|
|
||||||
func (ci *ClassIntegration) GetRandomClassForEntity(classType string) int8 {
|
|
||||||
return ci.utils.GetRandomClassByType(classType)
|
|
||||||
}
|
|
||||||
|
|
||||||
// CheckClassCompatibility checks if two entities' classes are compatible for grouping
|
|
||||||
func (ci *ClassIntegration) CheckClassCompatibility(entity1, entity2 ClassAware) bool {
|
|
||||||
class1 := entity1.GetClass()
|
|
||||||
class2 := entity2.GetClass()
|
|
||||||
|
|
||||||
if !ci.classes.IsValidClassID(class1) || !ci.classes.IsValidClassID(class2) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Same class is always compatible
|
|
||||||
if class1 == class2 {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if they share the same base class (good for grouping)
|
|
||||||
// base1 := ci.classes.GetBaseClass(class1)
|
|
||||||
// base2 := ci.classes.GetBaseClass(class2)
|
|
||||||
|
|
||||||
// Different base classes can group together (provides diversity)
|
|
||||||
// Same base class provides synergy
|
|
||||||
return true // For now, all classes are compatible for grouping
|
|
||||||
}
|
|
||||||
|
|
||||||
// FormatEntityClass returns a formatted class name for an entity
|
|
||||||
func (ci *ClassIntegration) FormatEntityClass(entity EntityWithClass, format string) string {
|
|
||||||
classID := entity.GetClass()
|
|
||||||
level := entity.GetLevel()
|
|
||||||
|
|
||||||
switch format {
|
|
||||||
case "eq":
|
|
||||||
return ci.utils.GetEQClassName(classID, level)
|
|
||||||
default:
|
|
||||||
return ci.utils.FormatClassName(classID, format)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetEntityBaseClass returns an entity's base class
|
|
||||||
func (ci *ClassIntegration) GetEntityBaseClass(entity ClassAware) int8 {
|
|
||||||
classID := entity.GetClass()
|
|
||||||
return ci.classes.GetBaseClass(classID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetEntitySecondaryBaseClass returns an entity's secondary base class
|
|
||||||
func (ci *ClassIntegration) GetEntitySecondaryBaseClass(entity ClassAware) int8 {
|
|
||||||
classID := entity.GetClass()
|
|
||||||
return ci.classes.GetSecondaryBaseClass(classID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsEntityAdventureClass checks if an entity has an adventure class
|
|
||||||
func (ci *ClassIntegration) IsEntityAdventureClass(entity ClassAware) bool {
|
|
||||||
classID := entity.GetClass()
|
|
||||||
return ci.classes.IsAdventureClass(classID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsEntityTradeskillClass checks if an entity has a tradeskill class
|
|
||||||
func (ci *ClassIntegration) IsEntityTradeskillClass(entity ClassAware) bool {
|
|
||||||
classID := entity.GetClass()
|
|
||||||
return ci.classes.IsTradeskillClass(classID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetEntitiesByClass filters entities by class
|
|
||||||
func (ci *ClassIntegration) GetEntitiesByClass(entities []ClassAware, classID int8) []ClassAware {
|
|
||||||
result := make([]ClassAware, 0)
|
|
||||||
|
|
||||||
for _, entity := range entities {
|
|
||||||
if entity.GetClass() == classID {
|
|
||||||
result = append(result, entity)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetEntitiesByBaseClass filters entities by base class
|
|
||||||
func (ci *ClassIntegration) GetEntitiesByBaseClass(entities []ClassAware, baseClassID int8) []ClassAware {
|
|
||||||
result := make([]ClassAware, 0)
|
|
||||||
|
|
||||||
for _, entity := range entities {
|
|
||||||
if ci.GetEntityBaseClass(entity) == baseClassID {
|
|
||||||
result = append(result, entity)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetEntitiesByClassType filters entities by class type (adventure/tradeskill)
|
|
||||||
func (ci *ClassIntegration) GetEntitiesByClassType(entities []ClassAware, classType string) []ClassAware {
|
|
||||||
result := make([]ClassAware, 0)
|
|
||||||
|
|
||||||
for _, entity := range entities {
|
|
||||||
classID := entity.GetClass()
|
|
||||||
if ci.classes.GetClassType(classID) == classType {
|
|
||||||
result = append(result, entity)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// ValidateClassForRace checks if a class/race combination is valid
|
|
||||||
func (ci *ClassIntegration) ValidateClassForRace(classID, raceID int8) (bool, string) {
|
|
||||||
if !ci.classes.IsValidClassID(classID) {
|
|
||||||
return false, "Invalid class"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use the utility function (which currently allows all combinations)
|
|
||||||
if ci.utils.ValidateClassForRace(classID, raceID) {
|
|
||||||
return true, ""
|
|
||||||
}
|
|
||||||
|
|
||||||
className := ci.classes.GetClassNameCase(classID)
|
|
||||||
return false, fmt.Sprintf("Class %s cannot be race %d", className, raceID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetClassStartingStats returns the starting stats for a class
|
|
||||||
func (ci *ClassIntegration) GetClassStartingStats(classID int8) map[string]int16 {
|
|
||||||
// Base stats that all classes start with
|
|
||||||
baseStats := map[string]int16{
|
|
||||||
"strength": 50,
|
|
||||||
"stamina": 50,
|
|
||||||
"agility": 50,
|
|
||||||
"wisdom": 50,
|
|
||||||
"intelligence": 50,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply class modifiers based on class type and role
|
|
||||||
switch ci.classes.GetBaseClass(classID) {
|
|
||||||
case ClassFighter:
|
|
||||||
baseStats["strength"] += 5
|
|
||||||
baseStats["stamina"] += 5
|
|
||||||
baseStats["intelligence"] -= 3
|
|
||||||
case ClassPriest:
|
|
||||||
baseStats["wisdom"] += 5
|
|
||||||
baseStats["intelligence"] += 3
|
|
||||||
baseStats["strength"] -= 2
|
|
||||||
case ClassMage:
|
|
||||||
baseStats["intelligence"] += 5
|
|
||||||
baseStats["wisdom"] += 3
|
|
||||||
baseStats["strength"] -= 3
|
|
||||||
baseStats["stamina"] -= 2
|
|
||||||
case ClassScout:
|
|
||||||
baseStats["agility"] += 5
|
|
||||||
baseStats["stamina"] += 3
|
|
||||||
baseStats["wisdom"] -= 2
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fine-tune for specific secondary base classes
|
|
||||||
switch ci.classes.GetSecondaryBaseClass(classID) {
|
|
||||||
case ClassWarrior:
|
|
||||||
baseStats["strength"] += 2
|
|
||||||
baseStats["stamina"] += 2
|
|
||||||
case ClassBrawler:
|
|
||||||
baseStats["agility"] += 2
|
|
||||||
baseStats["strength"] += 1
|
|
||||||
case ClassCrusader:
|
|
||||||
baseStats["wisdom"] += 2
|
|
||||||
baseStats["strength"] += 1
|
|
||||||
case ClassCleric:
|
|
||||||
baseStats["wisdom"] += 3
|
|
||||||
case ClassDruid:
|
|
||||||
baseStats["wisdom"] += 2
|
|
||||||
baseStats["intelligence"] += 1
|
|
||||||
case ClassShaman:
|
|
||||||
baseStats["wisdom"] += 2
|
|
||||||
baseStats["stamina"] += 1
|
|
||||||
case ClassSorcerer:
|
|
||||||
baseStats["intelligence"] += 3
|
|
||||||
case ClassEnchanter:
|
|
||||||
baseStats["intelligence"] += 2
|
|
||||||
baseStats["agility"] += 1
|
|
||||||
case ClassSummoner:
|
|
||||||
baseStats["intelligence"] += 2
|
|
||||||
baseStats["wisdom"] += 1
|
|
||||||
case ClassRogue:
|
|
||||||
baseStats["agility"] += 3
|
|
||||||
case ClassBard:
|
|
||||||
baseStats["agility"] += 2
|
|
||||||
baseStats["intelligence"] += 1
|
|
||||||
case ClassPredator:
|
|
||||||
baseStats["agility"] += 2
|
|
||||||
baseStats["stamina"] += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
return baseStats
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateClassSpecificEntity creates entity data with class-specific properties
|
|
||||||
func (ci *ClassIntegration) CreateClassSpecificEntity(classID int8) map[string]any {
|
|
||||||
if !ci.classes.IsValidClassID(classID) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
entityData := make(map[string]any)
|
|
||||||
|
|
||||||
// Basic class info
|
|
||||||
entityData["class_id"] = classID
|
|
||||||
entityData["class_name"] = ci.classes.GetClassNameCase(classID)
|
|
||||||
entityData["class_type"] = ci.classes.GetClassType(classID)
|
|
||||||
|
|
||||||
// Starting stats
|
|
||||||
entityData["starting_stats"] = ci.GetClassStartingStats(classID)
|
|
||||||
|
|
||||||
// Class progression
|
|
||||||
entityData["progression"] = ci.utils.GetClassProgression(classID)
|
|
||||||
|
|
||||||
// Class description
|
|
||||||
entityData["description"] = ci.utils.GetClassDescription(classID)
|
|
||||||
|
|
||||||
// Role information
|
|
||||||
entityData["base_class"] = ci.classes.GetBaseClass(classID)
|
|
||||||
entityData["secondary_base_class"] = ci.classes.GetSecondaryBaseClass(classID)
|
|
||||||
|
|
||||||
return entityData
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetClassSelectionData returns data for class selection UI
|
|
||||||
func (ci *ClassIntegration) GetClassSelectionData() map[string]any {
|
|
||||||
data := make(map[string]any)
|
|
||||||
|
|
||||||
// All available adventure classes (exclude tradeskill for character creation)
|
|
||||||
allClasses := ci.classes.GetAllClasses()
|
|
||||||
adventureClasses := make([]map[string]any, 0)
|
|
||||||
|
|
||||||
for classID, displayName := range allClasses {
|
|
||||||
if ci.classes.IsAdventureClass(classID) {
|
|
||||||
classData := map[string]any{
|
|
||||||
"id": classID,
|
|
||||||
"name": displayName,
|
|
||||||
"type": ci.classes.GetClassType(classID),
|
|
||||||
"description": ci.utils.GetClassDescription(classID),
|
|
||||||
"base_class": ci.classes.GetBaseClass(classID),
|
|
||||||
"secondary_base_class": ci.classes.GetSecondaryBaseClass(classID),
|
|
||||||
"starting_stats": ci.GetClassStartingStats(classID),
|
|
||||||
"progression": ci.utils.GetClassProgression(classID),
|
|
||||||
"is_base_class": ci.utils.IsBaseClass(classID),
|
|
||||||
}
|
|
||||||
adventureClasses = append(adventureClasses, classData)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data["adventure_classes"] = adventureClasses
|
|
||||||
data["statistics"] = ci.utils.GetClassStatistics()
|
|
||||||
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
// Global class integration instance
|
|
||||||
var globalClassIntegration *ClassIntegration
|
|
||||||
|
|
||||||
// GetGlobalClassIntegration returns the global class integration helper
|
|
||||||
func GetGlobalClassIntegration() *ClassIntegration {
|
|
||||||
if globalClassIntegration == nil {
|
|
||||||
globalClassIntegration = NewClassIntegration()
|
|
||||||
}
|
|
||||||
return globalClassIntegration
|
|
||||||
}
|
|
@ -1,455 +0,0 @@
|
|||||||
package classes
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"sync"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ClassManager provides high-level class management functionality
|
|
||||||
type ClassManager struct {
|
|
||||||
classes *Classes
|
|
||||||
utils *ClassUtils
|
|
||||||
integration *ClassIntegration
|
|
||||||
|
|
||||||
// Statistics tracking
|
|
||||||
classUsageStats map[int8]int32 // Track how often each class is used
|
|
||||||
|
|
||||||
// Thread safety
|
|
||||||
mutex sync.RWMutex
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewClassManager creates a new class manager
|
|
||||||
func NewClassManager() *ClassManager {
|
|
||||||
return &ClassManager{
|
|
||||||
classes: GetGlobalClasses(),
|
|
||||||
utils: NewClassUtils(),
|
|
||||||
integration: NewClassIntegration(),
|
|
||||||
classUsageStats: make(map[int8]int32),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// RegisterClassUsage tracks class usage for statistics
|
|
||||||
func (cm *ClassManager) RegisterClassUsage(classID int8) {
|
|
||||||
if !cm.classes.IsValidClassID(classID) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
cm.mutex.Lock()
|
|
||||||
defer cm.mutex.Unlock()
|
|
||||||
|
|
||||||
cm.classUsageStats[classID]++
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetClassUsageStats returns class usage statistics
|
|
||||||
func (cm *ClassManager) GetClassUsageStats() map[int8]int32 {
|
|
||||||
cm.mutex.RLock()
|
|
||||||
defer cm.mutex.RUnlock()
|
|
||||||
|
|
||||||
// Return a copy to prevent external modification
|
|
||||||
stats := make(map[int8]int32)
|
|
||||||
for classID, count := range cm.classUsageStats {
|
|
||||||
stats[classID] = count
|
|
||||||
}
|
|
||||||
|
|
||||||
return stats
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetMostPopularClass returns the most frequently used class
|
|
||||||
func (cm *ClassManager) GetMostPopularClass() (int8, int32) {
|
|
||||||
cm.mutex.RLock()
|
|
||||||
defer cm.mutex.RUnlock()
|
|
||||||
|
|
||||||
var mostPopularClass int8 = -1
|
|
||||||
var maxUsage int32 = 0
|
|
||||||
|
|
||||||
for classID, usage := range cm.classUsageStats {
|
|
||||||
if usage > maxUsage {
|
|
||||||
maxUsage = usage
|
|
||||||
mostPopularClass = classID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return mostPopularClass, maxUsage
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetLeastPopularClass returns the least frequently used class
|
|
||||||
func (cm *ClassManager) GetLeastPopularClass() (int8, int32) {
|
|
||||||
cm.mutex.RLock()
|
|
||||||
defer cm.mutex.RUnlock()
|
|
||||||
|
|
||||||
var leastPopularClass int8 = -1
|
|
||||||
var minUsage int32 = -1
|
|
||||||
|
|
||||||
for classID, usage := range cm.classUsageStats {
|
|
||||||
if minUsage == -1 || usage < minUsage {
|
|
||||||
minUsage = usage
|
|
||||||
leastPopularClass = classID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return leastPopularClass, minUsage
|
|
||||||
}
|
|
||||||
|
|
||||||
// ResetUsageStats clears all usage statistics
|
|
||||||
func (cm *ClassManager) ResetUsageStats() {
|
|
||||||
cm.mutex.Lock()
|
|
||||||
defer cm.mutex.Unlock()
|
|
||||||
|
|
||||||
cm.classUsageStats = make(map[int8]int32)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ProcessClassCommand handles class-related commands
|
|
||||||
func (cm *ClassManager) ProcessClassCommand(command string, args []string) (string, error) {
|
|
||||||
switch command {
|
|
||||||
case "list":
|
|
||||||
return cm.handleListCommand(args)
|
|
||||||
case "info":
|
|
||||||
return cm.handleInfoCommand(args)
|
|
||||||
case "random":
|
|
||||||
return cm.handleRandomCommand(args)
|
|
||||||
case "stats":
|
|
||||||
return cm.handleStatsCommand(args)
|
|
||||||
case "search":
|
|
||||||
return cm.handleSearchCommand(args)
|
|
||||||
case "progression":
|
|
||||||
return cm.handleProgressionCommand(args)
|
|
||||||
default:
|
|
||||||
return "", fmt.Errorf("unknown class command: %s", command)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleListCommand lists classes by criteria
|
|
||||||
func (cm *ClassManager) handleListCommand(args []string) (string, error) {
|
|
||||||
if len(args) == 0 {
|
|
||||||
// List all classes
|
|
||||||
allClasses := cm.classes.GetAllClasses()
|
|
||||||
result := "All Classes:\n"
|
|
||||||
for classID, displayName := range allClasses {
|
|
||||||
classType := cm.classes.GetClassType(classID)
|
|
||||||
baseClass := cm.classes.GetBaseClass(classID)
|
|
||||||
baseClassName := cm.classes.GetClassNameCase(baseClass)
|
|
||||||
result += fmt.Sprintf("%d: %s (%s, Base: %s)\n", classID, displayName, classType, baseClassName)
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// List classes by type
|
|
||||||
classType := args[0]
|
|
||||||
allClasses := cm.classes.GetAllClasses()
|
|
||||||
result := fmt.Sprintf("%s Classes:\n", classType)
|
|
||||||
count := 0
|
|
||||||
|
|
||||||
for classID, displayName := range allClasses {
|
|
||||||
if cm.classes.GetClassType(classID) == classType {
|
|
||||||
baseClass := cm.classes.GetBaseClass(classID)
|
|
||||||
baseClassName := cm.classes.GetClassNameCase(baseClass)
|
|
||||||
result += fmt.Sprintf("%d: %s (Base: %s)\n", classID, displayName, baseClassName)
|
|
||||||
count++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if count == 0 {
|
|
||||||
return fmt.Sprintf("No classes found for type: %s", classType), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleInfoCommand provides detailed information about a class
|
|
||||||
func (cm *ClassManager) handleInfoCommand(args []string) (string, error) {
|
|
||||||
if len(args) == 0 {
|
|
||||||
return "", fmt.Errorf("class name or ID required")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to parse as class name or ID
|
|
||||||
classID := cm.utils.ParseClassName(args[0])
|
|
||||||
if classID == -1 {
|
|
||||||
return fmt.Sprintf("Invalid class: %s", args[0]), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
classInfo := cm.classes.GetClassInfo(classID)
|
|
||||||
if !classInfo["valid"].(bool) {
|
|
||||||
return fmt.Sprintf("Invalid class ID: %d", classID), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
result := "Class Information:\n"
|
|
||||||
result += fmt.Sprintf("ID: %d\n", classID)
|
|
||||||
result += fmt.Sprintf("Name: %s\n", classInfo["display_name"])
|
|
||||||
result += fmt.Sprintf("Type: %s\n", classInfo["type"])
|
|
||||||
result += fmt.Sprintf("Base Class: %s\n", cm.classes.GetClassNameCase(classInfo["base_class"].(int8)))
|
|
||||||
|
|
||||||
if secondaryBase := classInfo["secondary_base_class"].(int8); secondaryBase != DefaultClassID {
|
|
||||||
result += fmt.Sprintf("Secondary Base: %s\n", cm.classes.GetClassNameCase(secondaryBase))
|
|
||||||
}
|
|
||||||
|
|
||||||
result += fmt.Sprintf("Description: %s\n", cm.utils.GetClassDescription(classID))
|
|
||||||
|
|
||||||
// Add progression path
|
|
||||||
progression := cm.utils.GetClassProgression(classID)
|
|
||||||
if len(progression) > 1 {
|
|
||||||
result += "Progression Path: "
|
|
||||||
progressionNames := make([]string, len(progression))
|
|
||||||
for i, progClassID := range progression {
|
|
||||||
progressionNames[i] = cm.classes.GetClassNameCase(progClassID)
|
|
||||||
}
|
|
||||||
result += fmt.Sprintf("%s\n", cm.utils.FormatClassList(progression, " → "))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add starting stats
|
|
||||||
startingStats := cm.integration.GetClassStartingStats(classID)
|
|
||||||
if len(startingStats) > 0 {
|
|
||||||
result += "Starting Stats:\n"
|
|
||||||
for stat, value := range startingStats {
|
|
||||||
result += fmt.Sprintf(" %s: %d\n", stat, value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add usage statistics if available
|
|
||||||
cm.mutex.RLock()
|
|
||||||
usage, hasUsage := cm.classUsageStats[classID]
|
|
||||||
cm.mutex.RUnlock()
|
|
||||||
|
|
||||||
if hasUsage {
|
|
||||||
result += fmt.Sprintf("Usage Count: %d\n", usage)
|
|
||||||
}
|
|
||||||
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleRandomCommand generates random classes
|
|
||||||
func (cm *ClassManager) handleRandomCommand(args []string) (string, error) {
|
|
||||||
classType := ClassTypeAdventure
|
|
||||||
if len(args) > 0 {
|
|
||||||
classType = args[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
classID := cm.utils.GetRandomClassByType(classType)
|
|
||||||
if classID == -1 {
|
|
||||||
return "Failed to generate random class", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
displayName := cm.classes.GetClassNameCase(classID)
|
|
||||||
actualType := cm.classes.GetClassType(classID)
|
|
||||||
|
|
||||||
return fmt.Sprintf("Random %s Class: %s (ID: %d)", actualType, displayName, classID), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleStatsCommand shows class system statistics
|
|
||||||
func (cm *ClassManager) handleStatsCommand(args []string) (string, error) {
|
|
||||||
systemStats := cm.utils.GetClassStatistics()
|
|
||||||
usageStats := cm.GetClassUsageStats()
|
|
||||||
|
|
||||||
result := "Class System Statistics:\n"
|
|
||||||
result += fmt.Sprintf("Total Classes: %d\n", systemStats["total_classes"])
|
|
||||||
result += fmt.Sprintf("Adventure Classes: %d\n", systemStats["adventure_classes"])
|
|
||||||
result += fmt.Sprintf("Tradeskill Classes: %d\n", systemStats["tradeskill_classes"])
|
|
||||||
result += fmt.Sprintf("Special Classes: %d\n", systemStats["special_classes"])
|
|
||||||
|
|
||||||
if len(usageStats) > 0 {
|
|
||||||
result += "\nUsage Statistics:\n"
|
|
||||||
mostPopular, maxUsage := cm.GetMostPopularClass()
|
|
||||||
leastPopular, minUsage := cm.GetLeastPopularClass()
|
|
||||||
|
|
||||||
if mostPopular != -1 {
|
|
||||||
mostPopularName := cm.classes.GetClassNameCase(mostPopular)
|
|
||||||
result += fmt.Sprintf("Most Popular: %s (%d uses)\n", mostPopularName, maxUsage)
|
|
||||||
}
|
|
||||||
|
|
||||||
if leastPopular != -1 {
|
|
||||||
leastPopularName := cm.classes.GetClassNameCase(leastPopular)
|
|
||||||
result += fmt.Sprintf("Least Popular: %s (%d uses)\n", leastPopularName, minUsage)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show base class distribution
|
|
||||||
if baseDistribution, exists := systemStats["base_class_distribution"]; exists {
|
|
||||||
result += "\nBase Class Distribution:\n"
|
|
||||||
distribution := baseDistribution.(map[string][]string)
|
|
||||||
for baseClass, subClasses := range distribution {
|
|
||||||
result += fmt.Sprintf("%s: %d subclasses\n", baseClass, len(subClasses))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleSearchCommand searches for classes by pattern
|
|
||||||
func (cm *ClassManager) handleSearchCommand(args []string) (string, error) {
|
|
||||||
if len(args) == 0 {
|
|
||||||
return "", fmt.Errorf("search pattern required")
|
|
||||||
}
|
|
||||||
|
|
||||||
pattern := args[0]
|
|
||||||
matchingClasses := cm.utils.GetClassesByPattern(pattern)
|
|
||||||
|
|
||||||
if len(matchingClasses) == 0 {
|
|
||||||
return fmt.Sprintf("No classes found matching pattern: %s", pattern), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
result := fmt.Sprintf("Classes matching '%s':\n", pattern)
|
|
||||||
for _, classID := range matchingClasses {
|
|
||||||
displayName := cm.classes.GetClassNameCase(classID)
|
|
||||||
classType := cm.classes.GetClassType(classID)
|
|
||||||
baseClass := cm.classes.GetBaseClass(classID)
|
|
||||||
baseClassName := cm.classes.GetClassNameCase(baseClass)
|
|
||||||
result += fmt.Sprintf("%d: %s (%s, Base: %s)\n", classID, displayName, classType, baseClassName)
|
|
||||||
}
|
|
||||||
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleProgressionCommand shows class progression information
|
|
||||||
func (cm *ClassManager) handleProgressionCommand(args []string) (string, error) {
|
|
||||||
if len(args) == 0 {
|
|
||||||
return "", fmt.Errorf("class name or ID required")
|
|
||||||
}
|
|
||||||
|
|
||||||
classID := cm.utils.ParseClassName(args[0])
|
|
||||||
if classID == -1 {
|
|
||||||
return fmt.Sprintf("Invalid class: %s", args[0]), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
progression := cm.utils.GetClassProgression(classID)
|
|
||||||
if len(progression) <= 1 {
|
|
||||||
return fmt.Sprintf("Class %s has no progression path", cm.classes.GetClassNameCase(classID)), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
result := fmt.Sprintf("Progression Path for %s:\n", cm.classes.GetClassNameCase(classID))
|
|
||||||
for i, stepClassID := range progression {
|
|
||||||
stepName := cm.classes.GetClassNameCase(stepClassID)
|
|
||||||
if i == 0 {
|
|
||||||
result += fmt.Sprintf("1. %s (Starting Class)\n", stepName)
|
|
||||||
} else if i == len(progression)-1 {
|
|
||||||
result += fmt.Sprintf("%d. %s (Final Class)\n", i+1, stepName)
|
|
||||||
} else {
|
|
||||||
result += fmt.Sprintf("%d. %s\n", i+1, stepName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ValidateEntityClasses validates classes for a collection of entities
|
|
||||||
func (cm *ClassManager) ValidateEntityClasses(entities []ClassAware) map[string]any {
|
|
||||||
validationResults := make(map[string]any)
|
|
||||||
|
|
||||||
validCount := 0
|
|
||||||
invalidCount := 0
|
|
||||||
classDistribution := make(map[int8]int)
|
|
||||||
|
|
||||||
for i, entity := range entities {
|
|
||||||
classID := entity.GetClass()
|
|
||||||
isValid := cm.classes.IsValidClassID(classID)
|
|
||||||
|
|
||||||
if isValid {
|
|
||||||
validCount++
|
|
||||||
classDistribution[classID]++
|
|
||||||
} else {
|
|
||||||
invalidCount++
|
|
||||||
}
|
|
||||||
|
|
||||||
// Track invalid entities
|
|
||||||
if !isValid {
|
|
||||||
if validationResults["invalid_entities"] == nil {
|
|
||||||
validationResults["invalid_entities"] = make([]map[string]any, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
invalidList := validationResults["invalid_entities"].([]map[string]any)
|
|
||||||
invalidList = append(invalidList, map[string]any{
|
|
||||||
"index": i,
|
|
||||||
"class_id": classID,
|
|
||||||
})
|
|
||||||
validationResults["invalid_entities"] = invalidList
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
validationResults["total_entities"] = len(entities)
|
|
||||||
validationResults["valid_count"] = validCount
|
|
||||||
validationResults["invalid_count"] = invalidCount
|
|
||||||
validationResults["class_distribution"] = classDistribution
|
|
||||||
|
|
||||||
return validationResults
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetClassRecommendations returns class recommendations for character creation
|
|
||||||
func (cm *ClassManager) GetClassRecommendations(preferences map[string]any) []int8 {
|
|
||||||
recommendations := make([]int8, 0)
|
|
||||||
|
|
||||||
// Check for class type preference
|
|
||||||
if classType, exists := preferences["class_type"]; exists {
|
|
||||||
if typeStr, ok := classType.(string); ok {
|
|
||||||
allClasses := cm.classes.GetAllClasses()
|
|
||||||
for classID := range allClasses {
|
|
||||||
if cm.classes.GetClassType(classID) == typeStr {
|
|
||||||
recommendations = append(recommendations, classID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for base class preference
|
|
||||||
if baseClass, exists := preferences["base_class"]; exists {
|
|
||||||
if baseClassID, ok := baseClass.(int8); ok {
|
|
||||||
subClasses := cm.utils.GetClassesForBaseClass(baseClassID)
|
|
||||||
recommendations = append(recommendations, subClasses...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for specific stat preferences
|
|
||||||
if preferredStats, exists := preferences["preferred_stats"]; exists {
|
|
||||||
if stats, ok := preferredStats.([]string); ok {
|
|
||||||
allClasses := cm.classes.GetAllClasses()
|
|
||||||
|
|
||||||
for classID := range allClasses {
|
|
||||||
startingStats := cm.integration.GetClassStartingStats(classID)
|
|
||||||
|
|
||||||
// Check if this class has bonuses in preferred stats
|
|
||||||
hasPreferredBonus := false
|
|
||||||
for _, preferredStat := range stats {
|
|
||||||
if statValue, exists := startingStats[preferredStat]; exists && statValue > 52 { // Above base of 50 + minor bonus
|
|
||||||
hasPreferredBonus = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if hasPreferredBonus {
|
|
||||||
recommendations = append(recommendations, classID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no specific preferences, recommend popular classes
|
|
||||||
if len(recommendations) == 0 {
|
|
||||||
// Get usage stats and recommend most popular classes
|
|
||||||
usageStats := cm.GetClassUsageStats()
|
|
||||||
if len(usageStats) > 0 {
|
|
||||||
// Sort by usage and take top classes
|
|
||||||
// For simplicity, just return all classes with usage > 0
|
|
||||||
for classID, usage := range usageStats {
|
|
||||||
if usage > 0 {
|
|
||||||
recommendations = append(recommendations, classID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If still no recommendations, return a default set of beginner-friendly classes
|
|
||||||
if len(recommendations) == 0 {
|
|
||||||
recommendations = []int8{ClassWarrior, ClassCleric, ClassWizard, ClassRogue}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return recommendations
|
|
||||||
}
|
|
||||||
|
|
||||||
// Global class manager instance
|
|
||||||
var globalClassManager *ClassManager
|
|
||||||
var initClassManagerOnce sync.Once
|
|
||||||
|
|
||||||
// GetGlobalClassManager returns the global class manager (singleton)
|
|
||||||
func GetGlobalClassManager() *ClassManager {
|
|
||||||
initClassManagerOnce.Do(func() {
|
|
||||||
globalClassManager = NewClassManager()
|
|
||||||
})
|
|
||||||
return globalClassManager
|
|
||||||
}
|
|
@ -1,450 +0,0 @@
|
|||||||
package classes
|
|
||||||
|
|
||||||
import (
|
|
||||||
"math/rand"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ClassUtils provides utility functions for class operations
|
|
||||||
type ClassUtils struct {
|
|
||||||
classes *Classes
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewClassUtils creates a new class utilities instance
|
|
||||||
func NewClassUtils() *ClassUtils {
|
|
||||||
return &ClassUtils{
|
|
||||||
classes: GetGlobalClasses(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ParseClassName attempts to parse a class name from various input formats
|
|
||||||
func (cu *ClassUtils) ParseClassName(input string) int8 {
|
|
||||||
if input == "" {
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try direct lookup first
|
|
||||||
classID := cu.classes.GetClassID(input)
|
|
||||||
if classID != -1 {
|
|
||||||
return classID
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try with common variations
|
|
||||||
variations := []string{
|
|
||||||
strings.ToUpper(input),
|
|
||||||
strings.ReplaceAll(strings.ToUpper(input), " ", ""),
|
|
||||||
strings.ReplaceAll(strings.ToUpper(input), "_", ""),
|
|
||||||
strings.ReplaceAll(strings.ToUpper(input), "-", ""),
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, variation := range variations {
|
|
||||||
if classID := cu.classes.GetClassID(variation); classID != -1 {
|
|
||||||
return classID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try matching against friendly names (case insensitive)
|
|
||||||
inputLower := strings.ToLower(input)
|
|
||||||
allClasses := cu.classes.GetAllClasses()
|
|
||||||
for classID, displayName := range allClasses {
|
|
||||||
if strings.ToLower(displayName) == inputLower {
|
|
||||||
return classID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return -1 // Not found
|
|
||||||
}
|
|
||||||
|
|
||||||
// FormatClassName returns a properly formatted class name
|
|
||||||
func (cu *ClassUtils) FormatClassName(classID int8, format string) string {
|
|
||||||
switch strings.ToLower(format) {
|
|
||||||
case "display", "friendly", "proper":
|
|
||||||
return cu.classes.GetClassNameCase(classID)
|
|
||||||
case "upper", "uppercase":
|
|
||||||
return cu.classes.GetClassName(classID)
|
|
||||||
case "lower", "lowercase":
|
|
||||||
return strings.ToLower(cu.classes.GetClassName(classID))
|
|
||||||
default:
|
|
||||||
return cu.classes.GetClassNameCase(classID) // Default to friendly name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetRandomClassByType returns a random class of the specified type
|
|
||||||
func (cu *ClassUtils) GetRandomClassByType(classType string) int8 {
|
|
||||||
allClasses := cu.classes.GetAllClasses()
|
|
||||||
validClasses := make([]int8, 0)
|
|
||||||
|
|
||||||
for classID := range allClasses {
|
|
||||||
if cu.classes.GetClassType(classID) == classType {
|
|
||||||
validClasses = append(validClasses, classID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(validClasses) == 0 {
|
|
||||||
return DefaultClassID
|
|
||||||
}
|
|
||||||
|
|
||||||
return validClasses[rand.Intn(len(validClasses))]
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetRandomAdventureClass returns a random adventure class
|
|
||||||
func (cu *ClassUtils) GetRandomAdventureClass() int8 {
|
|
||||||
return cu.GetRandomClassByType(ClassTypeAdventure)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetRandomTradeskillClass returns a random tradeskill class
|
|
||||||
func (cu *ClassUtils) GetRandomTradeskillClass() int8 {
|
|
||||||
return cu.GetRandomClassByType(ClassTypeTradeskill)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ValidateClassForRace checks if a class is valid for a specific race
|
|
||||||
// This is a placeholder for future race-class restrictions
|
|
||||||
func (cu *ClassUtils) ValidateClassForRace(classID, raceID int8) bool {
|
|
||||||
// TODO: Implement race-class restrictions when race system is available
|
|
||||||
// For now, all classes can be all races
|
|
||||||
return cu.classes.IsValidClassID(classID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetClassDescription returns a description of the class
|
|
||||||
func (cu *ClassUtils) GetClassDescription(classID int8) string {
|
|
||||||
// This would typically come from a database or configuration
|
|
||||||
// For now, provide basic descriptions based on class
|
|
||||||
|
|
||||||
switch classID {
|
|
||||||
case ClassCommoner:
|
|
||||||
return "A starting class for all characters before choosing their path."
|
|
||||||
case ClassFighter:
|
|
||||||
return "Warriors who excel in melee combat and defense."
|
|
||||||
case ClassWarrior:
|
|
||||||
return "Masters of weapons and armor, the ultimate melee combatants."
|
|
||||||
case ClassGuardian:
|
|
||||||
return "Defensive warriors who protect their allies with shield and sword."
|
|
||||||
case ClassBerserker:
|
|
||||||
return "Rage-fueled fighters who sacrifice defense for devastating attacks."
|
|
||||||
case ClassBrawler:
|
|
||||||
return "Hand-to-hand combat specialists who fight with fists and focus."
|
|
||||||
case ClassMonk:
|
|
||||||
return "Disciplined fighters who use martial arts and inner peace."
|
|
||||||
case ClassBruiser:
|
|
||||||
return "Brutal brawlers who overwhelm enemies with raw power."
|
|
||||||
case ClassCrusader:
|
|
||||||
return "Holy warriors who blend combat prowess with divine magic."
|
|
||||||
case ClassShadowknight:
|
|
||||||
return "Dark knights who wield unholy magic alongside martial skill."
|
|
||||||
case ClassPaladin:
|
|
||||||
return "Champions of good who protect the innocent with sword and spell."
|
|
||||||
case ClassPriest:
|
|
||||||
return "Divine casters who channel the power of the gods."
|
|
||||||
case ClassCleric:
|
|
||||||
return "Healers and supporters who keep their allies alive and fighting."
|
|
||||||
case ClassTemplar:
|
|
||||||
return "Protective priests who shield allies from harm."
|
|
||||||
case ClassInquisitor:
|
|
||||||
return "Militant clerics who combine healing with righteous fury."
|
|
||||||
case ClassDruid:
|
|
||||||
return "Nature priests who harness the power of the natural world."
|
|
||||||
case ClassWarden:
|
|
||||||
return "Protective druids who shield allies with nature's blessing."
|
|
||||||
case ClassFury:
|
|
||||||
return "Destructive druids who unleash nature's wrath upon enemies."
|
|
||||||
case ClassShaman:
|
|
||||||
return "Spirit-workers who commune with ancestors and totems."
|
|
||||||
case ClassMystic:
|
|
||||||
return "Supportive shamans who provide wards and spiritual guidance."
|
|
||||||
case ClassDefiler:
|
|
||||||
return "Dark shamans who corrupt and weaken their enemies."
|
|
||||||
case ClassMage:
|
|
||||||
return "Wielders of arcane magic who bend reality to their will."
|
|
||||||
case ClassSorcerer:
|
|
||||||
return "Destructive mages who specialize in damaging spells."
|
|
||||||
case ClassWizard:
|
|
||||||
return "Scholarly sorcerers who master the elements."
|
|
||||||
case ClassWarlock:
|
|
||||||
return "Dark sorcerers who deal in forbidden magic."
|
|
||||||
case ClassEnchanter:
|
|
||||||
return "Mind-controlling mages who manipulate enemies and allies."
|
|
||||||
case ClassIllusionist:
|
|
||||||
return "Deceptive enchanters who confuse and misdirect."
|
|
||||||
case ClassCoercer:
|
|
||||||
return "Dominating enchanters who force enemies to obey."
|
|
||||||
case ClassSummoner:
|
|
||||||
return "Mages who call forth creatures to fight for them."
|
|
||||||
case ClassConjuror:
|
|
||||||
return "Elemental summoners who command earth and air."
|
|
||||||
case ClassNecromancer:
|
|
||||||
return "Death mages who raise undead minions and drain life."
|
|
||||||
case ClassScout:
|
|
||||||
return "Agile fighters who rely on speed and cunning."
|
|
||||||
case ClassRogue:
|
|
||||||
return "Stealthy combatants who strike from the shadows."
|
|
||||||
case ClassSwashbuckler:
|
|
||||||
return "Dashing rogues who fight with finesse and flair."
|
|
||||||
case ClassBrigand:
|
|
||||||
return "Brutal rogues who prefer dirty fighting tactics."
|
|
||||||
case ClassBard:
|
|
||||||
return "Musical combatants who inspire allies and demoralize foes."
|
|
||||||
case ClassTroubador:
|
|
||||||
return "Supportive bards who strengthen their allies."
|
|
||||||
case ClassDirge:
|
|
||||||
return "Dark bards who weaken enemies with haunting melodies."
|
|
||||||
case ClassPredator:
|
|
||||||
return "Hunters who excel at tracking and ranged combat."
|
|
||||||
case ClassRanger:
|
|
||||||
return "Nature-loving predators who protect the wilderness."
|
|
||||||
case ClassAssassin:
|
|
||||||
return "Deadly predators who eliminate targets with precision."
|
|
||||||
case ClassAnimalist:
|
|
||||||
return "Beast masters who fight alongside animal companions."
|
|
||||||
case ClassBeastlord:
|
|
||||||
return "Animalists who have formed powerful bonds with their pets."
|
|
||||||
case ClassShaper:
|
|
||||||
return "Mystic priests who manipulate spiritual energy."
|
|
||||||
case ClassChanneler:
|
|
||||||
return "Shapers who focus spiritual power through channeling."
|
|
||||||
case ClassArtisan:
|
|
||||||
return "Crafters who create useful items for adventurers."
|
|
||||||
case ClassCraftsman:
|
|
||||||
return "Specialized artisans who work with physical materials."
|
|
||||||
case ClassProvisioner:
|
|
||||||
return "Food and drink specialists who create consumables."
|
|
||||||
case ClassWoodworker:
|
|
||||||
return "Crafters who work with wood to create furniture and tools."
|
|
||||||
case ClassCarpenter:
|
|
||||||
return "Master woodworkers who create complex wooden items."
|
|
||||||
case ClassOutfitter:
|
|
||||||
return "Equipment crafters who create armor and weapons."
|
|
||||||
case ClassArmorer:
|
|
||||||
return "Specialists in creating protective armor."
|
|
||||||
case ClassWeaponsmith:
|
|
||||||
return "Masters of weapon crafting and enhancement."
|
|
||||||
case ClassTailor:
|
|
||||||
return "Cloth workers who create clothing and soft armor."
|
|
||||||
case ClassScholar:
|
|
||||||
return "Academic crafters who create magical and scholarly items."
|
|
||||||
case ClassJeweler:
|
|
||||||
return "Specialists in creating jewelry and accessories."
|
|
||||||
case ClassSage:
|
|
||||||
return "Book and scroll crafters who preserve knowledge."
|
|
||||||
case ClassAlchemist:
|
|
||||||
return "Potion makers who brew magical elixirs and potions."
|
|
||||||
default:
|
|
||||||
return "An unknown class with mysterious abilities."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetClassProgression returns the class progression path
|
|
||||||
func (cu *ClassUtils) GetClassProgression(classID int8) []int8 {
|
|
||||||
progression := make([]int8, 0)
|
|
||||||
|
|
||||||
// Always start with Commoner (except for Commoner itself)
|
|
||||||
if classID != ClassCommoner {
|
|
||||||
progression = append(progression, ClassCommoner)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add base class if different from current
|
|
||||||
baseClass := cu.classes.GetBaseClass(classID)
|
|
||||||
if baseClass != classID && baseClass != ClassCommoner {
|
|
||||||
progression = append(progression, baseClass)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add secondary base class if different
|
|
||||||
secondaryBase := cu.classes.GetSecondaryBaseClass(classID)
|
|
||||||
if secondaryBase != classID && secondaryBase != baseClass && secondaryBase != ClassCommoner {
|
|
||||||
progression = append(progression, secondaryBase)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add the final class
|
|
||||||
progression = append(progression, classID)
|
|
||||||
|
|
||||||
return progression
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetClassesForBaseClass returns all classes that belong to a base class
|
|
||||||
func (cu *ClassUtils) GetClassesForBaseClass(baseClassID int8) []int8 {
|
|
||||||
result := make([]int8, 0)
|
|
||||||
allClasses := cu.classes.GetAllClasses()
|
|
||||||
|
|
||||||
for classID := range allClasses {
|
|
||||||
if cu.classes.GetBaseClass(classID) == baseClassID {
|
|
||||||
result = append(result, classID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetClassesBySecondaryBase returns all classes that belong to a secondary base class
|
|
||||||
func (cu *ClassUtils) GetClassesBySecondaryBase(secondaryBaseID int8) []int8 {
|
|
||||||
result := make([]int8, 0)
|
|
||||||
allClasses := cu.classes.GetAllClasses()
|
|
||||||
|
|
||||||
for classID := range allClasses {
|
|
||||||
if cu.classes.GetSecondaryBaseClass(classID) == secondaryBaseID {
|
|
||||||
result = append(result, classID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetClassesByPattern returns classes matching a name pattern
|
|
||||||
func (cu *ClassUtils) GetClassesByPattern(pattern string) []int8 {
|
|
||||||
pattern = strings.ToLower(pattern)
|
|
||||||
result := make([]int8, 0)
|
|
||||||
|
|
||||||
allClasses := cu.classes.GetAllClasses()
|
|
||||||
for classID, displayName := range allClasses {
|
|
||||||
if strings.Contains(strings.ToLower(displayName), pattern) {
|
|
||||||
result = append(result, classID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// ValidateClassTransition checks if a class change is allowed
|
|
||||||
func (cu *ClassUtils) ValidateClassTransition(fromClassID, toClassID int8) (bool, string) {
|
|
||||||
if !cu.classes.IsValidClassID(fromClassID) {
|
|
||||||
return false, "Invalid source class"
|
|
||||||
}
|
|
||||||
|
|
||||||
if !cu.classes.IsValidClassID(toClassID) {
|
|
||||||
return false, "Invalid target class"
|
|
||||||
}
|
|
||||||
|
|
||||||
if fromClassID == toClassID {
|
|
||||||
return false, "Cannot change to the same class"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Basic progression validation - can only advance, not go backward
|
|
||||||
fromProgression := cu.GetClassProgression(fromClassID)
|
|
||||||
toProgression := cu.GetClassProgression(toClassID)
|
|
||||||
|
|
||||||
// Check if the target class is a valid advancement
|
|
||||||
if len(toProgression) <= len(fromProgression) {
|
|
||||||
return false, "Cannot regress to a lower tier class"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the progressions are compatible (share the same base path)
|
|
||||||
for i := 0; i < len(fromProgression); i++ {
|
|
||||||
if i >= len(toProgression) || fromProgression[i] != toProgression[i] {
|
|
||||||
return false, "Incompatible class progression paths"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true, ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetClassAliases returns common aliases for a class
|
|
||||||
func (cu *ClassUtils) GetClassAliases(classID int8) []string {
|
|
||||||
aliases := make([]string, 0)
|
|
||||||
|
|
||||||
switch classID {
|
|
||||||
case ClassShadowknight:
|
|
||||||
aliases = append(aliases, "SK", "Shadow Knight", "Dark Knight")
|
|
||||||
case ClassSwashbuckler:
|
|
||||||
aliases = append(aliases, "Swash", "Swashy")
|
|
||||||
case ClassTroubador:
|
|
||||||
aliases = append(aliases, "Troub", "Troubadour")
|
|
||||||
case ClassIllusionist:
|
|
||||||
aliases = append(aliases, "Illy", "Illusion")
|
|
||||||
case ClassConjuror:
|
|
||||||
aliases = append(aliases, "Conj", "Conjurer")
|
|
||||||
case ClassNecromancer:
|
|
||||||
aliases = append(aliases, "Necro", "Nec")
|
|
||||||
case ClassBeastlord:
|
|
||||||
aliases = append(aliases, "BL", "Beast Lord")
|
|
||||||
case ClassWeaponsmith:
|
|
||||||
aliases = append(aliases, "WS", "Weapon Smith")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Always include the official names
|
|
||||||
aliases = append(aliases, cu.classes.GetClassName(classID))
|
|
||||||
aliases = append(aliases, cu.classes.GetClassNameCase(classID))
|
|
||||||
|
|
||||||
return aliases
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetClassStatistics returns statistics about the class system
|
|
||||||
func (cu *ClassUtils) GetClassStatistics() map[string]any {
|
|
||||||
stats := make(map[string]any)
|
|
||||||
|
|
||||||
allClasses := cu.classes.GetAllClasses()
|
|
||||||
stats["total_classes"] = len(allClasses)
|
|
||||||
|
|
||||||
adventureCount := 0
|
|
||||||
tradeskillCount := 0
|
|
||||||
specialCount := 0
|
|
||||||
|
|
||||||
for classID := range allClasses {
|
|
||||||
switch cu.classes.GetClassType(classID) {
|
|
||||||
case ClassTypeAdventure:
|
|
||||||
adventureCount++
|
|
||||||
case ClassTypeTradeskill:
|
|
||||||
tradeskillCount++
|
|
||||||
default:
|
|
||||||
specialCount++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
stats["adventure_classes"] = adventureCount
|
|
||||||
stats["tradeskill_classes"] = tradeskillCount
|
|
||||||
stats["special_classes"] = specialCount
|
|
||||||
|
|
||||||
// Base class distribution
|
|
||||||
baseClassDistribution := make(map[string][]string)
|
|
||||||
for classID, displayName := range allClasses {
|
|
||||||
if cu.classes.IsAdventureClass(classID) {
|
|
||||||
baseClassID := cu.classes.GetBaseClass(classID)
|
|
||||||
baseClassName := cu.classes.GetClassNameCase(baseClassID)
|
|
||||||
baseClassDistribution[baseClassName] = append(baseClassDistribution[baseClassName], displayName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
stats["base_class_distribution"] = baseClassDistribution
|
|
||||||
|
|
||||||
return stats
|
|
||||||
}
|
|
||||||
|
|
||||||
// FormatClassList returns a formatted string of class names
|
|
||||||
func (cu *ClassUtils) FormatClassList(classIDs []int8, separator string) string {
|
|
||||||
if len(classIDs) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
names := make([]string, len(classIDs))
|
|
||||||
for i, classID := range classIDs {
|
|
||||||
names[i] = cu.classes.GetClassNameCase(classID)
|
|
||||||
}
|
|
||||||
|
|
||||||
return strings.Join(names, separator)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetEQClassName returns the EQ-style class name for a given class and level
|
|
||||||
// This is a placeholder for the original C++ GetEQClassName functionality
|
|
||||||
func (cu *ClassUtils) GetEQClassName(classID int8, level int8) string {
|
|
||||||
// TODO: Implement level-based class names when level system is available
|
|
||||||
// For now, just return the display name
|
|
||||||
return cu.classes.GetClassNameCase(classID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetStartingClass returns the appropriate starting class for character creation
|
|
||||||
func (cu *ClassUtils) GetStartingClass() int8 {
|
|
||||||
return ClassCommoner
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsBaseClass checks if a class is a base class (Fighter, Priest, Mage, Scout)
|
|
||||||
func (cu *ClassUtils) IsBaseClass(classID int8) bool {
|
|
||||||
return classID == ClassFighter || classID == ClassPriest || classID == ClassMage || classID == ClassScout
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsSecondaryBaseClass checks if a class is a secondary base class
|
|
||||||
func (cu *ClassUtils) IsSecondaryBaseClass(classID int8) bool {
|
|
||||||
// Check if any class has this as their secondary base
|
|
||||||
allClasses := cu.classes.GetAllClasses()
|
|
||||||
for checkClassID := range allClasses {
|
|
||||||
if cu.classes.GetSecondaryBaseClass(checkClassID) == classID && checkClassID != classID {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
Loading…
x
Reference in New Issue
Block a user