555 lines
14 KiB
Go
555 lines
14 KiB
Go
package appearances
|
|
|
|
import (
|
|
"fmt"
|
|
"maps"
|
|
"strings"
|
|
"sync"
|
|
|
|
"eq2emu/internal/database"
|
|
)
|
|
|
|
// MasterList is a specialized appearance master list optimized for:
|
|
// - Fast ID-based lookups (O(1))
|
|
// - Fast name-based searching (indexed)
|
|
// - Fast client version filtering (O(1))
|
|
// - Efficient range queries and statistics
|
|
type MasterList struct {
|
|
// Core storage
|
|
appearances map[int32]*Appearance // ID -> Appearance
|
|
mutex sync.RWMutex
|
|
|
|
// Specialized indices for O(1) lookups
|
|
byMinClient map[int16][]*Appearance // MinClient -> appearances
|
|
byNamePart map[string][]*Appearance // Name substring -> appearances (for common searches)
|
|
|
|
// Cached metadata
|
|
clientVersions []int16 // Unique client versions (cached)
|
|
metaStale bool // Whether metadata cache needs refresh
|
|
}
|
|
|
|
// NewMasterList creates a new specialized appearance master list
|
|
func NewMasterList() *MasterList {
|
|
return &MasterList{
|
|
appearances: make(map[int32]*Appearance),
|
|
byMinClient: make(map[int16][]*Appearance),
|
|
byNamePart: make(map[string][]*Appearance),
|
|
metaStale: true,
|
|
}
|
|
}
|
|
|
|
// refreshMetaCache updates the client versions cache
|
|
func (ml *MasterList) refreshMetaCache() {
|
|
if !ml.metaStale {
|
|
return
|
|
}
|
|
|
|
clientVersionSet := make(map[int16]struct{})
|
|
|
|
// Collect unique client versions
|
|
for _, appearance := range ml.appearances {
|
|
clientVersionSet[appearance.MinClient] = struct{}{}
|
|
}
|
|
|
|
// Clear existing cache and rebuild
|
|
ml.clientVersions = ml.clientVersions[:0]
|
|
for version := range clientVersionSet {
|
|
ml.clientVersions = append(ml.clientVersions, version)
|
|
}
|
|
|
|
ml.metaStale = false
|
|
}
|
|
|
|
// updateNameIndices updates name-based indices for an appearance
|
|
func (ml *MasterList) updateNameIndices(appearance *Appearance, add bool) {
|
|
// Index common name patterns for fast searching
|
|
name := strings.ToLower(appearance.Name)
|
|
partsSet := make(map[string]struct{}) // Use set to avoid duplicates
|
|
|
|
// Add full name
|
|
partsSet[name] = struct{}{}
|
|
|
|
// Add word-based indices for multi-word names
|
|
if strings.Contains(name, " ") {
|
|
words := strings.FieldsSeq(name)
|
|
for word := range words {
|
|
partsSet[word] = struct{}{}
|
|
}
|
|
}
|
|
|
|
// Add prefix indices for common prefixes
|
|
if len(name) >= 3 {
|
|
partsSet[name[:3]] = struct{}{}
|
|
}
|
|
if len(name) >= 5 {
|
|
partsSet[name[:5]] = struct{}{}
|
|
}
|
|
|
|
// Convert set to slice
|
|
for part := range partsSet {
|
|
if add {
|
|
ml.byNamePart[part] = append(ml.byNamePart[part], appearance)
|
|
} else {
|
|
// Remove from name part index
|
|
if namePartApps := ml.byNamePart[part]; namePartApps != nil {
|
|
for i, app := range namePartApps {
|
|
if app.ID == appearance.ID {
|
|
ml.byNamePart[part] = append(namePartApps[:i], namePartApps[i+1:]...)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// AddAppearance adds an appearance with full indexing
|
|
func (ml *MasterList) AddAppearance(appearance *Appearance) bool {
|
|
if appearance == nil {
|
|
return false
|
|
}
|
|
|
|
ml.mutex.Lock()
|
|
defer ml.mutex.Unlock()
|
|
|
|
// Check if exists
|
|
if _, exists := ml.appearances[appearance.ID]; exists {
|
|
return false
|
|
}
|
|
|
|
// Add to core storage
|
|
ml.appearances[appearance.ID] = appearance
|
|
|
|
// Update client version index
|
|
ml.byMinClient[appearance.MinClient] = append(ml.byMinClient[appearance.MinClient], appearance)
|
|
|
|
// Update name indices
|
|
ml.updateNameIndices(appearance, true)
|
|
|
|
// Invalidate metadata cache
|
|
ml.metaStale = true
|
|
|
|
return true
|
|
}
|
|
|
|
// GetAppearance retrieves by ID (O(1))
|
|
func (ml *MasterList) GetAppearance(id int32) *Appearance {
|
|
ml.mutex.RLock()
|
|
defer ml.mutex.RUnlock()
|
|
return ml.appearances[id]
|
|
}
|
|
|
|
// GetAppearanceSafe retrieves an appearance by ID with existence check
|
|
func (ml *MasterList) GetAppearanceSafe(id int32) (*Appearance, bool) {
|
|
ml.mutex.RLock()
|
|
defer ml.mutex.RUnlock()
|
|
appearance, exists := ml.appearances[id]
|
|
return appearance, exists
|
|
}
|
|
|
|
// HasAppearance checks if an appearance exists by ID
|
|
func (ml *MasterList) HasAppearance(id int32) bool {
|
|
ml.mutex.RLock()
|
|
defer ml.mutex.RUnlock()
|
|
_, exists := ml.appearances[id]
|
|
return exists
|
|
}
|
|
|
|
// GetAppearanceClone retrieves a cloned copy of an appearance by ID
|
|
func (ml *MasterList) GetAppearanceClone(id int32) *Appearance {
|
|
ml.mutex.RLock()
|
|
defer ml.mutex.RUnlock()
|
|
appearance := ml.appearances[id]
|
|
if appearance == nil {
|
|
return nil
|
|
}
|
|
return appearance.Clone()
|
|
}
|
|
|
|
// GetAllAppearances returns a copy of all appearances map
|
|
func (ml *MasterList) GetAllAppearances() map[int32]*Appearance {
|
|
ml.mutex.RLock()
|
|
defer ml.mutex.RUnlock()
|
|
|
|
// Return a copy to prevent external modification
|
|
result := make(map[int32]*Appearance, len(ml.appearances))
|
|
maps.Copy(result, ml.appearances)
|
|
return result
|
|
}
|
|
|
|
// GetAllAppearancesList returns all appearances as a slice
|
|
func (ml *MasterList) GetAllAppearancesList() []*Appearance {
|
|
ml.mutex.RLock()
|
|
defer ml.mutex.RUnlock()
|
|
|
|
result := make([]*Appearance, 0, len(ml.appearances))
|
|
for _, appearance := range ml.appearances {
|
|
result = append(result, appearance)
|
|
}
|
|
return result
|
|
}
|
|
|
|
// FindAppearancesByMinClient finds appearances with specific minimum client version (O(1))
|
|
func (ml *MasterList) FindAppearancesByMinClient(minClient int16) []*Appearance {
|
|
ml.mutex.RLock()
|
|
defer ml.mutex.RUnlock()
|
|
return ml.byMinClient[minClient]
|
|
}
|
|
|
|
// GetCompatibleAppearances returns appearances compatible with the given client version
|
|
func (ml *MasterList) GetCompatibleAppearances(clientVersion int16) []*Appearance {
|
|
ml.mutex.RLock()
|
|
defer ml.mutex.RUnlock()
|
|
|
|
var result []*Appearance
|
|
|
|
// Collect all appearances with MinClient <= clientVersion
|
|
for minClient, appearances := range ml.byMinClient {
|
|
if minClient <= clientVersion {
|
|
result = append(result, appearances...)
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// FindAppearancesByName finds appearances containing the given name substring (optimized)
|
|
func (ml *MasterList) FindAppearancesByName(nameSubstring string) []*Appearance {
|
|
ml.mutex.RLock()
|
|
defer ml.mutex.RUnlock()
|
|
|
|
searchKey := strings.ToLower(nameSubstring)
|
|
|
|
// Try indexed lookup first for exact matches
|
|
if indexedApps := ml.byNamePart[searchKey]; indexedApps != nil {
|
|
// Return a copy to prevent external modification
|
|
result := make([]*Appearance, len(indexedApps))
|
|
copy(result, indexedApps)
|
|
return result
|
|
}
|
|
|
|
// Fallback to full scan for partial matches
|
|
var result []*Appearance
|
|
for _, appearance := range ml.appearances {
|
|
if contains(strings.ToLower(appearance.Name), searchKey) {
|
|
result = append(result, appearance)
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// GetAppearancesByIDRange returns appearances within the given ID range (inclusive)
|
|
func (ml *MasterList) GetAppearancesByIDRange(minID, maxID int32) []*Appearance {
|
|
ml.mutex.RLock()
|
|
defer ml.mutex.RUnlock()
|
|
|
|
var result []*Appearance
|
|
for id, appearance := range ml.appearances {
|
|
if id >= minID && id <= maxID {
|
|
result = append(result, appearance)
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// GetAppearancesByClientRange returns appearances compatible within client version range
|
|
func (ml *MasterList) GetAppearancesByClientRange(minClientVersion, maxClientVersion int16) []*Appearance {
|
|
ml.mutex.RLock()
|
|
defer ml.mutex.RUnlock()
|
|
|
|
var result []*Appearance
|
|
|
|
// Collect all appearances with MinClient in range
|
|
for minClient, appearances := range ml.byMinClient {
|
|
if minClient >= minClientVersion && minClient <= maxClientVersion {
|
|
result = append(result, appearances...)
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// GetClientVersions returns all unique client versions using cached results
|
|
func (ml *MasterList) GetClientVersions() []int16 {
|
|
ml.mutex.Lock() // Need write lock to potentially update cache
|
|
defer ml.mutex.Unlock()
|
|
|
|
ml.refreshMetaCache()
|
|
|
|
// Return a copy to prevent external modification
|
|
result := make([]int16, len(ml.clientVersions))
|
|
copy(result, ml.clientVersions)
|
|
return result
|
|
}
|
|
|
|
// RemoveAppearance removes an appearance and updates all indices
|
|
func (ml *MasterList) RemoveAppearance(id int32) bool {
|
|
ml.mutex.Lock()
|
|
defer ml.mutex.Unlock()
|
|
|
|
appearance, exists := ml.appearances[id]
|
|
if !exists {
|
|
return false
|
|
}
|
|
|
|
// Remove from core storage
|
|
delete(ml.appearances, id)
|
|
|
|
// Remove from client version index
|
|
clientApps := ml.byMinClient[appearance.MinClient]
|
|
for i, app := range clientApps {
|
|
if app.ID == id {
|
|
ml.byMinClient[appearance.MinClient] = append(clientApps[:i], clientApps[i+1:]...)
|
|
break
|
|
}
|
|
}
|
|
|
|
// Remove from name indices
|
|
ml.updateNameIndices(appearance, false)
|
|
|
|
// Invalidate metadata cache
|
|
ml.metaStale = true
|
|
|
|
return true
|
|
}
|
|
|
|
// UpdateAppearance updates an existing appearance
|
|
func (ml *MasterList) UpdateAppearance(appearance *Appearance) error {
|
|
if appearance == nil {
|
|
return fmt.Errorf("appearance cannot be nil")
|
|
}
|
|
|
|
ml.mutex.Lock()
|
|
defer ml.mutex.Unlock()
|
|
|
|
// Check if exists
|
|
old, exists := ml.appearances[appearance.ID]
|
|
if !exists {
|
|
return fmt.Errorf("appearance %d not found", appearance.ID)
|
|
}
|
|
|
|
// Remove old appearance from indices (but not core storage yet)
|
|
clientApps := ml.byMinClient[old.MinClient]
|
|
for i, app := range clientApps {
|
|
if app.ID == appearance.ID {
|
|
ml.byMinClient[old.MinClient] = append(clientApps[:i], clientApps[i+1:]...)
|
|
break
|
|
}
|
|
}
|
|
|
|
// Remove from name indices
|
|
ml.updateNameIndices(old, false)
|
|
|
|
// Update core storage
|
|
ml.appearances[appearance.ID] = appearance
|
|
|
|
// Add new appearance to indices
|
|
ml.byMinClient[appearance.MinClient] = append(ml.byMinClient[appearance.MinClient], appearance)
|
|
ml.updateNameIndices(appearance, true)
|
|
|
|
// Invalidate metadata cache
|
|
ml.metaStale = true
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetAppearanceCount returns the number of appearances
|
|
func (ml *MasterList) GetAppearanceCount() int {
|
|
ml.mutex.RLock()
|
|
defer ml.mutex.RUnlock()
|
|
return len(ml.appearances)
|
|
}
|
|
|
|
// Size returns the total number of appearances
|
|
func (ml *MasterList) Size() int {
|
|
ml.mutex.RLock()
|
|
defer ml.mutex.RUnlock()
|
|
return len(ml.appearances)
|
|
}
|
|
|
|
// IsEmpty returns true if the master list is empty
|
|
func (ml *MasterList) IsEmpty() bool {
|
|
ml.mutex.RLock()
|
|
defer ml.mutex.RUnlock()
|
|
return len(ml.appearances) == 0
|
|
}
|
|
|
|
// ClearAppearances removes all appearances from the list
|
|
func (ml *MasterList) ClearAppearances() {
|
|
ml.mutex.Lock()
|
|
defer ml.mutex.Unlock()
|
|
|
|
// Clear all maps
|
|
ml.appearances = make(map[int32]*Appearance)
|
|
ml.byMinClient = make(map[int16][]*Appearance)
|
|
ml.byNamePart = make(map[string][]*Appearance)
|
|
|
|
// Clear cached metadata
|
|
ml.clientVersions = ml.clientVersions[:0]
|
|
ml.metaStale = true
|
|
}
|
|
|
|
// Clear removes all appearances from the master list
|
|
func (ml *MasterList) Clear() {
|
|
ml.ClearAppearances()
|
|
}
|
|
|
|
// ForEach executes a function for each appearance
|
|
func (ml *MasterList) ForEach(fn func(int32, *Appearance)) {
|
|
ml.mutex.RLock()
|
|
defer ml.mutex.RUnlock()
|
|
|
|
for id, appearance := range ml.appearances {
|
|
fn(id, appearance)
|
|
}
|
|
}
|
|
|
|
// ValidateAppearances checks all appearances for consistency
|
|
func (ml *MasterList) ValidateAppearances() []string {
|
|
ml.mutex.RLock()
|
|
defer ml.mutex.RUnlock()
|
|
|
|
var issues []string
|
|
|
|
for id, appearance := range ml.appearances {
|
|
if appearance == nil {
|
|
issues = append(issues, fmt.Sprintf("Appearance ID %d is nil", id))
|
|
continue
|
|
}
|
|
|
|
if appearance.GetID() != id {
|
|
issues = append(issues, fmt.Sprintf("Appearance ID mismatch: map key %d != appearance ID %d", id, appearance.GetID()))
|
|
}
|
|
|
|
if len(appearance.GetName()) == 0 {
|
|
issues = append(issues, fmt.Sprintf("Appearance ID %d has empty name", id))
|
|
}
|
|
|
|
if appearance.GetMinClientVersion() < 0 {
|
|
issues = append(issues, fmt.Sprintf("Appearance ID %d has negative min client version: %d", id, appearance.GetMinClientVersion()))
|
|
}
|
|
}
|
|
|
|
return issues
|
|
}
|
|
|
|
// IsValid returns true if all appearances are valid
|
|
func (ml *MasterList) IsValid() bool {
|
|
issues := ml.ValidateAppearances()
|
|
return len(issues) == 0
|
|
}
|
|
|
|
// GetStatistics returns statistics about the appearance collection
|
|
func (ml *MasterList) GetStatistics() map[string]any {
|
|
ml.mutex.RLock()
|
|
defer ml.mutex.RUnlock()
|
|
|
|
stats := make(map[string]any)
|
|
stats["total_appearances"] = len(ml.appearances)
|
|
|
|
if len(ml.appearances) == 0 {
|
|
return stats
|
|
}
|
|
|
|
// Count by minimum client version
|
|
versionCounts := make(map[int16]int)
|
|
var minID, maxID int32
|
|
first := true
|
|
|
|
for id, appearance := range ml.appearances {
|
|
versionCounts[appearance.GetMinClientVersion()]++
|
|
|
|
if first {
|
|
minID = id
|
|
maxID = id
|
|
first = false
|
|
} else {
|
|
if id < minID {
|
|
minID = id
|
|
}
|
|
if id > maxID {
|
|
maxID = id
|
|
}
|
|
}
|
|
}
|
|
|
|
stats["appearances_by_min_client"] = versionCounts
|
|
stats["min_id"] = minID
|
|
stats["max_id"] = maxID
|
|
stats["id_range"] = maxID - minID
|
|
|
|
return stats
|
|
}
|
|
|
|
// LoadAllAppearances loads all appearances from the database into the master list
|
|
func (ml *MasterList) LoadAllAppearances(db *database.Database) error {
|
|
if db == nil {
|
|
return fmt.Errorf("database connection is nil")
|
|
}
|
|
|
|
// Clear existing appearances
|
|
ml.Clear()
|
|
|
|
query := `SELECT appearance_id, name, min_client_version FROM appearances ORDER BY appearance_id`
|
|
rows, err := db.Query(query)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to query appearances: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
count := 0
|
|
for rows.Next() {
|
|
appearance := &Appearance{
|
|
db: db,
|
|
isNew: false,
|
|
}
|
|
|
|
err := rows.Scan(&appearance.ID, &appearance.Name, &appearance.MinClient)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to scan appearance: %w", err)
|
|
}
|
|
|
|
if !ml.AddAppearance(appearance) {
|
|
return fmt.Errorf("failed to add appearance %d to master list", appearance.ID)
|
|
}
|
|
|
|
count++
|
|
}
|
|
|
|
if err := rows.Err(); err != nil {
|
|
return fmt.Errorf("error iterating appearance rows: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// LoadAllAppearancesFromDatabase is a convenience function that creates a master list and loads all appearances
|
|
func LoadAllAppearancesFromDatabase(db *database.Database) (*MasterList, error) {
|
|
masterList := NewMasterList()
|
|
err := masterList.LoadAllAppearances(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
|
|
}
|