Compare commits

..

2 Commits

Author SHA1 Message Date
a47ad4f737 modernize class package 2025-08-07 17:17:44 -05:00
79ee999150 modernize chat package 2025-08-07 17:09:30 -05:00
22 changed files with 2160 additions and 6202 deletions

View File

@ -1,226 +0,0 @@
# Chat System
The chat system provides comprehensive channel-based communication for EverQuest II server emulation, converted from the original C++ EQ2EMu implementation.
## Overview
The chat system manages multiple chat channels with membership, access control, and message routing capabilities. It supports both persistent world channels (loaded from database) and temporary custom channels (created by players).
## Architecture
### Core Components
**ChatManager** - Main chat system coordinator managing multiple channels
**Channel** - Individual channel implementation with membership and messaging
**ChatService** - High-level service interface for chat operations
**DatabaseChannelManager** - Database persistence layer for world channels
### Key Features
- **Channel Types**: World (persistent) and Custom (temporary) channels
- **Access Control**: Level, race, and class restrictions with bitmask filtering
- **Password Protection**: Optional password protection for channels
- **Language Integration**: Multilingual chat processing with language comprehension
- **Discord Integration**: Optional Discord webhook bridge for specific channels
- **Thread Safety**: All operations use proper Go concurrency patterns
## Channel Types
### World Channels
- Persistent channels loaded from database at server startup
- Cannot be deleted by players
- Configured with access restrictions (level, race, class)
- Examples: "Auction", "Level_1-9", "Trade"
### Custom Channels
- Created dynamically by players
- Automatically deleted when empty for 5+ minutes
- Support optional password protection
- Player-controlled membership
## Database Schema
```sql
CREATE TABLE channels (
name TEXT PRIMARY KEY,
password TEXT,
level_restriction INTEGER NOT NULL DEFAULT 0,
classes INTEGER NOT NULL DEFAULT 0,
races INTEGER NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
```
## Access Control
### Level Restrictions
- Minimum level required to join channel
- 0 = no level restriction
### Race Restrictions (Bitmask)
- Bit position corresponds to race ID
- 0 = all races allowed
- Example: `(1 << raceID) & raceMask` checks if race is allowed
### Class Restrictions (Bitmask)
- Bit position corresponds to class ID
- 0 = all classes allowed
- Example: `(1 << classID) & classMask` checks if class is allowed
## Usage Examples
### Basic Operations
```go
// Initialize chat service
service := NewChatService(database, clientManager, playerManager, languageProcessor)
err := service.Initialize(ctx)
// Create custom channel
err := service.CreateChannel("MyChannel", "password123")
// Join channel
err := service.JoinChannel(characterID, "MyChannel", "password123")
// Send message
err := service.SendChannelMessage(characterID, "MyChannel", "Hello everyone!")
// Leave channel
err := service.LeaveChannel(characterID, "MyChannel")
```
### Channel Commands
```go
// Process channel command from client
err := service.ProcessChannelCommand(characterID, "join", "Auction")
err := service.ProcessChannelCommand(characterID, "tell", "Auction", "WTS", "Epic", "Sword")
err := service.ProcessChannelCommand(characterID, "who", "Auction")
err := service.ProcessChannelCommand(characterID, "leave", "Auction")
```
### Admin Operations
```go
// Get channel statistics
stats := service.GetStatistics()
fmt.Printf("Total channels: %d, Active: %d\n", stats.TotalChannels, stats.ActiveChannels)
// Broadcast system message
err := service.BroadcastSystemMessage("Auction", "Server restart in 10 minutes", "System")
// Cleanup empty channels
removed := service.CleanupEmptyChannels()
```
## Integration Interfaces
### ClientManager
Handles client communication for channel lists, messages, and updates.
### PlayerManager
Provides player information for access control and message routing.
### LanguageProcessor
Processes multilingual messages with language comprehension checks.
### ChannelDatabase
Manages persistent storage of world channels and configuration.
## Protocol Integration
### Packet Types
- `WS_AvailWorldChannels` - Channel list for client
- `WS_ChatChannelUpdate` - Join/leave notifications
- `WS_HearChat` - Channel message delivery
- `WS_WhoChannelQueryReply` - User list responses
### Channel Actions
- `CHAT_CHANNEL_JOIN` (0) - Player joins channel
- `CHAT_CHANNEL_LEAVE` (1) - Player leaves channel
- `CHAT_CHANNEL_OTHER_JOIN` (2) - Another player joins
- `CHAT_CHANNEL_OTHER_LEAVE` (3) - Another player leaves
## Language Support
The chat system integrates with the language system for multilingual communication:
- Messages are processed based on sender's default language
- Recipients receive scrambled text for unknown languages
- Language comprehension is checked per message
- Proper language ID tracking for all communications
## Discord Integration (Optional)
Channels can be configured for Discord webhook integration:
- Bidirectional chat bridge (EQ2 ↔ Discord)
- Configurable per channel via rules system
- Webhook-based implementation for simplicity
- Server name and character name formatting
## Thread Safety
All operations are thread-safe using:
- `sync.RWMutex` for read/write operations
- Atomic operations where appropriate
- Proper locking hierarchies to prevent deadlocks
- Channel-level and manager-level synchronization
## Performance Considerations
- Channel lookups use case-insensitive maps
- Member lists use slices with efficient removal
- Read operations use read locks for concurrency
- Database operations are context-aware with timeouts
## Error Handling
The system provides comprehensive error handling:
- Channel not found errors
- Access denied errors (level/race/class restrictions)
- Password validation errors
- Database connection errors
- Language processing errors
## Future Enhancements
Areas marked for future implementation:
- Advanced Discord bot integration
- Channel moderation features
- Message history and logging
- Channel-specific emote support
- Advanced filtering and search
- Cross-server channel support
## File Structure
```
internal/chat/
├── README.md # This documentation
├── constants.go # Channel constants and limits
├── types.go # Core data structures
├── interfaces.go # Integration interfaces
├── chat.go # Main ChatManager implementation
├── channel.go # Channel implementation
├── database.go # Database operations
├── manager.go # High-level ChatService
└── channel/
└── channel.go # Standalone channel package
```
## Dependencies
- `eq2emu/internal/database` - Database wrapper
- `eq2emu/internal/languages` - Language processing
- Standard library: `context`, `sync`, `strings`, `time`
## Testing
The chat system is designed for comprehensive testing:
- Mock interfaces for all dependencies
- Isolated channel testing
- Concurrent operation testing
- Database integration testing
- Error condition testing

View File

@ -3,118 +3,179 @@ package chat
import (
"fmt"
"slices"
"time"
"eq2emu/internal/database"
)
// NewChannel creates a new channel instance
func NewChannel(name string) *Channel {
// Channel represents a chat channel with membership and message routing capabilities
type Channel struct {
ID int32 `json:"id"` // Channel ID
Name string `json:"name"` // Channel name
Password string `json:"-"` // Channel password (hidden from JSON)
ChannelType int `json:"type"` // Channel type (world/custom)
LevelRestriction int32 `json:"level_restriction"`
RaceRestriction int32 `json:"race_restriction"`
ClassRestriction int32 `json:"class_restriction"`
DiscordEnabled bool `json:"discord_enabled"`
Created time.Time `json:"created"`
Updated time.Time `json:"updated"`
members []int32 `json:"-"` // Character IDs (not persisted)
db *database.Database `json:"-"` // Database connection
isNew bool `json:"-"` // Whether this is a new channel
}
// New creates a new channel with the given database
func New(db *database.Database) *Channel {
return &Channel{
name: name,
db: db,
isNew: true,
members: make([]int32, 0),
Created: time.Now(),
Updated: time.Now(),
}
}
// NewWithData creates a new channel with data
func NewWithData(id int32, name string, channelType int, db *database.Database) *Channel {
return &Channel{
ID: id,
Name: name,
ChannelType: channelType,
db: db,
isNew: true,
members: make([]int32, 0),
Created: time.Now(),
Updated: time.Now(),
}
}
// Load loads a channel by ID from the database
func Load(db *database.Database, id int32) (*Channel, error) {
channel := &Channel{
db: db,
isNew: false,
members: make([]int32, 0),
}
query := `SELECT id, name, password, type, level_restriction, race_restriction, class_restriction, discord_enabled, created_at, updated_at FROM channels WHERE id = ?`
row := db.QueryRow(query, id)
err := row.Scan(&channel.ID, &channel.Name, &channel.Password, &channel.ChannelType,
&channel.LevelRestriction, &channel.RaceRestriction, &channel.ClassRestriction,
&channel.DiscordEnabled, &channel.Created, &channel.Updated)
if err != nil {
return nil, fmt.Errorf("failed to load channel %d: %w", id, err)
}
return channel, nil
}
// LoadByName loads a channel by name from the database
func LoadByName(db *database.Database, name string) (*Channel, error) {
channel := &Channel{
db: db,
isNew: false,
members: make([]int32, 0),
}
query := `SELECT id, name, password, type, level_restriction, race_restriction, class_restriction, discord_enabled, created_at, updated_at FROM channels WHERE name = ?`
row := db.QueryRow(query, name)
err := row.Scan(&channel.ID, &channel.Name, &channel.Password, &channel.ChannelType,
&channel.LevelRestriction, &channel.RaceRestriction, &channel.ClassRestriction,
&channel.DiscordEnabled, &channel.Created, &channel.Updated)
if err != nil {
return nil, fmt.Errorf("failed to load channel %s: %w", name, err)
}
return channel, nil
}
// GetID returns the channel ID (implements Identifiable interface)
func (c *Channel) GetID() int32 {
return c.ID
}
// SetName sets the channel name
func (c *Channel) SetName(name string) {
c.mu.Lock()
defer c.mu.Unlock()
c.name = name
c.Name = name
c.Updated = time.Now()
}
// SetPassword sets the channel password
func (c *Channel) SetPassword(password string) {
c.mu.Lock()
defer c.mu.Unlock()
c.password = password
c.Password = password
c.Updated = time.Now()
}
// SetType sets the channel type
func (c *Channel) SetType(channelType int) {
c.mu.Lock()
defer c.mu.Unlock()
c.channelType = channelType
c.ChannelType = channelType
c.Updated = time.Now()
}
// SetLevelRestriction sets the minimum level required to join
func (c *Channel) SetLevelRestriction(level int32) {
c.mu.Lock()
defer c.mu.Unlock()
c.levelRestriction = level
c.LevelRestriction = level
c.Updated = time.Now()
}
// SetRacesAllowed sets the race bitmask for allowed races
func (c *Channel) SetRacesAllowed(races int32) {
c.mu.Lock()
defer c.mu.Unlock()
c.raceRestriction = races
c.RaceRestriction = races
c.Updated = time.Now()
}
// SetClassesAllowed sets the class bitmask for allowed classes
func (c *Channel) SetClassesAllowed(classes int32) {
c.mu.Lock()
defer c.mu.Unlock()
c.classRestriction = classes
c.ClassRestriction = classes
c.Updated = time.Now()
}
// GetName returns the channel name
func (c *Channel) GetName() string {
c.mu.RLock()
defer c.mu.RUnlock()
return c.name
return c.Name
}
// GetType returns the channel type
func (c *Channel) GetType() int {
c.mu.RLock()
defer c.mu.RUnlock()
return c.channelType
return c.ChannelType
}
// GetNumClients returns the number of clients in the channel
func (c *Channel) GetNumClients() int {
c.mu.RLock()
defer c.mu.RUnlock()
return len(c.members)
}
// HasPassword returns true if the channel has a password
func (c *Channel) HasPassword() bool {
c.mu.RLock()
defer c.mu.RUnlock()
return c.password != ""
return c.Password != ""
}
// PasswordMatches checks if the provided password matches the channel password
func (c *Channel) PasswordMatches(password string) bool {
c.mu.RLock()
defer c.mu.RUnlock()
return c.password == password
return c.Password == password
}
// CanJoinChannelByLevel checks if a player's level meets the channel requirements
func (c *Channel) CanJoinChannelByLevel(level int32) bool {
c.mu.RLock()
defer c.mu.RUnlock()
return level >= c.levelRestriction
return level >= c.LevelRestriction
}
// CanJoinChannelByRace checks if a player's race is allowed in the channel
func (c *Channel) CanJoinChannelByRace(raceID int32) bool {
c.mu.RLock()
defer c.mu.RUnlock()
return c.raceRestriction == NoRaceRestriction || (c.raceRestriction&(1<<raceID)) != 0
return c.RaceRestriction == NoRaceRestriction || (c.RaceRestriction&(1<<raceID)) != 0
}
// CanJoinChannelByClass checks if a player's class is allowed in the channel
func (c *Channel) CanJoinChannelByClass(classID int32) bool {
c.mu.RLock()
defer c.mu.RUnlock()
return c.classRestriction == NoClassRestriction || (c.classRestriction&(1<<classID)) != 0
return c.ClassRestriction == NoClassRestriction || (c.ClassRestriction&(1<<classID)) != 0
}
// IsInChannel checks if a character is in the channel
func (c *Channel) IsInChannel(characterID int32) bool {
c.mu.RLock()
defer c.mu.RUnlock()
return c.isInChannel(characterID)
}
@ -125,8 +186,6 @@ func (c *Channel) isInChannel(characterID int32) bool {
// JoinChannel adds a character to the channel
func (c *Channel) JoinChannel(characterID int32) error {
c.mu.Lock()
defer c.mu.Unlock()
return c.joinChannel(characterID)
}
@ -134,7 +193,7 @@ func (c *Channel) JoinChannel(characterID int32) error {
func (c *Channel) joinChannel(characterID int32) error {
// Check if already in channel
if c.isInChannel(characterID) {
return fmt.Errorf("character %d is already in channel %s", characterID, c.name)
return fmt.Errorf("character %d is already in channel %s", characterID, c.Name)
}
// Add to members list
@ -144,8 +203,6 @@ func (c *Channel) joinChannel(characterID int32) error {
// LeaveChannel removes a character from the channel
func (c *Channel) LeaveChannel(characterID int32) error {
c.mu.Lock()
defer c.mu.Unlock()
return c.leaveChannel(characterID)
}
@ -161,14 +218,11 @@ func (c *Channel) leaveChannel(characterID int32) error {
}
}
return fmt.Errorf("character %d is not in channel %s", characterID, c.name)
return fmt.Errorf("character %d is not in channel %s", characterID, c.Name)
}
// GetMembers returns a copy of the current member list
func (c *Channel) GetMembers() []int32 {
c.mu.RLock()
defer c.mu.RUnlock()
// Return a copy to prevent external modification
members := make([]int32, len(c.members))
copy(members, c.members)
@ -177,44 +231,38 @@ func (c *Channel) GetMembers() []int32 {
// GetChannelInfo returns basic channel information
func (c *Channel) GetChannelInfo() ChannelInfo {
c.mu.RLock()
defer c.mu.RUnlock()
return ChannelInfo{
Name: c.name,
HasPassword: c.password != "",
Name: c.Name,
HasPassword: c.Password != "",
MemberCount: len(c.members),
LevelRestriction: c.levelRestriction,
RaceRestriction: c.raceRestriction,
ClassRestriction: c.classRestriction,
ChannelType: c.channelType,
LevelRestriction: c.LevelRestriction,
RaceRestriction: c.RaceRestriction,
ClassRestriction: c.ClassRestriction,
ChannelType: c.ChannelType,
}
}
// ValidateJoin checks if a character can join the channel based on restrictions
func (c *Channel) ValidateJoin(level, race, class int32, password string) error {
c.mu.RLock()
defer c.mu.RUnlock()
// Check password
if c.password != "" && c.password != password {
return fmt.Errorf("invalid password for channel %s", c.name)
if c.Password != "" && c.Password != password {
return fmt.Errorf("invalid password for channel %s", c.Name)
}
// Check level restriction
if !c.CanJoinChannelByLevel(level) {
return fmt.Errorf("level %d does not meet minimum requirement of %d for channel %s",
level, c.levelRestriction, c.name)
level, c.LevelRestriction, c.Name)
}
// Check race restriction
if !c.CanJoinChannelByRace(race) {
return fmt.Errorf("race %d is not allowed in channel %s", race, c.name)
return fmt.Errorf("race %d is not allowed in channel %s", race, c.Name)
}
// Check class restriction
if !c.CanJoinChannelByClass(class) {
return fmt.Errorf("class %d is not allowed in channel %s", class, c.name)
return fmt.Errorf("class %d is not allowed in channel %s", class, c.Name)
}
return nil
@ -222,28 +270,129 @@ func (c *Channel) ValidateJoin(level, race, class int32, password string) error
// IsEmpty returns true if the channel has no members
func (c *Channel) IsEmpty() bool {
c.mu.RLock()
defer c.mu.RUnlock()
return len(c.members) == 0
}
// Copy creates a deep copy of the channel (useful for serialization or backups)
func (c *Channel) Copy() *Channel {
c.mu.RLock()
defer c.mu.RUnlock()
newChannel := &Channel{
name: c.name,
password: c.password,
channelType: c.channelType,
levelRestriction: c.levelRestriction,
raceRestriction: c.raceRestriction,
classRestriction: c.classRestriction,
discordEnabled: c.discordEnabled,
created: c.created,
ID: c.ID,
Name: c.Name,
Password: c.Password,
ChannelType: c.ChannelType,
LevelRestriction: c.LevelRestriction,
RaceRestriction: c.RaceRestriction,
ClassRestriction: c.ClassRestriction,
DiscordEnabled: c.DiscordEnabled,
Created: c.Created,
Updated: c.Updated,
db: c.db,
isNew: true, // Copy is always new
members: make([]int32, len(c.members)),
}
copy(newChannel.members, c.members)
return newChannel
}
// IsNew returns true if this is a new channel not yet saved to database
func (c *Channel) IsNew() bool {
return c.isNew
}
// Save saves the channel to the database
func (c *Channel) Save() error {
if c.db == nil {
return fmt.Errorf("no database connection available")
}
if c.isNew {
return c.insert()
}
return c.update()
}
// Delete removes the channel from the database
func (c *Channel) Delete() error {
if c.db == nil {
return fmt.Errorf("no database connection available")
}
if c.isNew {
return fmt.Errorf("cannot delete unsaved channel")
}
query := `DELETE FROM channels WHERE id = ?`
_, err := c.db.Exec(query, c.ID)
if err != nil {
return fmt.Errorf("failed to delete channel %d: %w", c.ID, err)
}
return nil
}
// Reload reloads the channel data from the database
func (c *Channel) Reload() error {
if c.db == nil {
return fmt.Errorf("no database connection available")
}
if c.isNew {
return fmt.Errorf("cannot reload unsaved channel")
}
query := `SELECT name, password, type, level_restriction, race_restriction, class_restriction, discord_enabled, created_at, updated_at FROM channels WHERE id = ?`
row := c.db.QueryRow(query, c.ID)
err := row.Scan(&c.Name, &c.Password, &c.ChannelType, &c.LevelRestriction,
&c.RaceRestriction, &c.ClassRestriction, &c.DiscordEnabled, &c.Created, &c.Updated)
if err != nil {
return fmt.Errorf("failed to reload channel %d: %w", c.ID, err)
}
return nil
}
// insert inserts a new channel into the database
func (c *Channel) insert() error {
query := `INSERT INTO channels (name, password, type, level_restriction, race_restriction, class_restriction, discord_enabled, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
result, err := c.db.Exec(query, c.Name, c.Password, c.ChannelType, c.LevelRestriction,
c.RaceRestriction, c.ClassRestriction, c.DiscordEnabled, c.Created, c.Updated)
if err != nil {
return fmt.Errorf("failed to insert channel: %w", err)
}
// Get the generated ID
id, err := result.LastInsertId()
if err != nil {
return fmt.Errorf("failed to get inserted channel ID: %w", err)
}
c.ID = int32(id)
c.isNew = false
return nil
}
// update updates an existing channel in the database
func (c *Channel) update() error {
c.Updated = time.Now()
query := `UPDATE channels SET name = ?, password = ?, type = ?, level_restriction = ?,
race_restriction = ?, class_restriction = ?, discord_enabled = ?, updated_at = ?
WHERE id = ?`
result, err := c.db.Exec(query, c.Name, c.Password, c.ChannelType, c.LevelRestriction,
c.RaceRestriction, c.ClassRestriction, c.DiscordEnabled, c.Updated, c.ID)
if err != nil {
return fmt.Errorf("failed to update channel: %w", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("failed to get rows affected: %w", err)
}
if rowsAffected == 0 {
return fmt.Errorf("channel %d not found for update", c.ID)
}
return nil
}

View File

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

View File

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

View File

@ -1,455 +0,0 @@
package chat
import (
"context"
"fmt"
"strings"
"time"
)
// NewChatManager creates a new chat manager instance
func NewChatManager(database ChannelDatabase, clientManager ClientManager, playerManager PlayerManager, languageProcessor LanguageProcessor) *ChatManager {
return &ChatManager{
channels: make(map[string]*Channel),
database: database,
clientManager: clientManager,
playerManager: playerManager,
languageProcessor: languageProcessor,
}
}
// Initialize loads world channels from database and prepares the chat system
func (cm *ChatManager) Initialize(ctx context.Context) error {
cm.mu.Lock()
defer cm.mu.Unlock()
// Load world channels from database
worldChannels, err := cm.database.LoadWorldChannels(ctx)
if err != nil {
return fmt.Errorf("failed to load world channels: %w", err)
}
// Create world channels
for _, channelData := range worldChannels {
channel := &Channel{
name: channelData.Name,
password: channelData.Password,
channelType: ChannelTypeWorld,
levelRestriction: channelData.LevelRestriction,
raceRestriction: channelData.RaceRestriction,
classRestriction: channelData.ClassRestriction,
members: make([]int32, 0),
created: time.Now(),
}
cm.channels[strings.ToLower(channelData.Name)] = channel
}
return nil
}
// AddChannel adds a new channel to the manager (used for world channels loaded from database)
func (cm *ChatManager) AddChannel(channel *Channel) {
cm.mu.Lock()
defer cm.mu.Unlock()
cm.channels[strings.ToLower(channel.name)] = channel
}
// GetNumChannels returns the total number of channels
func (cm *ChatManager) GetNumChannels() int {
cm.mu.RLock()
defer cm.mu.RUnlock()
return len(cm.channels)
}
// GetWorldChannelList returns filtered list of world channels for a client
func (cm *ChatManager) GetWorldChannelList(characterID int32) ([]ChannelInfo, error) {
cm.mu.RLock()
defer cm.mu.RUnlock()
playerInfo, err := cm.playerManager.GetPlayerInfo(characterID)
if err != nil {
return nil, fmt.Errorf("failed to get player info: %w", err)
}
var channelList []ChannelInfo
for _, channel := range cm.channels {
if channel.channelType == ChannelTypeWorld {
// Check if player can join based on restrictions
if cm.canJoinChannel(playerInfo.Level, playerInfo.Race, playerInfo.Class,
channel.levelRestriction, channel.raceRestriction, channel.classRestriction) {
channelInfo := ChannelInfo{
Name: channel.name,
HasPassword: channel.password != "",
MemberCount: len(channel.members),
LevelRestriction: channel.levelRestriction,
RaceRestriction: channel.raceRestriction,
ClassRestriction: channel.classRestriction,
ChannelType: channel.channelType,
}
channelList = append(channelList, channelInfo)
}
}
}
return channelList, nil
}
// ChannelExists checks if a channel with the given name exists
func (cm *ChatManager) ChannelExists(channelName string) bool {
cm.mu.RLock()
defer cm.mu.RUnlock()
_, exists := cm.channels[strings.ToLower(channelName)]
return exists
}
// HasPassword checks if a channel has a password
func (cm *ChatManager) HasPassword(channelName string) bool {
cm.mu.RLock()
defer cm.mu.RUnlock()
if channel, exists := cm.channels[strings.ToLower(channelName)]; exists {
return channel.password != ""
}
return false
}
// PasswordMatches checks if the provided password matches the channel password
func (cm *ChatManager) PasswordMatches(channelName, password string) bool {
cm.mu.RLock()
defer cm.mu.RUnlock()
if channel, exists := cm.channels[strings.ToLower(channelName)]; exists {
return channel.password == password
}
return false
}
// CreateChannel creates a new custom channel
func (cm *ChatManager) CreateChannel(channelName string, password ...string) error {
if len(channelName) > MaxChannelNameLength {
return fmt.Errorf("channel name too long: %d > %d", len(channelName), MaxChannelNameLength)
}
cm.mu.Lock()
defer cm.mu.Unlock()
// Check if channel already exists
if _, exists := cm.channels[strings.ToLower(channelName)]; exists {
return fmt.Errorf("channel %s already exists", channelName)
}
// Create new custom channel
channel := &Channel{
name: channelName,
channelType: ChannelTypeCustom,
members: make([]int32, 0),
created: time.Now(),
}
// Set password if provided
if len(password) > 0 && password[0] != "" {
if len(password[0]) > MaxChannelPasswordLength {
return fmt.Errorf("channel password too long: %d > %d", len(password[0]), MaxChannelPasswordLength)
}
channel.password = password[0]
}
cm.channels[strings.ToLower(channelName)] = channel
return nil
}
// IsInChannel checks if a character is in the specified channel
func (cm *ChatManager) IsInChannel(characterID int32, channelName string) bool {
cm.mu.RLock()
defer cm.mu.RUnlock()
if channel, exists := cm.channels[strings.ToLower(channelName)]; exists {
return channel.isInChannel(characterID)
}
return false
}
// JoinChannel adds a character to a channel
func (cm *ChatManager) JoinChannel(characterID int32, channelName string, password ...string) error {
cm.mu.Lock()
defer cm.mu.Unlock()
channel, exists := cm.channels[strings.ToLower(channelName)]
if !exists {
return fmt.Errorf("channel %s does not exist", channelName)
}
// Check password if channel has one
if channel.password != "" {
if len(password) == 0 || password[0] != channel.password {
return fmt.Errorf("invalid password for channel %s", channelName)
}
}
// Get player info for validation
playerInfo, err := cm.playerManager.GetPlayerInfo(characterID)
if err != nil {
return fmt.Errorf("failed to get player info: %w", err)
}
// Validate restrictions
if !cm.canJoinChannel(playerInfo.Level, playerInfo.Race, playerInfo.Class,
channel.levelRestriction, channel.raceRestriction, channel.classRestriction) {
return fmt.Errorf("player does not meet channel requirements")
}
// Add to channel
if err := channel.joinChannel(characterID); err != nil {
return err
}
// Notify all channel members of the join
cm.notifyChannelUpdate(channelName, ChatChannelOtherJoin, playerInfo.CharacterName, characterID)
return nil
}
// LeaveChannel removes a character from a channel
func (cm *ChatManager) LeaveChannel(characterID int32, channelName string) error {
cm.mu.Lock()
defer cm.mu.Unlock()
channel, exists := cm.channels[strings.ToLower(channelName)]
if !exists {
return fmt.Errorf("channel %s does not exist", channelName)
}
// Get player info for notification
playerInfo, err := cm.playerManager.GetPlayerInfo(characterID)
if err != nil {
return fmt.Errorf("failed to get player info: %w", err)
}
// Remove from channel
if err := channel.leaveChannel(characterID); err != nil {
return err
}
// Delete custom channels with no members
if channel.channelType == ChannelTypeCustom && len(channel.members) == 0 {
delete(cm.channels, strings.ToLower(channelName))
}
// Notify all remaining channel members of the leave
cm.notifyChannelUpdate(channelName, ChatChannelOtherLeave, playerInfo.CharacterName, characterID)
return nil
}
// LeaveAllChannels removes a character from all channels they're in
func (cm *ChatManager) LeaveAllChannels(characterID int32) error {
cm.mu.Lock()
defer cm.mu.Unlock()
playerInfo, err := cm.playerManager.GetPlayerInfo(characterID)
if err != nil {
return fmt.Errorf("failed to get player info: %w", err)
}
// Find all channels the player is in and remove them
var channelsToDelete []string
for channelName, channel := range cm.channels {
if channel.isInChannel(characterID) {
channel.leaveChannel(characterID)
// Mark custom channels with no members for deletion
if channel.channelType == ChannelTypeCustom && len(channel.members) == 0 {
channelsToDelete = append(channelsToDelete, channelName)
} else {
// Notify remaining members
cm.notifyChannelUpdate(channel.name, ChatChannelOtherLeave, playerInfo.CharacterName, characterID)
}
}
}
// Delete empty custom channels
for _, channelName := range channelsToDelete {
delete(cm.channels, channelName)
}
return nil
}
// TellChannel sends a message to all members of a channel
func (cm *ChatManager) TellChannel(senderID int32, channelName, message string, customName ...string) error {
cm.mu.RLock()
defer cm.mu.RUnlock()
channel, exists := cm.channels[strings.ToLower(channelName)]
if !exists {
return fmt.Errorf("channel %s does not exist", channelName)
}
// Check if sender is in channel (unless it's a system message)
if senderID != 0 && !channel.isInChannel(senderID) {
return fmt.Errorf("sender is not in channel %s", channelName)
}
// Get sender info
var senderName string
var languageID int32
if senderID != 0 {
playerInfo, err := cm.playerManager.GetPlayerInfo(senderID)
if err != nil {
return fmt.Errorf("failed to get sender info: %w", err)
}
senderName = playerInfo.CharacterName
// Get sender's default language
if cm.languageProcessor != nil {
languageID = cm.languageProcessor.GetDefaultLanguage(senderID)
}
}
// Use custom name if provided (for system messages)
if len(customName) > 0 && customName[0] != "" {
senderName = customName[0]
}
// Create message
chatMessage := ChannelMessage{
SenderID: senderID,
SenderName: senderName,
Message: message,
LanguageID: languageID,
ChannelName: channelName,
Timestamp: time.Now(),
}
// Send to all channel members
return cm.deliverChannelMessage(channel, chatMessage)
}
// SendChannelUserList sends the list of users in a channel to a client
func (cm *ChatManager) SendChannelUserList(requesterID int32, channelName string) error {
cm.mu.RLock()
defer cm.mu.RUnlock()
channel, exists := cm.channels[strings.ToLower(channelName)]
if !exists {
return fmt.Errorf("channel %s does not exist", channelName)
}
// Check if requester is in channel
if !channel.isInChannel(requesterID) {
return fmt.Errorf("requester is not in channel %s", channelName)
}
// Build member list
var members []ChannelMember
for _, memberID := range channel.members {
if playerInfo, err := cm.playerManager.GetPlayerInfo(memberID); err == nil {
member := ChannelMember{
CharacterID: memberID,
CharacterName: playerInfo.CharacterName,
Level: playerInfo.Level,
Race: playerInfo.Race,
Class: playerInfo.Class,
JoinedAt: time.Now(), // TODO: Track actual join time
}
members = append(members, member)
}
}
// Send user list to requester
return cm.clientManager.SendChannelUserList(requesterID, channelName, members)
}
// GetChannel returns a channel by name (for internal use)
func (cm *ChatManager) GetChannel(channelName string) *Channel {
cm.mu.RLock()
defer cm.mu.RUnlock()
return cm.channels[strings.ToLower(channelName)]
}
// GetStatistics returns chat system statistics
func (cm *ChatManager) GetStatistics() ChatStatistics {
cm.mu.RLock()
defer cm.mu.RUnlock()
stats := ChatStatistics{
TotalChannels: len(cm.channels),
}
for _, channel := range cm.channels {
switch channel.channelType {
case ChannelTypeWorld:
stats.WorldChannels++
case ChannelTypeCustom:
stats.CustomChannels++
}
stats.TotalMembers += len(channel.members)
if len(channel.members) > 0 {
stats.ActiveChannels++
}
}
return stats
}
// Helper methods
// canJoinChannel checks if a player meets channel requirements
func (cm *ChatManager) canJoinChannel(playerLevel, playerRace, playerClass, levelReq, raceReq, classReq int32) bool {
// Check level restriction
if levelReq > NoLevelRestriction && playerLevel < levelReq {
return false
}
// Check race restriction (bitmask)
if raceReq > NoRaceRestriction && (raceReq&(1<<playerRace)) == 0 {
return false
}
// Check class restriction (bitmask)
if classReq > NoClassRestriction && (classReq&(1<<playerClass)) == 0 {
return false
}
return true
}
// notifyChannelUpdate sends channel update notifications to all members
func (cm *ChatManager) notifyChannelUpdate(channelName string, action int, characterName string, excludeCharacterID int32) {
if channel, exists := cm.channels[strings.ToLower(channelName)]; exists {
for _, memberID := range channel.members {
if memberID != excludeCharacterID {
cm.clientManager.SendChannelUpdate(memberID, channelName, action, characterName)
}
}
}
}
// deliverChannelMessage processes and delivers a message to all channel members
func (cm *ChatManager) deliverChannelMessage(channel *Channel, message ChannelMessage) error {
for _, memberID := range channel.members {
// Process message for language if language processor is available
processedMessage := message
if cm.languageProcessor != nil && message.SenderID != 0 {
if processedText, err := cm.languageProcessor.ProcessMessage(
message.SenderID, memberID, message.Message, message.LanguageID); err == nil {
processedMessage.Message = processedText
}
}
// Send message to member
if err := cm.clientManager.SendChannelMessage(memberID, processedMessage); err != nil {
// Log error but continue sending to other members
// TODO: Add proper logging
}
}
return nil
}

View File

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

View File

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

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

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

View File

@ -1,122 +0,0 @@
package chat
import "context"
// ChannelDatabase defines database operations for chat channels
type ChannelDatabase interface {
// LoadWorldChannels retrieves all persistent world channels from database
LoadWorldChannels(ctx context.Context) ([]ChatChannelData, error)
// SaveChannel persists a channel to database (world channels only)
SaveChannel(ctx context.Context, channel ChatChannelData) error
// DeleteChannel removes a channel from database
DeleteChannel(ctx context.Context, channelName string) error
}
// ClientManager handles client communication for chat system
type ClientManager interface {
// SendChannelList sends available channels to a client
SendChannelList(characterID int32, channels []ChannelInfo) error
// SendChannelMessage delivers a message to a client
SendChannelMessage(characterID int32, message ChannelMessage) error
// SendChannelUpdate notifies client of channel membership changes
SendChannelUpdate(characterID int32, channelName string, action int, characterName string) error
// SendChannelUserList sends who list to client
SendChannelUserList(characterID int32, channelName string, members []ChannelMember) error
// IsClientConnected checks if a character is currently online
IsClientConnected(characterID int32) bool
}
// PlayerManager provides player information for chat system
type PlayerManager interface {
// GetPlayerInfo retrieves basic player information
GetPlayerInfo(characterID int32) (PlayerInfo, error)
// ValidatePlayer checks if player meets channel requirements
ValidatePlayer(characterID int32, levelReq, raceReq, classReq int32) bool
// GetPlayerLanguages returns languages known by player
GetPlayerLanguages(characterID int32) ([]int32, error)
}
// LanguageProcessor handles multilingual chat processing
type LanguageProcessor interface {
// ProcessMessage processes a message for language comprehension
ProcessMessage(senderID, receiverID int32, message string, languageID int32) (string, error)
// CanUnderstand checks if receiver can understand sender's language
CanUnderstand(senderID, receiverID int32, languageID int32) bool
// GetDefaultLanguage returns the default language for a character
GetDefaultLanguage(characterID int32) int32
}
// PlayerInfo contains basic player information needed for chat
type PlayerInfo struct {
CharacterID int32
CharacterName string
Level int32
Race int32
Class int32
IsOnline bool
}
// ChatAware interface for entities that can participate in chat
type ChatAware interface {
GetCharacterID() int32
GetCharacterName() string
GetLevel() int32
GetRace() int32
GetClass() int32
}
// EntityChatAdapter adapts entities to work with chat system
type EntityChatAdapter struct {
entity interface {
GetID() int32
// Add other entity methods as needed
}
playerManager PlayerManager
}
// GetCharacterID returns the character ID from the adapted entity
func (a *EntityChatAdapter) GetCharacterID() int32 {
return a.entity.GetID()
}
// GetCharacterName returns the character name from player manager
func (a *EntityChatAdapter) GetCharacterName() string {
if info, err := a.playerManager.GetPlayerInfo(a.entity.GetID()); err == nil {
return info.CharacterName
}
return ""
}
// GetLevel returns the character level from player manager
func (a *EntityChatAdapter) GetLevel() int32 {
if info, err := a.playerManager.GetPlayerInfo(a.entity.GetID()); err == nil {
return info.Level
}
return 0
}
// GetRace returns the character race from player manager
func (a *EntityChatAdapter) GetRace() int32 {
if info, err := a.playerManager.GetPlayerInfo(a.entity.GetID()); err == nil {
return info.Race
}
return 0
}
// GetClass returns the character class from player manager
func (a *EntityChatAdapter) GetClass() int32 {
if info, err := a.playerManager.GetPlayerInfo(a.entity.GetID()); err == nil {
return info.Class
}
return 0
}

View File

@ -1,307 +0,0 @@
package chat
import (
"context"
"fmt"
"strings"
"sync"
"time"
)
// ChatService provides high-level chat system management
type ChatService struct {
manager *ChatManager
mu sync.RWMutex
}
// NewChatService creates a new chat service instance
func NewChatService(database ChannelDatabase, clientManager ClientManager, playerManager PlayerManager, languageProcessor LanguageProcessor) *ChatService {
return &ChatService{
manager: NewChatManager(database, clientManager, playerManager, languageProcessor),
}
}
// Initialize initializes the chat service and loads world channels
func (cs *ChatService) Initialize(ctx context.Context) error {
cs.mu.Lock()
defer cs.mu.Unlock()
return cs.manager.Initialize(ctx)
}
// ProcessChannelCommand processes chat channel commands (join, leave, create, etc.)
func (cs *ChatService) ProcessChannelCommand(characterID int32, command, channelName string, args ...string) error {
cs.mu.RLock()
defer cs.mu.RUnlock()
switch strings.ToLower(command) {
case "join":
password := ""
if len(args) > 0 {
password = args[0]
}
return cs.manager.JoinChannel(characterID, channelName, password)
case "leave":
return cs.manager.LeaveChannel(characterID, channelName)
case "create":
password := ""
if len(args) > 0 {
password = args[0]
}
return cs.manager.CreateChannel(channelName, password)
case "tell", "say":
if len(args) == 0 {
return fmt.Errorf("no message provided")
}
message := strings.Join(args, " ")
return cs.manager.TellChannel(characterID, channelName, message)
case "who", "list":
return cs.manager.SendChannelUserList(characterID, channelName)
default:
return fmt.Errorf("unknown channel command: %s", command)
}
}
// SendChannelMessage sends a message to a channel
func (cs *ChatService) SendChannelMessage(senderID int32, channelName string, message string, customName ...string) error {
cs.mu.RLock()
defer cs.mu.RUnlock()
return cs.manager.TellChannel(senderID, channelName, message, customName...)
}
// JoinChannel adds a character to a channel
func (cs *ChatService) JoinChannel(characterID int32, channelName string, password ...string) error {
cs.mu.RLock()
defer cs.mu.RUnlock()
return cs.manager.JoinChannel(characterID, channelName, password...)
}
// LeaveChannel removes a character from a channel
func (cs *ChatService) LeaveChannel(characterID int32, channelName string) error {
cs.mu.RLock()
defer cs.mu.RUnlock()
return cs.manager.LeaveChannel(characterID, channelName)
}
// LeaveAllChannels removes a character from all channels
func (cs *ChatService) LeaveAllChannels(characterID int32) error {
cs.mu.RLock()
defer cs.mu.RUnlock()
return cs.manager.LeaveAllChannels(characterID)
}
// CreateChannel creates a new custom channel
func (cs *ChatService) CreateChannel(channelName string, password ...string) error {
cs.mu.RLock()
defer cs.mu.RUnlock()
return cs.manager.CreateChannel(channelName, password...)
}
// GetWorldChannelList returns available world channels for a character
func (cs *ChatService) GetWorldChannelList(characterID int32) ([]ChannelInfo, error) {
cs.mu.RLock()
defer cs.mu.RUnlock()
return cs.manager.GetWorldChannelList(characterID)
}
// ChannelExists checks if a channel exists
func (cs *ChatService) ChannelExists(channelName string) bool {
cs.mu.RLock()
defer cs.mu.RUnlock()
return cs.manager.ChannelExists(channelName)
}
// IsInChannel checks if a character is in a channel
func (cs *ChatService) IsInChannel(characterID int32, channelName string) bool {
cs.mu.RLock()
defer cs.mu.RUnlock()
return cs.manager.IsInChannel(characterID, channelName)
}
// GetChannelInfo returns information about a specific channel
func (cs *ChatService) GetChannelInfo(channelName string) (*ChannelInfo, error) {
cs.mu.RLock()
defer cs.mu.RUnlock()
channel := cs.manager.GetChannel(channelName)
if channel == nil {
return nil, fmt.Errorf("channel %s not found", channelName)
}
info := channel.GetChannelInfo()
return &info, nil
}
// GetStatistics returns chat system statistics
func (cs *ChatService) GetStatistics() ChatStatistics {
cs.mu.RLock()
defer cs.mu.RUnlock()
return cs.manager.GetStatistics()
}
// SendChannelUserList sends the user list for a channel to a character
func (cs *ChatService) SendChannelUserList(requesterID int32, channelName string) error {
cs.mu.RLock()
defer cs.mu.RUnlock()
return cs.manager.SendChannelUserList(requesterID, channelName)
}
// ValidateChannelName checks if a channel name is valid
func (cs *ChatService) ValidateChannelName(channelName string) error {
if len(channelName) == 0 {
return fmt.Errorf("channel name cannot be empty")
}
if len(channelName) > MaxChannelNameLength {
return fmt.Errorf("channel name too long: %d > %d", len(channelName), MaxChannelNameLength)
}
// Check for invalid characters
if strings.ContainsAny(channelName, " \t\n\r") {
return fmt.Errorf("channel name cannot contain whitespace")
}
return nil
}
// ValidateChannelPassword checks if a channel password is valid
func (cs *ChatService) ValidateChannelPassword(password string) error {
if len(password) > MaxChannelPasswordLength {
return fmt.Errorf("channel password too long: %d > %d", len(password), MaxChannelPasswordLength)
}
return nil
}
// GetChannelMembers returns the list of members in a channel
func (cs *ChatService) GetChannelMembers(channelName string) ([]int32, error) {
cs.mu.RLock()
defer cs.mu.RUnlock()
channel := cs.manager.GetChannel(channelName)
if channel == nil {
return nil, fmt.Errorf("channel %s not found", channelName)
}
return channel.GetMembers(), nil
}
// CleanupEmptyChannels removes empty custom channels (called periodically)
func (cs *ChatService) CleanupEmptyChannels() int {
cs.mu.Lock()
defer cs.mu.Unlock()
removed := 0
for name, channel := range cs.manager.channels {
if channel.channelType == ChannelTypeCustom && channel.IsEmpty() {
// Check if channel has been empty for a reasonable time
if time.Since(channel.created) > 5*time.Minute {
delete(cs.manager.channels, name)
removed++
}
}
}
return removed
}
// BroadcastSystemMessage sends a system message to all members of a channel
func (cs *ChatService) BroadcastSystemMessage(channelName string, message string, systemName string) error {
cs.mu.RLock()
defer cs.mu.RUnlock()
return cs.manager.TellChannel(0, channelName, message, systemName)
}
// GetActiveChannels returns a list of channels that have active members
func (cs *ChatService) GetActiveChannels() []string {
cs.mu.RLock()
defer cs.mu.RUnlock()
var activeChannels []string
for name, channel := range cs.manager.channels {
if !channel.IsEmpty() {
activeChannels = append(activeChannels, name)
}
}
return activeChannels
}
// GetChannelsByType returns channels of a specific type
func (cs *ChatService) GetChannelsByType(channelType int) []string {
cs.mu.RLock()
defer cs.mu.RUnlock()
var channels []string
for name, channel := range cs.manager.channels {
if channel.channelType == channelType {
channels = append(channels, name)
}
}
return channels
}
// ProcessChannelFilter applies filtering to channel lists based on player criteria
func (cs *ChatService) ProcessChannelFilter(characterID int32, filter ChannelFilter) ([]ChannelInfo, error) {
cs.mu.RLock()
defer cs.mu.RUnlock()
playerInfo, err := cs.manager.playerManager.GetPlayerInfo(characterID)
if err != nil {
return nil, fmt.Errorf("failed to get player info: %w", err)
}
var filteredChannels []ChannelInfo
for _, channel := range cs.manager.channels {
// Apply type filters
if !filter.IncludeWorld && channel.channelType == ChannelTypeWorld {
continue
}
if !filter.IncludeCustom && channel.channelType == ChannelTypeCustom {
continue
}
// Apply level range filters
if filter.MinLevel > 0 && playerInfo.Level < filter.MinLevel {
continue
}
if filter.MaxLevel > 0 && playerInfo.Level > filter.MaxLevel {
continue
}
// Apply race filter
if filter.Race > 0 && playerInfo.Race != filter.Race {
continue
}
// Apply class filter
if filter.Class > 0 && playerInfo.Class != filter.Class {
continue
}
// Check if player can actually join the channel
if cs.manager.canJoinChannel(playerInfo.Level, playerInfo.Race, playerInfo.Class,
channel.levelRestriction, channel.raceRestriction, channel.classRestriction) {
filteredChannels = append(filteredChannels, channel.GetChannelInfo())
}
}
return filteredChannels, nil
}

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

@ -0,0 +1,293 @@
package chat
import (
"fmt"
"strings"
"eq2emu/internal/common"
"eq2emu/internal/database"
)
// MasterList manages a collection of channels using the generic MasterList base
type MasterList struct {
*common.MasterList[int32, *Channel]
}
// NewMasterList creates a new channel master list
func NewMasterList() *MasterList {
return &MasterList{
MasterList: common.NewMasterList[int32, *Channel](),
}
}
// AddChannel adds a channel to the master list
func (ml *MasterList) AddChannel(channel *Channel) bool {
return ml.Add(channel)
}
// GetChannel retrieves a channel by ID
func (ml *MasterList) GetChannel(id int32) *Channel {
return ml.Get(id)
}
// GetChannelSafe retrieves a channel by ID with existence check
func (ml *MasterList) GetChannelSafe(id int32) (*Channel, bool) {
return ml.GetSafe(id)
}
// HasChannel checks if a channel exists by ID
func (ml *MasterList) HasChannel(id int32) bool {
return ml.Exists(id)
}
// RemoveChannel removes a channel by ID
func (ml *MasterList) RemoveChannel(id int32) bool {
return ml.Remove(id)
}
// GetAllChannels returns all channels as a map
func (ml *MasterList) GetAllChannels() map[int32]*Channel {
return ml.GetAll()
}
// GetAllChannelsList returns all channels as a slice
func (ml *MasterList) GetAllChannelsList() []*Channel {
return ml.GetAllSlice()
}
// GetChannelCount returns the number of channels
func (ml *MasterList) GetChannelCount() int {
return ml.Size()
}
// ClearChannels removes all channels from the list
func (ml *MasterList) ClearChannels() {
ml.Clear()
}
// FindChannelsByName finds channels containing the given name substring
func (ml *MasterList) FindChannelsByName(nameSubstring string) []*Channel {
return ml.Filter(func(channel *Channel) bool {
return contains(channel.GetName(), nameSubstring)
})
}
// FindChannelsByType finds channels of a specific type
func (ml *MasterList) FindChannelsByType(channelType int) []*Channel {
return ml.Filter(func(channel *Channel) bool {
return channel.GetType() == channelType
})
}
// GetWorldChannels returns all world channels
func (ml *MasterList) GetWorldChannels() []*Channel {
return ml.FindChannelsByType(ChannelTypeWorld)
}
// GetCustomChannels returns all custom channels
func (ml *MasterList) GetCustomChannels() []*Channel {
return ml.FindChannelsByType(ChannelTypeCustom)
}
// GetActiveChannels returns channels that have members
func (ml *MasterList) GetActiveChannels() []*Channel {
return ml.Filter(func(channel *Channel) bool {
return !channel.IsEmpty()
})
}
// GetEmptyChannels returns channels that have no members
func (ml *MasterList) GetEmptyChannels() []*Channel {
return ml.Filter(func(channel *Channel) bool {
return channel.IsEmpty()
})
}
// GetChannelByName retrieves a channel by name (case-insensitive)
func (ml *MasterList) GetChannelByName(name string) *Channel {
name = strings.ToLower(name)
var foundChannel *Channel
ml.ForEach(func(id int32, channel *Channel) {
if strings.ToLower(channel.GetName()) == name {
foundChannel = channel
}
})
return foundChannel
}
// HasChannelByName checks if a channel exists by name (case-insensitive)
func (ml *MasterList) HasChannelByName(name string) bool {
return ml.GetChannelByName(name) != nil
}
// GetCompatibleChannels returns channels compatible with player restrictions
func (ml *MasterList) GetCompatibleChannels(level, race, class int32) []*Channel {
return ml.Filter(func(channel *Channel) bool {
return channel.CanJoinChannelByLevel(level) &&
channel.CanJoinChannelByRace(race) &&
channel.CanJoinChannelByClass(class)
})
}
// ValidateChannels checks all channels for consistency
func (ml *MasterList) ValidateChannels() []string {
var issues []string
ml.ForEach(func(id int32, channel *Channel) {
if channel == nil {
issues = append(issues, fmt.Sprintf("Channel ID %d is nil", id))
return
}
if channel.GetID() != id {
issues = append(issues, fmt.Sprintf("Channel ID mismatch: map key %d != channel ID %d", id, channel.GetID()))
}
if len(channel.GetName()) == 0 {
issues = append(issues, fmt.Sprintf("Channel ID %d has empty name", id))
}
if channel.GetType() < ChannelTypeNone || channel.GetType() > ChannelTypeCustom {
issues = append(issues, fmt.Sprintf("Channel ID %d has invalid type: %d", id, channel.GetType()))
}
if len(channel.GetName()) > MaxChannelNameLength {
issues = append(issues, fmt.Sprintf("Channel ID %d name too long: %d > %d", id, len(channel.GetName()), MaxChannelNameLength))
}
if len(channel.Password) > MaxChannelPasswordLength {
issues = append(issues, fmt.Sprintf("Channel ID %d password too long: %d > %d", id, len(channel.Password), MaxChannelPasswordLength))
}
})
return issues
}
// IsValid returns true if all channels are valid
func (ml *MasterList) IsValid() bool {
issues := ml.ValidateChannels()
return len(issues) == 0
}
// GetStatistics returns statistics about the channel collection
func (ml *MasterList) GetStatistics() map[string]any {
stats := make(map[string]any)
stats["total_channels"] = ml.Size()
if ml.IsEmpty() {
return stats
}
// Count by channel type
typeCounts := make(map[int]int)
var totalMembers int
var activeChannels int
var minID, maxID int32
first := true
ml.ForEach(func(id int32, channel *Channel) {
typeCounts[channel.GetType()]++
totalMembers += channel.GetNumClients()
if !channel.IsEmpty() {
activeChannels++
}
if first {
minID = id
maxID = id
first = false
} else {
if id < minID {
minID = id
}
if id > maxID {
maxID = id
}
}
})
stats["channels_by_type"] = typeCounts
stats["world_channels"] = typeCounts[ChannelTypeWorld]
stats["custom_channels"] = typeCounts[ChannelTypeCustom]
stats["total_members"] = totalMembers
stats["active_channels"] = activeChannels
stats["min_id"] = minID
stats["max_id"] = maxID
stats["id_range"] = maxID - minID
return stats
}
// LoadAllChannels loads all channels from the database into the master list
func (ml *MasterList) LoadAllChannels(db *database.Database) error {
if db == nil {
return fmt.Errorf("database connection is nil")
}
// Clear existing channels
ml.Clear()
query := `SELECT id, name, password, type, level_restriction, race_restriction, class_restriction, discord_enabled, created_at, updated_at FROM channels ORDER BY id`
rows, err := db.Query(query)
if err != nil {
return fmt.Errorf("failed to query channels: %w", err)
}
defer rows.Close()
count := 0
for rows.Next() {
channel := &Channel{
db: db,
isNew: false,
members: make([]int32, 0),
}
err := rows.Scan(&channel.ID, &channel.Name, &channel.Password, &channel.ChannelType,
&channel.LevelRestriction, &channel.RaceRestriction, &channel.ClassRestriction,
&channel.DiscordEnabled, &channel.Created, &channel.Updated)
if err != nil {
return fmt.Errorf("failed to scan channel: %w", err)
}
if !ml.AddChannel(channel) {
return fmt.Errorf("failed to add channel %d to master list", channel.ID)
}
count++
}
if err := rows.Err(); err != nil {
return fmt.Errorf("error iterating channel rows: %w", err)
}
return nil
}
// LoadAllChannelsFromDatabase is a convenience function that creates a master list and loads all channels
func LoadAllChannelsFromDatabase(db *database.Database) (*MasterList, error) {
masterList := NewMasterList()
err := masterList.LoadAllChannels(db)
if err != nil {
return nil, err
}
return masterList, nil
}
// contains checks if a string contains a substring (case-sensitive)
func contains(str, substr string) bool {
if len(substr) == 0 {
return true
}
if len(str) < len(substr) {
return false
}
for i := 0; i <= len(str)-len(substr); i++ {
if str[i:i+len(substr)] == substr {
return true
}
}
return false
}

View File

@ -0,0 +1,389 @@
package chat
import (
"testing"
"eq2emu/internal/database"
)
func TestNewMasterList(t *testing.T) {
masterList := NewMasterList()
if masterList == nil {
t.Fatal("NewMasterList returned nil")
}
if masterList.GetChannelCount() != 0 {
t.Errorf("Expected count 0, got %d", masterList.GetChannelCount())
}
}
func TestMasterListBasicOperations(t *testing.T) {
db, _ := database.NewSQLite("file::memory:?mode=memory&cache=shared")
defer db.Close()
masterList := NewMasterList()
// Create test channels
channel1 := NewWithData(1001, "Auction", ChannelTypeWorld, db)
channel2 := NewWithData(1002, "Custom Channel", ChannelTypeCustom, db)
// Test adding
if !masterList.AddChannel(channel1) {
t.Error("Should successfully add channel1")
}
if !masterList.AddChannel(channel2) {
t.Error("Should successfully add channel2")
}
// Test duplicate add (should fail)
if masterList.AddChannel(channel1) {
t.Error("Should not add duplicate channel")
}
if masterList.GetChannelCount() != 2 {
t.Errorf("Expected count 2, got %d", masterList.GetChannelCount())
}
// Test retrieving
retrieved := masterList.GetChannel(1001)
if retrieved == nil {
t.Error("Should retrieve added channel")
}
if retrieved.GetName() != "Auction" {
t.Errorf("Expected name 'Auction', got '%s'", retrieved.GetName())
}
// Test safe retrieval
retrieved, exists := masterList.GetChannelSafe(1001)
if !exists || retrieved == nil {
t.Error("GetChannelSafe should return channel and true")
}
_, exists = masterList.GetChannelSafe(9999)
if exists {
t.Error("GetChannelSafe should return false for non-existent ID")
}
// Test HasChannel
if !masterList.HasChannel(1001) {
t.Error("HasChannel should return true for existing ID")
}
if masterList.HasChannel(9999) {
t.Error("HasChannel should return false for non-existent ID")
}
// Test removing
if !masterList.RemoveChannel(1001) {
t.Error("Should successfully remove channel")
}
if masterList.GetChannelCount() != 1 {
t.Errorf("Expected count 1, got %d", masterList.GetChannelCount())
}
if masterList.HasChannel(1001) {
t.Error("Channel should be removed")
}
// Test clear
masterList.ClearChannels()
if masterList.GetChannelCount() != 0 {
t.Errorf("Expected count 0 after clear, got %d", masterList.GetChannelCount())
}
}
func TestMasterListFiltering(t *testing.T) {
db, _ := database.NewSQLite("file::memory:?mode=memory&cache=shared")
defer db.Close()
masterList := NewMasterList()
// Add test data
channels := []*Channel{
NewWithData(1, "Auction", ChannelTypeWorld, db),
NewWithData(2, "Trade", ChannelTypeWorld, db),
NewWithData(3, "Custom Chat", ChannelTypeCustom, db),
NewWithData(4, "Player Channel", ChannelTypeCustom, db),
}
for _, ch := range channels {
masterList.AddChannel(ch)
}
// Test FindChannelsByName
auctionChannels := masterList.FindChannelsByName("Auction")
if len(auctionChannels) != 1 {
t.Errorf("FindChannelsByName('Auction') returned %v results, want 1", len(auctionChannels))
}
chatChannels := masterList.FindChannelsByName("Channel")
if len(chatChannels) != 1 {
t.Errorf("FindChannelsByName('Channel') returned %v results, want 1", len(chatChannels))
}
// Test FindChannelsByType
worldChannels := masterList.FindChannelsByType(ChannelTypeWorld)
if len(worldChannels) != 2 {
t.Errorf("FindChannelsByType(World) returned %v results, want 2", len(worldChannels))
}
customChannels := masterList.FindChannelsByType(ChannelTypeCustom)
if len(customChannels) != 2 {
t.Errorf("FindChannelsByType(Custom) returned %v results, want 2", len(customChannels))
}
// Test GetWorldChannels
worldList := masterList.GetWorldChannels()
if len(worldList) != 2 {
t.Errorf("GetWorldChannels() returned %v results, want 2", len(worldList))
}
// Test GetCustomChannels
customList := masterList.GetCustomChannels()
if len(customList) != 2 {
t.Errorf("GetCustomChannels() returned %v results, want 2", len(customList))
}
// Test GetActiveChannels (all channels are empty initially)
activeChannels := masterList.GetActiveChannels()
if len(activeChannels) != 0 {
t.Errorf("GetActiveChannels() returned %v results, want 0", len(activeChannels))
}
// Add members to make channels active
channels[0].JoinChannel(1001)
channels[1].JoinChannel(1002)
activeChannels = masterList.GetActiveChannels()
if len(activeChannels) != 2 {
t.Errorf("GetActiveChannels() returned %v results, want 2", len(activeChannels))
}
// Test GetEmptyChannels
emptyChannels := masterList.GetEmptyChannels()
if len(emptyChannels) != 2 {
t.Errorf("GetEmptyChannels() returned %v results, want 2", len(emptyChannels))
}
}
func TestMasterListGetByName(t *testing.T) {
db, _ := database.NewSQLite("file::memory:?mode=memory&cache=shared")
defer db.Close()
masterList := NewMasterList()
// Add test channels
channel1 := NewWithData(100, "Auction", ChannelTypeWorld, db)
channel2 := NewWithData(200, "AUCTION", ChannelTypeWorld, db) // Different case
masterList.AddChannel(channel1)
masterList.AddChannel(channel2)
// Test case-insensitive lookup
found := masterList.GetChannelByName("auction")
if found == nil {
t.Error("GetChannelByName should find channel (case insensitive)")
}
found = masterList.GetChannelByName("AUCTION")
if found == nil {
t.Error("GetChannelByName should find channel (uppercase)")
}
found = masterList.GetChannelByName("NonExistent")
if found != nil {
t.Error("GetChannelByName should return nil for non-existent channel")
}
// Test HasChannelByName
if !masterList.HasChannelByName("auction") {
t.Error("HasChannelByName should return true for existing channel")
}
if masterList.HasChannelByName("NonExistent") {
t.Error("HasChannelByName should return false for non-existent channel")
}
}
func TestMasterListCompatibility(t *testing.T) {
db, _ := database.NewSQLite("file::memory:?mode=memory&cache=shared")
defer db.Close()
masterList := NewMasterList()
// Create channels with restrictions
channel1 := NewWithData(1, "LowLevel", ChannelTypeWorld, db)
channel1.SetLevelRestriction(5)
channel2 := NewWithData(2, "HighLevel", ChannelTypeWorld, db)
channel2.SetLevelRestriction(50)
channel3 := NewWithData(3, "RaceRestricted", ChannelTypeWorld, db)
channel3.SetRacesAllowed(1 << 1) // Only race 1 allowed
masterList.AddChannel(channel1)
masterList.AddChannel(channel2)
masterList.AddChannel(channel3)
// Test compatibility for level 10, race 1, class 1 player
compatible := masterList.GetCompatibleChannels(10, 1, 1)
if len(compatible) != 2 { // Should match channel1 and channel3
t.Errorf("GetCompatibleChannels(10,1,1) returned %v results, want 2", len(compatible))
}
// Test compatibility for level 60, race 2, class 1 player
compatible = masterList.GetCompatibleChannels(60, 2, 1)
if len(compatible) != 2 { // Should match channel1 and channel2 (not channel3)
t.Errorf("GetCompatibleChannels(60,2,1) returned %v results, want 2", len(compatible))
}
// Test compatibility for level 1, race 1, class 1 player
compatible = masterList.GetCompatibleChannels(1, 1, 1)
if len(compatible) != 1 { // Should only match channel3 (no level restriction)
t.Errorf("GetCompatibleChannels(1,1,1) returned %v results, want 1", len(compatible))
}
}
func TestMasterListGetAll(t *testing.T) {
db, _ := database.NewSQLite("file::memory:?mode=memory&cache=shared")
defer db.Close()
masterList := NewMasterList()
// Add test channels
for i := int32(1); i <= 3; i++ {
ch := NewWithData(i*100, "Test", ChannelTypeWorld, db)
masterList.AddChannel(ch)
}
// Test GetAllChannels (map)
allMap := masterList.GetAllChannels()
if len(allMap) != 3 {
t.Errorf("GetAllChannels() returned %v items, want 3", len(allMap))
}
// Verify it's a copy by modifying returned map
delete(allMap, 100)
if masterList.GetChannelCount() != 3 {
t.Error("Modifying returned map affected internal state")
}
// Test GetAllChannelsList (slice)
allList := masterList.GetAllChannelsList()
if len(allList) != 3 {
t.Errorf("GetAllChannelsList() returned %v items, want 3", len(allList))
}
}
func TestMasterListValidation(t *testing.T) {
db, _ := database.NewSQLite("file::memory:?mode=memory&cache=shared")
defer db.Close()
masterList := NewMasterList()
// Add valid channels
ch1 := NewWithData(100, "Valid Channel", ChannelTypeWorld, db)
masterList.AddChannel(ch1)
issues := masterList.ValidateChannels()
if len(issues) != 0 {
t.Errorf("ValidateChannels() returned issues for valid data: %v", issues)
}
if !masterList.IsValid() {
t.Error("IsValid() should return true for valid data")
}
// Add invalid channel (empty name)
ch2 := NewWithData(200, "", ChannelTypeWorld, db)
masterList.AddChannel(ch2)
issues = masterList.ValidateChannels()
if len(issues) == 0 {
t.Error("ValidateChannels() should return issues for invalid data")
}
if masterList.IsValid() {
t.Error("IsValid() should return false for invalid data")
}
}
func TestMasterListStatistics(t *testing.T) {
db, _ := database.NewSQLite("file::memory:?mode=memory&cache=shared")
defer db.Close()
masterList := NewMasterList()
// Add channels with different types
masterList.AddChannel(NewWithData(10, "World1", ChannelTypeWorld, db))
masterList.AddChannel(NewWithData(20, "World2", ChannelTypeWorld, db))
masterList.AddChannel(NewWithData(30, "Custom1", ChannelTypeCustom, db))
masterList.AddChannel(NewWithData(40, "Custom2", ChannelTypeCustom, db))
masterList.AddChannel(NewWithData(50, "Custom3", ChannelTypeCustom, db))
// Add some members
masterList.GetChannel(10).JoinChannel(1001)
masterList.GetChannel(20).JoinChannel(1002)
stats := masterList.GetStatistics()
if total, ok := stats["total_channels"].(int); !ok || total != 5 {
t.Errorf("total_channels = %v, want 5", stats["total_channels"])
}
if worldChannels, ok := stats["world_channels"].(int); !ok || worldChannels != 2 {
t.Errorf("world_channels = %v, want 2", stats["world_channels"])
}
if customChannels, ok := stats["custom_channels"].(int); !ok || customChannels != 3 {
t.Errorf("custom_channels = %v, want 3", stats["custom_channels"])
}
if activeChannels, ok := stats["active_channels"].(int); !ok || activeChannels != 2 {
t.Errorf("active_channels = %v, want 2", stats["active_channels"])
}
if totalMembers, ok := stats["total_members"].(int); !ok || totalMembers != 2 {
t.Errorf("total_members = %v, want 2", stats["total_members"])
}
if minID, ok := stats["min_id"].(int32); !ok || minID != 10 {
t.Errorf("min_id = %v, want 10", stats["min_id"])
}
if maxID, ok := stats["max_id"].(int32); !ok || maxID != 50 {
t.Errorf("max_id = %v, want 50", stats["max_id"])
}
if idRange, ok := stats["id_range"].(int32); !ok || idRange != 40 {
t.Errorf("id_range = %v, want 40", stats["id_range"])
}
}
func TestContainsFunction(t *testing.T) {
tests := []struct {
str string
substr string
want bool
}{
{"hello world", "world", true},
{"hello world", "World", false}, // Case sensitive
{"hello", "hello world", false},
{"hello", "", true},
{"", "hello", false},
{"", "", true},
{"abcdef", "cde", true},
{"abcdef", "xyz", false},
}
for _, tt := range tests {
t.Run("", func(t *testing.T) {
if got := contains(tt.str, tt.substr); got != tt.want {
t.Errorf("contains(%q, %q) = %v, want %v", tt.str, tt.substr, got, tt.want)
}
})
}
}

View File

@ -1,24 +1,10 @@
package chat
import (
"sync"
"context"
"time"
)
// Channel represents a chat channel with membership and message routing capabilities
type Channel struct {
mu sync.RWMutex
name string
password string
channelType int
levelRestriction int32
raceRestriction int32
classRestriction int32
members []int32 // Character IDs
discordEnabled bool
created time.Time
}
// ChannelMessage represents a message sent to a channel
type ChannelMessage struct {
SenderID int32
@ -61,16 +47,68 @@ type ChatChannelData struct {
RaceRestriction int32
}
// ChatManager manages all chat channels and operations
type ChatManager struct {
mu sync.RWMutex
channels map[string]*Channel
database ChannelDatabase
// ChannelDatabase defines database operations for chat channels
type ChannelDatabase interface {
// LoadWorldChannels retrieves all persistent world channels from database
LoadWorldChannels(ctx context.Context) ([]ChatChannelData, error)
// Integration interfaces
clientManager ClientManager
playerManager PlayerManager
languageProcessor LanguageProcessor
// SaveChannel persists a channel to database (world channels only)
SaveChannel(ctx context.Context, channel ChatChannelData) error
// DeleteChannel removes a channel from database
DeleteChannel(ctx context.Context, channelName string) error
}
// ClientManager handles client communication for chat system
type ClientManager interface {
// SendChannelList sends available channels to a client
SendChannelList(characterID int32, channels []ChannelInfo) error
// SendChannelMessage delivers a message to a client
SendChannelMessage(characterID int32, message ChannelMessage) error
// SendChannelUpdate notifies client of channel membership changes
SendChannelUpdate(characterID int32, channelName string, action int, characterName string) error
// SendChannelUserList sends who list to client
SendChannelUserList(characterID int32, channelName string, members []ChannelMember) error
// IsClientConnected checks if a character is currently online
IsClientConnected(characterID int32) bool
}
// PlayerManager provides player information for chat system
type PlayerManager interface {
// GetPlayerInfo retrieves basic player information
GetPlayerInfo(characterID int32) (PlayerInfo, error)
// ValidatePlayer checks if player meets channel requirements
ValidatePlayer(characterID int32, levelReq, raceReq, classReq int32) bool
// GetPlayerLanguages returns languages known by player
GetPlayerLanguages(characterID int32) ([]int32, error)
}
// LanguageProcessor handles multilingual chat processing
type LanguageProcessor interface {
// ProcessMessage processes a message for language comprehension
ProcessMessage(senderID, receiverID int32, message string, languageID int32) (string, error)
// CanUnderstand checks if receiver can understand sender's language
CanUnderstand(senderID, receiverID int32, languageID int32) bool
// GetDefaultLanguage returns the default language for a character
GetDefaultLanguage(characterID int32) int32
}
// PlayerInfo contains basic player information needed for chat
type PlayerInfo struct {
CharacterID int32
CharacterName string
Level int32
Race int32
Class int32
IsOnline bool
}
// ChatStatistics provides statistics about chat system usage

359
internal/classes/class.go Normal file
View 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
}

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

View File

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

View File

@ -101,126 +101,3 @@ const (
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
View 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

View File

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

View File

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

View File

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