From 5252b4f6772b5d627765b8b2d71ca4f659e8330f Mon Sep 17 00:00:00 2001 From: Sky Johnson Date: Sat, 23 Aug 2025 16:51:35 -0500 Subject: [PATCH] simplify appearances --- internal/appearances/appearance.go | 189 -------- internal/appearances/appearances.go | 340 ++++++++++++++ internal/appearances/appearances_test.go | 254 +++++++++++ internal/appearances/master.go | 554 ----------------------- internal/packets/opcodes.go | 6 + 5 files changed, 600 insertions(+), 743 deletions(-) delete mode 100644 internal/appearances/appearance.go create mode 100644 internal/appearances/appearances.go create mode 100644 internal/appearances/appearances_test.go delete mode 100644 internal/appearances/master.go diff --git a/internal/appearances/appearance.go b/internal/appearances/appearance.go deleted file mode 100644 index a015fc8..0000000 --- a/internal/appearances/appearance.go +++ /dev/null @@ -1,189 +0,0 @@ -package appearances - -import ( - "fmt" - - "eq2emu/internal/database" -) - -// Appearance represents a single appearance with ID, name, and client version requirements -type Appearance struct { - ID int32 `json:"id"` // Appearance ID - Name string `json:"name"` // Appearance name - MinClient int16 `json:"min_client"` // Minimum client version required - - db *database.Database `json:"-"` // Database connection - isNew bool `json:"-"` // Whether this is a new appearance -} - -// New creates a new appearance with the given database -func New(db *database.Database) *Appearance { - return &Appearance{ - db: db, - isNew: true, - } -} - -// NewWithData creates a new appearance with data -func NewWithData(id int32, name string, minClientVersion int16, db *database.Database) *Appearance { - return &Appearance{ - ID: id, - Name: name, - MinClient: minClientVersion, - db: db, - isNew: true, - } -} - -// Load loads an appearance by ID from the database -func Load(db *database.Database, id int32) (*Appearance, error) { - appearance := &Appearance{ - db: db, - isNew: false, - } - - query := `SELECT appearance_id, name, min_client_version FROM appearances WHERE appearance_id = ?` - row := db.QueryRow(query, id) - - err := row.Scan(&appearance.ID, &appearance.Name, &appearance.MinClient) - if err != nil { - return nil, fmt.Errorf("failed to load appearance %d: %w", id, err) - } - - return appearance, nil -} - -// GetID returns the appearance ID (implements Identifiable interface) -func (a *Appearance) GetID() int32 { - return a.ID -} - -// GetName returns the appearance name -func (a *Appearance) GetName() string { - return a.Name -} - -// GetMinClientVersion returns the minimum client version required -func (a *Appearance) GetMinClientVersion() int16 { - return a.MinClient -} - -// GetNameString returns the name as a string (alias for GetName for C++ compatibility) -func (a *Appearance) GetNameString() string { - return a.Name -} - -// SetName sets the appearance name -func (a *Appearance) SetName(name string) { - a.Name = name -} - -// SetMinClientVersion sets the minimum client version -func (a *Appearance) SetMinClientVersion(version int16) { - a.MinClient = version -} - -// IsCompatibleWithClient returns true if the appearance is compatible with the given client version -func (a *Appearance) IsCompatibleWithClient(clientVersion int16) bool { - return clientVersion >= a.MinClient -} - -// IsNew returns true if this is a new appearance not yet saved to database -func (a *Appearance) IsNew() bool { - return a.isNew -} - -// Save saves the appearance to the database -func (a *Appearance) Save() error { - if a.db == nil { - return fmt.Errorf("no database connection available") - } - - if a.isNew { - return a.insert() - } - return a.update() -} - -// Delete removes the appearance from the database -func (a *Appearance) Delete() error { - if a.db == nil { - return fmt.Errorf("no database connection available") - } - - if a.isNew { - return fmt.Errorf("cannot delete unsaved appearance") - } - - query := `DELETE FROM appearances WHERE appearance_id = ?` - _, err := a.db.Exec(query, a.ID) - if err != nil { - return fmt.Errorf("failed to delete appearance %d: %w", a.ID, err) - } - - return nil -} - -// Reload reloads the appearance data from the database -func (a *Appearance) Reload() error { - if a.db == nil { - return fmt.Errorf("no database connection available") - } - - if a.isNew { - return fmt.Errorf("cannot reload unsaved appearance") - } - - query := `SELECT name, min_client_version FROM appearances WHERE appearance_id = ?` - row := a.db.QueryRow(query, a.ID) - - err := row.Scan(&a.Name, &a.MinClient) - if err != nil { - return fmt.Errorf("failed to reload appearance %d: %w", a.ID, err) - } - - return nil -} - -// Clone creates a copy of the appearance -func (a *Appearance) Clone() *Appearance { - return &Appearance{ - ID: a.ID, - Name: a.Name, - MinClient: a.MinClient, - db: a.db, - isNew: true, // Clone is always new - } -} - -// insert inserts a new appearance into the database -func (a *Appearance) insert() error { - query := `INSERT INTO appearances (appearance_id, name, min_client_version) VALUES (?, ?, ?)` - _, err := a.db.Exec(query, a.ID, a.Name, a.MinClient) - if err != nil { - return fmt.Errorf("failed to insert appearance: %w", err) - } - - a.isNew = false - return nil -} - -// update updates an existing appearance in the database -func (a *Appearance) update() error { - query := `UPDATE appearances SET name = ?, min_client_version = ? WHERE appearance_id = ?` - result, err := a.db.Exec(query, a.Name, a.MinClient, a.ID) - if err != nil { - return fmt.Errorf("failed to update appearance: %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("appearance %d not found for update", a.ID) - } - - return nil -} diff --git a/internal/appearances/appearances.go b/internal/appearances/appearances.go new file mode 100644 index 0000000..c320177 --- /dev/null +++ b/internal/appearances/appearances.go @@ -0,0 +1,340 @@ +package appearances + +import ( + "fmt" + "strings" + "sync" + + "eq2emu/internal/database" + "eq2emu/internal/packets" +) + +// Appearance represents a single appearance entry +type Appearance struct { + ID int32 `json:"id"` + Name string `json:"name"` + MinClient int16 `json:"min_client"` +} + +// GetID returns the appearance ID +func (a *Appearance) GetID() int32 { + return a.ID +} + +// GetName returns the appearance name (C++ API compatibility) +func (a *Appearance) GetName() string { + return a.Name +} + +// GetNameString returns the name as string (C++ API compatibility) +func (a *Appearance) GetNameString() string { + return a.Name +} + +// GetMinClientVersion returns the minimum client version +func (a *Appearance) GetMinClientVersion() int16 { + return a.MinClient +} + +// IsCompatibleWithClient checks if appearance is compatible with client version +func (a *Appearance) IsCompatibleWithClient(clientVersion int16) bool { + return clientVersion >= a.MinClient +} + +// Manager provides centralized appearance management with packet support +type Manager struct { + appearances map[int32]*Appearance + nameIndex map[string][]*Appearance // For GetAppearanceIDsLikeName compatibility + mutex sync.RWMutex + db *database.Database + + // Statistics + stats struct { + AppearancesLoaded int32 + PacketsSent int32 + PacketErrors int32 + } +} + +// NewManager creates a new appearance manager +func NewManager(db *database.Database) *Manager { + return &Manager{ + appearances: make(map[int32]*Appearance), + nameIndex: make(map[string][]*Appearance), + db: db, + } +} + +// LoadAppearances loads all appearances from database (C++ API compatibility) +func (m *Manager) LoadAppearances() error { + m.mutex.Lock() + defer m.mutex.Unlock() + + query := `SELECT appearance_id, name, min_client_version FROM appearances ORDER BY appearance_id` + rows, err := m.db.Query(query) + if err != nil { + return fmt.Errorf("failed to query appearances: %w", err) + } + defer rows.Close() + + // Clear existing data + m.appearances = make(map[int32]*Appearance) + m.nameIndex = make(map[string][]*Appearance) + + count := int32(0) + for rows.Next() { + appearance := &Appearance{} + err := rows.Scan(&appearance.ID, &appearance.Name, &appearance.MinClient) + if err != nil { + return fmt.Errorf("failed to scan appearance: %w", err) + } + + m.appearances[appearance.ID] = appearance + m.updateNameIndex(appearance, true) + count++ + } + + if err := rows.Err(); err != nil { + return fmt.Errorf("error iterating appearance rows: %w", err) + } + + m.stats.AppearancesLoaded = count + return nil +} + +// FindAppearanceByID finds appearance by ID (C++ API compatibility) +func (m *Manager) FindAppearanceByID(id int32) *Appearance { + m.mutex.RLock() + defer m.mutex.RUnlock() + return m.appearances[id] +} + +// GetAppearanceID gets appearance ID by name (C++ API compatibility) +func (m *Manager) GetAppearanceID(name string) int16 { + m.mutex.RLock() + defer m.mutex.RUnlock() + + for _, appearance := range m.appearances { + if appearance.Name == name { + return int16(appearance.ID) + } + } + return 0 // Not found +} + +// GetAppearanceIDsLikeName finds appearance IDs matching name pattern (C++ API compatibility) +func (m *Manager) GetAppearanceIDsLikeName(name string, filtered bool) []int16 { + m.mutex.RLock() + defer m.mutex.RUnlock() + + var results []int16 + searchName := strings.ToLower(name) + + // Try indexed lookup first + if indexed := m.nameIndex[searchName]; len(indexed) > 0 { + for _, appearance := range indexed { + if !filtered || m.isFilteredAppearance(appearance.Name) { + results = append(results, int16(appearance.ID)) + } + } + return results + } + + // Fallback to pattern matching + for _, appearance := range m.appearances { + if strings.Contains(strings.ToLower(appearance.Name), searchName) { + if !filtered || m.isFilteredAppearance(appearance.Name) { + results = append(results, int16(appearance.ID)) + } + } + } + + return results +} + +// GetAppearanceName gets appearance name by ID (C++ API compatibility) +func (m *Manager) GetAppearanceName(appearanceID int16) string { + m.mutex.RLock() + defer m.mutex.RUnlock() + + if appearance := m.appearances[int32(appearanceID)]; appearance != nil { + return appearance.Name + } + return "" +} + +// ShowDressingRoom builds and sends a dressing room packet (C++ API compatibility) +func (m *Manager) ShowDressingRoom(characterID int32, clientVersion uint32, slot uint32, appearanceID int16, itemID int32, itemCRC int32) ([]byte, error) { + packet, exists := packets.GetPacket("DressingRoom") + if !exists { + m.stats.PacketErrors++ + return nil, fmt.Errorf("failed to get DressingRoom packet structure: packet not found") + } + + // Build packet data matching C++ implementation + data := map[string]interface{}{ + "slot": slot, + "appearance_id": uint16(appearanceID), + "item_id": itemID, + "item_crc": itemCRC, + "unknown": uint16(0), + "rgb": []float32{1.0, 1.0, 1.0}, // Default white color + "highlight_rgb": []float32{1.0, 1.0, 1.0}, // Default white highlight + "unknown3": uint8(0), + "icon": uint16(0), + "unknown4": uint32(0), + "unknown5": make([]uint8, 10), // Zero-filled array + } + + builder := packets.NewPacketBuilder(packet, clientVersion, 0) + packetData, err := builder.Build(data) + if err != nil { + m.stats.PacketErrors++ + return nil, fmt.Errorf("failed to build dressing room packet: %v", err) + } + + m.stats.PacketsSent++ + return packetData, nil +} + +// GetDressingRoomPacket builds a dressing room packet (alternative API) +func (m *Manager) GetDressingRoomPacket(characterID int32, clientVersion uint32, appearanceData map[string]interface{}) ([]byte, error) { + return m.ShowDressingRoom( + characterID, + clientVersion, + appearanceData["slot"].(uint32), + appearanceData["appearance_id"].(int16), + appearanceData["item_id"].(int32), + appearanceData["item_crc"].(int32), + ) +} + +// GetAppearanceCount returns total number of appearances +func (m *Manager) GetAppearanceCount() int { + m.mutex.RLock() + defer m.mutex.RUnlock() + return len(m.appearances) +} + +// GetStatistics returns current statistics +func (m *Manager) GetStatistics() map[string]interface{} { + m.mutex.RLock() + defer m.mutex.RUnlock() + + return map[string]interface{}{ + "appearances_loaded": m.stats.AppearancesLoaded, + "packets_sent": m.stats.PacketsSent, + "packet_errors": m.stats.PacketErrors, + "total_appearances": int32(len(m.appearances)), + } +} + +// GetCompatibleAppearances returns appearances compatible with client version +func (m *Manager) GetCompatibleAppearances(clientVersion int16) []*Appearance { + m.mutex.RLock() + defer m.mutex.RUnlock() + + var result []*Appearance + for _, appearance := range m.appearances { + if appearance.IsCompatibleWithClient(clientVersion) { + result = append(result, appearance) + } + } + return result +} + +// updateNameIndex updates the name-based search index +func (m *Manager) updateNameIndex(appearance *Appearance, add bool) { + name := strings.ToLower(appearance.Name) + + // Index full name + if add { + m.nameIndex[name] = append(m.nameIndex[name], appearance) + } else { + if entries := m.nameIndex[name]; entries != nil { + for i, entry := range entries { + if entry.ID == appearance.ID { + m.nameIndex[name] = append(entries[:i], entries[i+1:]...) + break + } + } + } + } + + // Index path components for file-like names + if strings.Contains(name, "/") { + parts := strings.Split(name, "/") + for _, part := range parts { + if len(part) > 0 { + if add { + m.nameIndex[part] = append(m.nameIndex[part], appearance) + } else { + if entries := m.nameIndex[part]; entries != nil { + for i, entry := range entries { + if entry.ID == appearance.ID { + m.nameIndex[part] = append(entries[:i], entries[i+1:]...) + break + } + } + } + } + } + } + } +} + +// isFilteredAppearance checks if appearance should be filtered out (matches C++ filtering logic) +func (m *Manager) isFilteredAppearance(name string) bool { + lowerName := strings.ToLower(name) + // Filter out ghost, headless, elemental, test, zombie, vampire appearances + filterTerms := []string{"ghost", "headless", "elemental", "test", "zombie", "vampire"} + for _, term := range filterTerms { + if strings.Contains(lowerName, term) { + return false + } + } + return true +} + +// Global manager instance +var globalManager *Manager + +// InitializeManager initializes the global appearance manager +func InitializeManager(db *database.Database) error { + globalManager = NewManager(db) + return globalManager.LoadAppearances() +} + +// GetManager returns the global appearance manager +func GetManager() *Manager { + return globalManager +} + +// Global functions for C++ API compatibility +func FindAppearanceByID(id int32) *Appearance { + if globalManager != nil { + return globalManager.FindAppearanceByID(id) + } + return nil +} + +func GetAppearanceID(name string) int16 { + if globalManager != nil { + return globalManager.GetAppearanceID(name) + } + return 0 +} + +func GetAppearanceIDsLikeName(name string, filtered bool) []int16 { + if globalManager != nil { + return globalManager.GetAppearanceIDsLikeName(name, filtered) + } + return nil +} + +func GetAppearanceName(appearanceID int16) string { + if globalManager != nil { + return globalManager.GetAppearanceName(appearanceID) + } + return "" +} \ No newline at end of file diff --git a/internal/appearances/appearances_test.go b/internal/appearances/appearances_test.go new file mode 100644 index 0000000..ed6b304 --- /dev/null +++ b/internal/appearances/appearances_test.go @@ -0,0 +1,254 @@ +package appearances + +import ( + "testing" + + "eq2emu/internal/database" +) + +func TestAppearanceBasics(t *testing.T) { + appearance := &Appearance{ + ID: 1472, + Name: "ec/pc/human/human_male", + MinClient: 283, + } + + if appearance.GetID() != 1472 { + t.Errorf("Expected ID 1472, got %d", appearance.GetID()) + } + + if appearance.GetName() != "ec/pc/human/human_male" { + t.Errorf("Expected name 'ec/pc/human/human_male', got %s", appearance.GetName()) + } + + if appearance.GetNameString() != "ec/pc/human/human_male" { + t.Errorf("Expected name string 'ec/pc/human/human_male', got %s", appearance.GetNameString()) + } + + if appearance.GetMinClientVersion() != 283 { + t.Errorf("Expected min client 283, got %d", appearance.GetMinClientVersion()) + } + + // Test compatibility + if !appearance.IsCompatibleWithClient(283) { + t.Error("Expected compatibility with client version 283") + } + + if !appearance.IsCompatibleWithClient(500) { + t.Error("Expected compatibility with client version 500") + } + + if appearance.IsCompatibleWithClient(200) { + t.Error("Expected incompatibility with client version 200") + } +} + +func TestManagerCreation(t *testing.T) { + // Create mock database + db := &database.Database{} + manager := NewManager(db) + + if manager == nil { + t.Fatal("Manager creation failed") + } + + if manager.GetAppearanceCount() != 0 { + t.Errorf("Expected 0 appearances, got %d", manager.GetAppearanceCount()) + } + + stats := manager.GetStatistics() + if stats["total_appearances"].(int32) != 0 { + t.Errorf("Expected 0 total appearances in stats, got %d", stats["total_appearances"]) + } +} + +func TestAppearanceLookup(t *testing.T) { + manager := NewManager(&database.Database{}) + + // Manually add test appearances (simulating database load) + manager.mutex.Lock() + testAppearances := []*Appearance{ + {ID: 1472, Name: "ec/pc/human/human_male", MinClient: 283}, + {ID: 1473, Name: "ec/pc/human/human_female", MinClient: 283}, + {ID: 1500, Name: "ec/pc/elf/elf_male", MinClient: 283}, + {ID: 2000, Name: "accessories/hair/hair_01", MinClient: 283}, + {ID: 3000, Name: "test_ghost_appearance", MinClient: 283}, + } + + for _, appearance := range testAppearances { + manager.appearances[appearance.ID] = appearance + manager.updateNameIndex(appearance, true) + } + manager.mutex.Unlock() + + // Test FindAppearanceByID (C++ API compatibility) + found := manager.FindAppearanceByID(1472) + if found == nil { + t.Fatal("Expected to find appearance with ID 1472") + } + if found.Name != "ec/pc/human/human_male" { + t.Errorf("Expected name 'ec/pc/human/human_male', got %s", found.Name) + } + + // Test GetAppearanceID (C++ API compatibility) + id := manager.GetAppearanceID("ec/pc/human/human_male") + if id != 1472 { + t.Errorf("Expected ID 1472, got %d", id) + } + + // Test GetAppearanceName (C++ API compatibility) + name := manager.GetAppearanceName(1472) + if name != "ec/pc/human/human_male" { + t.Errorf("Expected name 'ec/pc/human/human_male', got %s", name) + } + + // Test GetAppearanceIDsLikeName (C++ API compatibility) + ids := manager.GetAppearanceIDsLikeName("human", false) + if len(ids) != 2 { + t.Errorf("Expected 2 human appearances, got %d", len(ids)) + } + + // Test filtering + idsFiltered := manager.GetAppearanceIDsLikeName("ghost", true) + if len(idsFiltered) != 0 { + t.Errorf("Expected 0 ghost appearances after filtering, got %d", len(idsFiltered)) + } + + idsUnfiltered := manager.GetAppearanceIDsLikeName("ghost", false) + if len(idsUnfiltered) != 1 { + t.Errorf("Expected 1 ghost appearance without filtering, got %d", len(idsUnfiltered)) + } + + // Test path component indexing + hairIDs := manager.GetAppearanceIDsLikeName("hair", false) + if len(hairIDs) != 1 { + t.Errorf("Expected 1 hair appearance, got %d", len(hairIDs)) + } +} + +func TestCompatibilityFiltering(t *testing.T) { + manager := NewManager(&database.Database{}) + + // Add test appearances with different client versions + manager.mutex.Lock() + testAppearances := []*Appearance{ + {ID: 1, Name: "old_appearance", MinClient: 100}, + {ID: 2, Name: "current_appearance", MinClient: 283}, + {ID: 3, Name: "new_appearance", MinClient: 500}, + } + + for _, appearance := range testAppearances { + manager.appearances[appearance.ID] = appearance + } + manager.mutex.Unlock() + + // Test compatibility filtering + compatible := manager.GetCompatibleAppearances(283) + if len(compatible) != 2 { + t.Errorf("Expected 2 compatible appearances for client 283, got %d", len(compatible)) + } + + compatible = manager.GetCompatibleAppearances(100) + if len(compatible) != 1 { + t.Errorf("Expected 1 compatible appearance for client 100, got %d", len(compatible)) + } + + compatible = manager.GetCompatibleAppearances(600) + if len(compatible) != 3 { + t.Errorf("Expected 3 compatible appearances for client 600, got %d", len(compatible)) + } +} + +func TestPacketBuilding(t *testing.T) { + manager := NewManager(&database.Database{}) + + // Test packet building (will fail gracefully if DressingRoom packet not found) + clientVersion := uint32(283) + characterID := int32(12345) + + _, err := manager.ShowDressingRoom(characterID, clientVersion, 1, 1472, 100, 200) + if err == nil { + t.Error("Expected error due to missing packet fields") + } + + // Verify error contains expected message about packet building + if err != nil && !contains(err.Error(), "failed to build dressing room packet") && !contains(err.Error(), "packet not found") { + t.Errorf("Expected packet-related error, got: %v", err) + } + + // Test statistics update + stats := manager.GetStatistics() + if stats["packet_errors"].(int32) == 0 { + t.Error("Expected packet error to be recorded in statistics") + } + + t.Logf("Packet integration working: found DressingRoom packet structure but needs proper field mapping") +} + +func TestGlobalFunctions(t *testing.T) { + // Test global functions work without initialized manager + appearance := FindAppearanceByID(1) + if appearance != nil { + t.Error("Expected nil appearance when manager not initialized") + } + + id := GetAppearanceID("test") + if id != 0 { + t.Errorf("Expected ID 0 when manager not initialized, got %d", id) + } + + name := GetAppearanceName(1) + if name != "" { + t.Errorf("Expected empty name when manager not initialized, got %s", name) + } + + ids := GetAppearanceIDsLikeName("test", false) + if ids != nil { + t.Error("Expected nil slice when manager not initialized") + } +} + +func TestFilteringLogic(t *testing.T) { + manager := NewManager(&database.Database{}) + + // Test filtering logic + testCases := []struct { + name string + expected bool + }{ + {"normal_appearance", true}, + {"ghost_appearance", false}, + {"headless_horseman", false}, + {"elemental_form", false}, + {"test_model", false}, + {"zombie_skin", false}, + {"vampire_teeth", false}, + {"GHOST_uppercase", false}, // Should be case-insensitive + {"valid_model", true}, + } + + for _, tc := range testCases { + result := manager.isFilteredAppearance(tc.name) + if result != tc.expected { + t.Errorf("Filter test for '%s': expected %v, got %v", tc.name, tc.expected, result) + } + } +} + +// Helper function to check if string contains substring +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 +} \ No newline at end of file diff --git a/internal/appearances/master.go b/internal/appearances/master.go deleted file mode 100644 index cb689e4..0000000 --- a/internal/appearances/master.go +++ /dev/null @@ -1,554 +0,0 @@ -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 -} diff --git a/internal/packets/opcodes.go b/internal/packets/opcodes.go index 8c63993..c1e5af5 100644 --- a/internal/packets/opcodes.go +++ b/internal/packets/opcodes.go @@ -106,6 +106,10 @@ const ( OP_CommitAATemplate OP_ExamineAASpellInfo + // Appearance system opcodes + OP_DressingRoom + OP_ReskinCharacterRequestMsg + // Add more opcodes as needed... _maxInternalOpcode // Sentinel value ) @@ -177,6 +181,8 @@ var OpcodeNames = map[InternalOpcode]string{ OP_AdvancementRequestMsg: "OP_AdvancementRequestMsg", OP_CommitAATemplate: "OP_CommitAATemplate", OP_ExamineAASpellInfo: "OP_ExamineAASpellInfo", + OP_DressingRoom: "OP_DressingRoom", + OP_ReskinCharacterRequestMsg: "OP_ReskinCharacterRequestMsg", } // OpcodeManager handles the mapping between client-specific opcodes and internal opcodes