605 lines
16 KiB
Go
605 lines
16 KiB
Go
package chat
|
|
|
|
import (
|
|
"fmt"
|
|
"maps"
|
|
"strings"
|
|
"sync"
|
|
|
|
"eq2emu/internal/database"
|
|
)
|
|
|
|
// MasterList is a specialized chat channel master list optimized for:
|
|
// - Fast ID-based lookups (O(1))
|
|
// - Fast channel type filtering (O(1))
|
|
// - Fast name-based searching (indexed)
|
|
// - Fast member count filtering
|
|
// - Efficient channel compatibility queries
|
|
type MasterList struct {
|
|
// Core storage
|
|
channels map[int32]*Channel // ID -> Channel
|
|
mutex sync.RWMutex
|
|
|
|
// Specialized indices for O(1) lookups
|
|
byType map[int][]*Channel // ChannelType -> channels
|
|
byMemberCt map[int][]*Channel // Member count -> channels (active/empty)
|
|
byNameLower map[string]*Channel // Lowercase name -> channel
|
|
byRestrict map[int32][]*Channel // Restriction level -> channels
|
|
|
|
// Cached metadata
|
|
memberCounts []int // Unique member counts (cached)
|
|
typeStats map[int]int // Channel type -> count
|
|
metaStale bool // Whether metadata cache needs refresh
|
|
}
|
|
|
|
// NewMasterList creates a new specialized chat channel master list
|
|
func NewMasterList() *MasterList {
|
|
return &MasterList{
|
|
channels: make(map[int32]*Channel),
|
|
byType: make(map[int][]*Channel),
|
|
byMemberCt: make(map[int][]*Channel),
|
|
byNameLower: make(map[string]*Channel),
|
|
byRestrict: make(map[int32][]*Channel),
|
|
typeStats: make(map[int]int),
|
|
metaStale: true,
|
|
}
|
|
}
|
|
|
|
// refreshMetaCache updates the member counts cache and type stats
|
|
func (ml *MasterList) refreshMetaCache() {
|
|
if !ml.metaStale {
|
|
return
|
|
}
|
|
|
|
// Clear and rebuild type stats
|
|
ml.typeStats = make(map[int]int)
|
|
memberCountSet := make(map[int]struct{})
|
|
|
|
// Collect unique member counts and type stats
|
|
for _, channel := range ml.channels {
|
|
ml.typeStats[channel.GetType()]++
|
|
memberCount := channel.GetNumClients()
|
|
memberCountSet[memberCount] = struct{}{}
|
|
}
|
|
|
|
// Clear and rebuild member counts cache
|
|
ml.memberCounts = ml.memberCounts[:0]
|
|
for count := range memberCountSet {
|
|
ml.memberCounts = append(ml.memberCounts, count)
|
|
}
|
|
|
|
ml.metaStale = false
|
|
}
|
|
|
|
// updateChannelIndices updates all indices for a channel
|
|
func (ml *MasterList) updateChannelIndices(channel *Channel, add bool) {
|
|
if add {
|
|
// Add to type index
|
|
ml.byType[channel.GetType()] = append(ml.byType[channel.GetType()], channel)
|
|
|
|
// Add to member count index
|
|
memberCount := channel.GetNumClients()
|
|
ml.byMemberCt[memberCount] = append(ml.byMemberCt[memberCount], channel)
|
|
|
|
// Add to name index
|
|
ml.byNameLower[strings.ToLower(channel.GetName())] = channel
|
|
|
|
// Add to restriction index
|
|
ml.byRestrict[channel.LevelRestriction] = append(ml.byRestrict[channel.LevelRestriction], channel)
|
|
} else {
|
|
// Remove from type index
|
|
typeChannels := ml.byType[channel.GetType()]
|
|
for i, ch := range typeChannels {
|
|
if ch.ID == channel.ID {
|
|
ml.byType[channel.GetType()] = append(typeChannels[:i], typeChannels[i+1:]...)
|
|
break
|
|
}
|
|
}
|
|
|
|
// Remove from member count index
|
|
memberCount := channel.GetNumClients()
|
|
memberChannels := ml.byMemberCt[memberCount]
|
|
for i, ch := range memberChannels {
|
|
if ch.ID == channel.ID {
|
|
ml.byMemberCt[memberCount] = append(memberChannels[:i], memberChannels[i+1:]...)
|
|
break
|
|
}
|
|
}
|
|
|
|
// Remove from name index
|
|
delete(ml.byNameLower, strings.ToLower(channel.GetName()))
|
|
|
|
// Remove from restriction index
|
|
restrChannels := ml.byRestrict[channel.LevelRestriction]
|
|
for i, ch := range restrChannels {
|
|
if ch.ID == channel.ID {
|
|
ml.byRestrict[channel.LevelRestriction] = append(restrChannels[:i], restrChannels[i+1:]...)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// RefreshChannelIndices refreshes the indices for a channel (used when member count changes)
|
|
func (ml *MasterList) RefreshChannelIndices(channel *Channel, oldMemberCount int) {
|
|
ml.mutex.Lock()
|
|
defer ml.mutex.Unlock()
|
|
|
|
// Remove from old member count index
|
|
oldMemberChannels := ml.byMemberCt[oldMemberCount]
|
|
for i, ch := range oldMemberChannels {
|
|
if ch.ID == channel.ID {
|
|
ml.byMemberCt[oldMemberCount] = append(oldMemberChannels[:i], oldMemberChannels[i+1:]...)
|
|
break
|
|
}
|
|
}
|
|
|
|
// Add to new member count index
|
|
newMemberCount := channel.GetNumClients()
|
|
ml.byMemberCt[newMemberCount] = append(ml.byMemberCt[newMemberCount], channel)
|
|
|
|
// Invalidate metadata cache
|
|
ml.metaStale = true
|
|
}
|
|
|
|
// AddChannel adds a channel with full indexing
|
|
func (ml *MasterList) AddChannel(channel *Channel) bool {
|
|
if channel == nil {
|
|
return false
|
|
}
|
|
|
|
ml.mutex.Lock()
|
|
defer ml.mutex.Unlock()
|
|
|
|
// Check if exists
|
|
if _, exists := ml.channels[channel.ID]; exists {
|
|
return false
|
|
}
|
|
|
|
// Add to core storage
|
|
ml.channels[channel.ID] = channel
|
|
|
|
// Update all indices
|
|
ml.updateChannelIndices(channel, true)
|
|
|
|
// Invalidate metadata cache
|
|
ml.metaStale = true
|
|
|
|
return true
|
|
}
|
|
|
|
// GetChannel retrieves by ID (O(1))
|
|
func (ml *MasterList) GetChannel(id int32) *Channel {
|
|
ml.mutex.RLock()
|
|
defer ml.mutex.RUnlock()
|
|
return ml.channels[id]
|
|
}
|
|
|
|
// GetChannelSafe retrieves a channel by ID with existence check
|
|
func (ml *MasterList) GetChannelSafe(id int32) (*Channel, bool) {
|
|
ml.mutex.RLock()
|
|
defer ml.mutex.RUnlock()
|
|
channel, exists := ml.channels[id]
|
|
return channel, exists
|
|
}
|
|
|
|
// HasChannel checks if a channel exists by ID
|
|
func (ml *MasterList) HasChannel(id int32) bool {
|
|
ml.mutex.RLock()
|
|
defer ml.mutex.RUnlock()
|
|
_, exists := ml.channels[id]
|
|
return exists
|
|
}
|
|
|
|
// RemoveChannel removes a channel and updates all indices
|
|
func (ml *MasterList) RemoveChannel(id int32) bool {
|
|
ml.mutex.Lock()
|
|
defer ml.mutex.Unlock()
|
|
|
|
channel, exists := ml.channels[id]
|
|
if !exists {
|
|
return false
|
|
}
|
|
|
|
// Remove from core storage
|
|
delete(ml.channels, id)
|
|
|
|
// Update all indices
|
|
ml.updateChannelIndices(channel, false)
|
|
|
|
// Invalidate metadata cache
|
|
ml.metaStale = true
|
|
|
|
return true
|
|
}
|
|
|
|
// GetAllChannels returns a copy of all channels map
|
|
func (ml *MasterList) GetAllChannels() map[int32]*Channel {
|
|
ml.mutex.RLock()
|
|
defer ml.mutex.RUnlock()
|
|
|
|
// Return a copy to prevent external modification
|
|
result := make(map[int32]*Channel, len(ml.channels))
|
|
maps.Copy(result, ml.channels)
|
|
return result
|
|
}
|
|
|
|
// GetAllChannelsList returns all channels as a slice
|
|
func (ml *MasterList) GetAllChannelsList() []*Channel {
|
|
ml.mutex.RLock()
|
|
defer ml.mutex.RUnlock()
|
|
|
|
result := make([]*Channel, 0, len(ml.channels))
|
|
for _, channel := range ml.channels {
|
|
result = append(result, channel)
|
|
}
|
|
return result
|
|
}
|
|
|
|
// GetChannelCount returns the number of channels
|
|
func (ml *MasterList) GetChannelCount() int {
|
|
ml.mutex.RLock()
|
|
defer ml.mutex.RUnlock()
|
|
return len(ml.channels)
|
|
}
|
|
|
|
// Size returns the total number of channels
|
|
func (ml *MasterList) Size() int {
|
|
ml.mutex.RLock()
|
|
defer ml.mutex.RUnlock()
|
|
return len(ml.channels)
|
|
}
|
|
|
|
// IsEmpty returns true if the master list is empty
|
|
func (ml *MasterList) IsEmpty() bool {
|
|
ml.mutex.RLock()
|
|
defer ml.mutex.RUnlock()
|
|
return len(ml.channels) == 0
|
|
}
|
|
|
|
// ClearChannels removes all channels from the list
|
|
func (ml *MasterList) ClearChannels() {
|
|
ml.mutex.Lock()
|
|
defer ml.mutex.Unlock()
|
|
|
|
// Clear all maps
|
|
ml.channels = make(map[int32]*Channel)
|
|
ml.byType = make(map[int][]*Channel)
|
|
ml.byMemberCt = make(map[int][]*Channel)
|
|
ml.byNameLower = make(map[string]*Channel)
|
|
ml.byRestrict = make(map[int32][]*Channel)
|
|
|
|
// Clear cached metadata
|
|
ml.memberCounts = ml.memberCounts[:0]
|
|
ml.typeStats = make(map[int]int)
|
|
ml.metaStale = true
|
|
}
|
|
|
|
// Clear removes all channels from the master list
|
|
func (ml *MasterList) Clear() {
|
|
ml.ClearChannels()
|
|
}
|
|
|
|
// FindChannelsByName finds channels containing the given name substring (optimized)
|
|
func (ml *MasterList) FindChannelsByName(nameSubstring string) []*Channel {
|
|
ml.mutex.RLock()
|
|
defer ml.mutex.RUnlock()
|
|
|
|
searchKey := strings.ToLower(nameSubstring)
|
|
|
|
// Try exact match first for full channel names
|
|
if exactChannel := ml.byNameLower[searchKey]; exactChannel != nil {
|
|
return []*Channel{exactChannel}
|
|
}
|
|
|
|
// Fallback to substring search
|
|
var result []*Channel
|
|
for _, channel := range ml.channels {
|
|
if contains(strings.ToLower(channel.GetName()), searchKey) {
|
|
result = append(result, channel)
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// FindChannelsByType finds channels of a specific type (O(1))
|
|
func (ml *MasterList) FindChannelsByType(channelType int) []*Channel {
|
|
ml.mutex.RLock()
|
|
defer ml.mutex.RUnlock()
|
|
return ml.byType[channelType]
|
|
}
|
|
|
|
// GetWorldChannels returns all world channels (O(1))
|
|
func (ml *MasterList) GetWorldChannels() []*Channel {
|
|
return ml.FindChannelsByType(ChannelTypeWorld)
|
|
}
|
|
|
|
// GetCustomChannels returns all custom channels (O(1))
|
|
func (ml *MasterList) GetCustomChannels() []*Channel {
|
|
return ml.FindChannelsByType(ChannelTypeCustom)
|
|
}
|
|
|
|
// GetActiveChannels returns channels that have members
|
|
func (ml *MasterList) GetActiveChannels() []*Channel {
|
|
ml.mutex.RLock()
|
|
defer ml.mutex.RUnlock()
|
|
|
|
var result []*Channel
|
|
for _, channel := range ml.channels {
|
|
if !channel.IsEmpty() {
|
|
result = append(result, channel)
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// GetEmptyChannels returns channels that have no members
|
|
func (ml *MasterList) GetEmptyChannels() []*Channel {
|
|
ml.mutex.RLock()
|
|
defer ml.mutex.RUnlock()
|
|
|
|
var result []*Channel
|
|
for _, channel := range ml.channels {
|
|
if channel.IsEmpty() {
|
|
result = append(result, channel)
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// GetChannelByName retrieves a channel by name (case-insensitive, O(1))
|
|
func (ml *MasterList) GetChannelByName(name string) *Channel {
|
|
ml.mutex.RLock()
|
|
defer ml.mutex.RUnlock()
|
|
return ml.byNameLower[strings.ToLower(name)]
|
|
}
|
|
|
|
// HasChannelByName checks if a channel exists by name (case-insensitive, O(1))
|
|
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 {
|
|
ml.mutex.RLock()
|
|
defer ml.mutex.RUnlock()
|
|
|
|
var result []*Channel
|
|
for _, channel := range ml.channels {
|
|
if channel.CanJoinChannelByLevel(level) &&
|
|
channel.CanJoinChannelByRace(race) &&
|
|
channel.CanJoinChannelByClass(class) {
|
|
result = append(result, channel)
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// GetChannelsByMemberCount returns channels with specific member count
|
|
func (ml *MasterList) GetChannelsByMemberCount(memberCount int) []*Channel {
|
|
ml.mutex.RLock()
|
|
defer ml.mutex.RUnlock()
|
|
return ml.byMemberCt[memberCount]
|
|
}
|
|
|
|
// GetChannelsByLevelRestriction returns channels with specific level restriction
|
|
func (ml *MasterList) GetChannelsByLevelRestriction(levelRestriction int32) []*Channel {
|
|
ml.mutex.RLock()
|
|
defer ml.mutex.RUnlock()
|
|
return ml.byRestrict[levelRestriction]
|
|
}
|
|
|
|
// UpdateChannel updates an existing channel and refreshes indices
|
|
func (ml *MasterList) UpdateChannel(channel *Channel) error {
|
|
if channel == nil {
|
|
return fmt.Errorf("channel cannot be nil")
|
|
}
|
|
|
|
ml.mutex.Lock()
|
|
defer ml.mutex.Unlock()
|
|
|
|
// Check if exists
|
|
old, exists := ml.channels[channel.ID]
|
|
if !exists {
|
|
return fmt.Errorf("channel %d not found", channel.ID)
|
|
}
|
|
|
|
// Remove old channel from indices (but not core storage yet)
|
|
ml.updateChannelIndices(old, false)
|
|
|
|
// Update core storage
|
|
ml.channels[channel.ID] = channel
|
|
|
|
// Add new channel to indices
|
|
ml.updateChannelIndices(channel, true)
|
|
|
|
// Invalidate metadata cache
|
|
ml.metaStale = true
|
|
|
|
return nil
|
|
}
|
|
|
|
// ForEach executes a function for each channel
|
|
func (ml *MasterList) ForEach(fn func(int32, *Channel)) {
|
|
ml.mutex.RLock()
|
|
defer ml.mutex.RUnlock()
|
|
|
|
for id, channel := range ml.channels {
|
|
fn(id, channel)
|
|
}
|
|
}
|
|
|
|
// ValidateChannels checks all channels for consistency
|
|
func (ml *MasterList) ValidateChannels() []string {
|
|
ml.mutex.RLock()
|
|
defer ml.mutex.RUnlock()
|
|
|
|
var issues []string
|
|
|
|
for id, channel := range ml.channels {
|
|
if channel == nil {
|
|
issues = append(issues, fmt.Sprintf("Channel ID %d is nil", id))
|
|
continue
|
|
}
|
|
|
|
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 using cached data
|
|
func (ml *MasterList) GetStatistics() map[string]any {
|
|
ml.mutex.Lock() // Need write lock to potentially update cache
|
|
defer ml.mutex.Unlock()
|
|
|
|
ml.refreshMetaCache()
|
|
|
|
stats := make(map[string]any)
|
|
stats["total_channels"] = len(ml.channels)
|
|
|
|
if len(ml.channels) == 0 {
|
|
return stats
|
|
}
|
|
|
|
// Use cached type stats
|
|
stats["channels_by_type"] = ml.typeStats
|
|
stats["world_channels"] = ml.typeStats[ChannelTypeWorld]
|
|
stats["custom_channels"] = ml.typeStats[ChannelTypeCustom]
|
|
|
|
// Calculate additional stats
|
|
var totalMembers int
|
|
var activeChannels int
|
|
var minID, maxID int32
|
|
first := true
|
|
|
|
for id, channel := range ml.channels {
|
|
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["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
|
|
} |