simplify appearances

This commit is contained in:
Sky Johnson 2025-08-23 16:51:35 -05:00
parent 4080a57d3e
commit 5252b4f677
5 changed files with 600 additions and 743 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -106,6 +106,10 @@ const (
OP_CommitAATemplate OP_CommitAATemplate
OP_ExamineAASpellInfo OP_ExamineAASpellInfo
// Appearance system opcodes
OP_DressingRoom
OP_ReskinCharacterRequestMsg
// Add more opcodes as needed... // Add more opcodes as needed...
_maxInternalOpcode // Sentinel value _maxInternalOpcode // Sentinel value
) )
@ -177,6 +181,8 @@ var OpcodeNames = map[InternalOpcode]string{
OP_AdvancementRequestMsg: "OP_AdvancementRequestMsg", OP_AdvancementRequestMsg: "OP_AdvancementRequestMsg",
OP_CommitAATemplate: "OP_CommitAATemplate", OP_CommitAATemplate: "OP_CommitAATemplate",
OP_ExamineAASpellInfo: "OP_ExamineAASpellInfo", OP_ExamineAASpellInfo: "OP_ExamineAASpellInfo",
OP_DressingRoom: "OP_DressingRoom",
OP_ReskinCharacterRequestMsg: "OP_ReskinCharacterRequestMsg",
} }
// OpcodeManager handles the mapping between client-specific opcodes and internal opcodes // OpcodeManager handles the mapping between client-specific opcodes and internal opcodes