diff --git a/internal/appearances/appearance.go b/internal/appearances/appearance.go
new file mode 100644
index 0000000..a015fc8
--- /dev/null
+++ b/internal/appearances/appearance.go
@@ -0,0 +1,189 @@
+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/appearance_test.go b/internal/appearances/appearance_test.go
new file mode 100644
index 0000000..ecbfa19
--- /dev/null
+++ b/internal/appearances/appearance_test.go
@@ -0,0 +1,221 @@
+package appearances
+
+import (
+ "testing"
+
+ "eq2emu/internal/database"
+)
+
+func TestNew(t *testing.T) {
+ db, err := database.NewSQLite("file::memory:?mode=memory&cache=shared")
+ if err != nil {
+ t.Fatalf("Failed to create test database: %v", err)
+ }
+ defer db.Close()
+
+ // Test creating a new appearance
+ appearance := New(db)
+ if appearance == nil {
+ t.Fatal("New returned nil")
+ }
+
+ if !appearance.IsNew() {
+ t.Error("New appearance should be marked as new")
+ }
+
+ // Test setting values
+ appearance.ID = 1001
+ appearance.Name = "Test Appearance"
+ appearance.MinClient = 1096
+
+ if appearance.GetID() != 1001 {
+ t.Errorf("Expected GetID() to return 1001, got %d", appearance.GetID())
+ }
+
+ if appearance.GetName() != "Test Appearance" {
+ t.Errorf("Expected GetName() to return 'Test Appearance', got %s", appearance.GetName())
+ }
+
+ if appearance.GetMinClientVersion() != 1096 {
+ t.Errorf("Expected GetMinClientVersion() to return 1096, got %d", appearance.GetMinClientVersion())
+ }
+}
+
+func TestNewWithData(t *testing.T) {
+ db, err := database.NewSQLite("file::memory:?mode=memory&cache=shared")
+ if err != nil {
+ t.Fatalf("Failed to create test database: %v", err)
+ }
+ defer db.Close()
+
+ appearance := NewWithData(100, "Human Male", 1096, db)
+ if appearance == nil {
+ t.Fatal("NewWithData returned nil")
+ }
+
+ if appearance.GetID() != 100 {
+ t.Errorf("Expected ID 100, got %d", appearance.GetID())
+ }
+
+ if appearance.GetName() != "Human Male" {
+ t.Errorf("Expected name 'Human Male', got '%s'", appearance.GetName())
+ }
+
+ if appearance.GetMinClientVersion() != 1096 {
+ t.Errorf("Expected min client 1096, got %d", appearance.GetMinClientVersion())
+ }
+
+ if !appearance.IsNew() {
+ t.Error("NewWithData should create new appearance")
+ }
+}
+
+func TestAppearanceGetters(t *testing.T) {
+ db, _ := database.NewSQLite("file::memory:?mode=memory&cache=shared")
+ defer db.Close()
+
+ app := NewWithData(123, "Test Appearance", 1096, db)
+
+ if id := app.GetID(); id != 123 {
+ t.Errorf("GetID() = %v, want 123", id)
+ }
+
+ if name := app.GetName(); name != "Test Appearance" {
+ t.Errorf("GetName() = %v, want Test Appearance", name)
+ }
+
+ if nameStr := app.GetNameString(); nameStr != "Test Appearance" {
+ t.Errorf("GetNameString() = %v, want Test Appearance", nameStr)
+ }
+
+ if minVer := app.GetMinClientVersion(); minVer != 1096 {
+ t.Errorf("GetMinClientVersion() = %v, want 1096", minVer)
+ }
+}
+
+func TestAppearanceSetters(t *testing.T) {
+ db, _ := database.NewSQLite("file::memory:?mode=memory&cache=shared")
+ defer db.Close()
+
+ app := NewWithData(100, "Original", 1000, db)
+
+ app.SetName("Modified Name")
+ if app.GetName() != "Modified Name" {
+ t.Errorf("SetName failed: got %v, want Modified Name", app.GetName())
+ }
+
+ app.SetMinClientVersion(2000)
+ if app.GetMinClientVersion() != 2000 {
+ t.Errorf("SetMinClientVersion failed: got %v, want 2000", app.GetMinClientVersion())
+ }
+}
+
+func TestIsCompatibleWithClient(t *testing.T) {
+ db, _ := database.NewSQLite("file::memory:?mode=memory&cache=shared")
+ defer db.Close()
+
+ app := NewWithData(100, "Test", 1096, db)
+
+ tests := []struct {
+ clientVersion int16
+ want bool
+ }{
+ {1095, false}, // Below minimum
+ {1096, true}, // Exact minimum
+ {1097, true}, // Above minimum
+ {2000, true}, // Well above minimum
+ {0, false}, // Zero version
+ }
+
+ for _, tt := range tests {
+ t.Run("", func(t *testing.T) {
+ if got := app.IsCompatibleWithClient(tt.clientVersion); got != tt.want {
+ t.Errorf("IsCompatibleWithClient(%v) = %v, want %v", tt.clientVersion, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestAppearanceClone(t *testing.T) {
+ db, _ := database.NewSQLite("file::memory:?mode=memory&cache=shared")
+ defer db.Close()
+
+ original := NewWithData(500, "Original Appearance", 1200, db)
+ clone := original.Clone()
+
+ if clone == nil {
+ t.Fatal("Clone returned nil")
+ }
+
+ if clone == original {
+ t.Error("Clone returned same pointer as original")
+ }
+
+ if clone.GetID() != original.GetID() {
+ t.Errorf("Clone ID = %v, want %v", clone.GetID(), original.GetID())
+ }
+
+ if clone.GetName() != original.GetName() {
+ t.Errorf("Clone Name = %v, want %v", clone.GetName(), original.GetName())
+ }
+
+ if clone.GetMinClientVersion() != original.GetMinClientVersion() {
+ t.Errorf("Clone MinClientVersion = %v, want %v", clone.GetMinClientVersion(), original.GetMinClientVersion())
+ }
+
+ if !clone.IsNew() {
+ t.Error("Clone should always be marked as new")
+ }
+
+ // Verify modification independence
+ clone.SetName("Modified Clone")
+ if original.GetName() == "Modified Clone" {
+ t.Error("Modifying clone affected original")
+ }
+}
+
+// Test appearance type functions
+func TestGetAppearanceType(t *testing.T) {
+ tests := []struct {
+ typeName string
+ expected int8
+ }{
+ {"hair_color1", AppearanceHairColor1},
+ {"soga_hair_color1", AppearanceSOGAHairColor1},
+ {"skin_color", AppearanceSkinColor},
+ {"eye_color", AppearanceEyeColor},
+ {"unknown_type", -1},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.typeName, func(t *testing.T) {
+ result := GetAppearanceType(tt.typeName)
+ if result != tt.expected {
+ t.Errorf("GetAppearanceType(%q) = %d, want %d", tt.typeName, result, tt.expected)
+ }
+ })
+ }
+}
+
+func TestGetAppearanceTypeName(t *testing.T) {
+ tests := []struct {
+ typeConst int8
+ expected string
+ }{
+ {AppearanceHairColor1, "hair_color1"},
+ {AppearanceSOGAHairColor1, "soga_hair_color1"},
+ {AppearanceSkinColor, "skin_color"},
+ {AppearanceEyeColor, "eye_color"},
+ {-1, "unknown"},
+ {100, "unknown"},
+ }
+
+ for _, tt := range tests {
+ t.Run("", func(t *testing.T) {
+ result := GetAppearanceTypeName(tt.typeConst)
+ if result != tt.expected {
+ t.Errorf("GetAppearanceTypeName(%d) = %q, want %q", tt.typeConst, result, tt.expected)
+ }
+ })
+ }
+}
diff --git a/internal/appearances/appearances.go b/internal/appearances/appearances.go
deleted file mode 100644
index b97dbdd..0000000
--- a/internal/appearances/appearances.go
+++ /dev/null
@@ -1,288 +0,0 @@
-package appearances
-
-import (
- "fmt"
- "sync"
-)
-
-// Appearances manages a collection of appearance objects with thread-safe operations
-type Appearances struct {
- appearanceMap map[int32]*Appearance // Map of appearance ID to appearance
- mutex sync.RWMutex // Thread safety for concurrent access
-}
-
-// NewAppearances creates a new appearances manager
-func NewAppearances() *Appearances {
- return &Appearances{
- appearanceMap: make(map[int32]*Appearance),
- }
-}
-
-// Reset clears all appearances from the manager
-func (a *Appearances) Reset() {
- a.ClearAppearances()
-}
-
-// ClearAppearances removes all appearances from the manager
-func (a *Appearances) ClearAppearances() {
- a.mutex.Lock()
- defer a.mutex.Unlock()
-
- // Clear the map - Go's garbage collector will handle cleanup
- a.appearanceMap = make(map[int32]*Appearance)
-}
-
-// InsertAppearance adds an appearance to the manager
-func (a *Appearances) InsertAppearance(appearance *Appearance) error {
- if appearance == nil {
- return fmt.Errorf("appearance cannot be nil")
- }
-
- a.mutex.Lock()
- defer a.mutex.Unlock()
-
- a.appearanceMap[appearance.GetID()] = appearance
- return nil
-}
-
-// FindAppearanceByID retrieves an appearance by its ID
-func (a *Appearances) FindAppearanceByID(id int32) *Appearance {
- a.mutex.RLock()
- defer a.mutex.RUnlock()
-
- if appearance, exists := a.appearanceMap[id]; exists {
- return appearance
- }
-
- return nil
-}
-
-// HasAppearance checks if an appearance exists by ID
-func (a *Appearances) HasAppearance(id int32) bool {
- a.mutex.RLock()
- defer a.mutex.RUnlock()
-
- _, exists := a.appearanceMap[id]
- return exists
-}
-
-// GetAppearanceCount returns the total number of appearances
-func (a *Appearances) GetAppearanceCount() int {
- a.mutex.RLock()
- defer a.mutex.RUnlock()
-
- return len(a.appearanceMap)
-}
-
-// GetAllAppearances returns a copy of all appearances
-func (a *Appearances) GetAllAppearances() map[int32]*Appearance {
- a.mutex.RLock()
- defer a.mutex.RUnlock()
-
- // Return a copy to prevent external modification
- result := make(map[int32]*Appearance)
- for id, appearance := range a.appearanceMap {
- result[id] = appearance
- }
-
- return result
-}
-
-// GetAppearanceIDs returns all appearance IDs
-func (a *Appearances) GetAppearanceIDs() []int32 {
- a.mutex.RLock()
- defer a.mutex.RUnlock()
-
- ids := make([]int32, 0, len(a.appearanceMap))
- for id := range a.appearanceMap {
- ids = append(ids, id)
- }
-
- return ids
-}
-
-// FindAppearancesByName finds all appearances with names containing the given substring
-func (a *Appearances) FindAppearancesByName(nameSubstring string) []*Appearance {
- a.mutex.RLock()
- defer a.mutex.RUnlock()
-
- var results []*Appearance
-
- for _, appearance := range a.appearanceMap {
- if contains(appearance.GetName(), nameSubstring) {
- results = append(results, appearance)
- }
- }
-
- return results
-}
-
-// FindAppearancesByMinClient finds all appearances with a specific minimum client version
-func (a *Appearances) FindAppearancesByMinClient(minClient int16) []*Appearance {
- a.mutex.RLock()
- defer a.mutex.RUnlock()
-
- var results []*Appearance
-
- for _, appearance := range a.appearanceMap {
- if appearance.GetMinClientVersion() == minClient {
- results = append(results, appearance)
- }
- }
-
- return results
-}
-
-// GetCompatibleAppearances returns all appearances compatible with the given client version
-func (a *Appearances) GetCompatibleAppearances(clientVersion int16) []*Appearance {
- a.mutex.RLock()
- defer a.mutex.RUnlock()
-
- var results []*Appearance
-
- for _, appearance := range a.appearanceMap {
- if appearance.IsCompatibleWithClient(clientVersion) {
- results = append(results, appearance)
- }
- }
-
- return results
-}
-
-// RemoveAppearance removes an appearance by ID
-func (a *Appearances) RemoveAppearance(id int32) bool {
- a.mutex.Lock()
- defer a.mutex.Unlock()
-
- if _, exists := a.appearanceMap[id]; exists {
- delete(a.appearanceMap, id)
- return true
- }
-
- return false
-}
-
-// UpdateAppearance updates an existing appearance or inserts it if it doesn't exist
-func (a *Appearances) UpdateAppearance(appearance *Appearance) error {
- if appearance == nil {
- return fmt.Errorf("appearance cannot be nil")
- }
-
- a.mutex.Lock()
- defer a.mutex.Unlock()
-
- a.appearanceMap[appearance.GetID()] = appearance
- return nil
-}
-
-// GetAppearancesByIDRange returns all appearances within the given ID range (inclusive)
-func (a *Appearances) GetAppearancesByIDRange(minID, maxID int32) []*Appearance {
- a.mutex.RLock()
- defer a.mutex.RUnlock()
-
- var results []*Appearance
-
- for id, appearance := range a.appearanceMap {
- if id >= minID && id <= maxID {
- results = append(results, appearance)
- }
- }
-
- return results
-}
-
-// ValidateAppearances checks all appearances for consistency
-func (a *Appearances) ValidateAppearances() []string {
- a.mutex.RLock()
- defer a.mutex.RUnlock()
-
- var issues []string
-
- for id, appearance := range a.appearanceMap {
- 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 (a *Appearances) IsValid() bool {
- issues := a.ValidateAppearances()
- return len(issues) == 0
-}
-
-// GetStatistics returns statistics about the appearance collection
-func (a *Appearances) GetStatistics() map[string]any {
- a.mutex.RLock()
- defer a.mutex.RUnlock()
-
- stats := make(map[string]any)
- stats["total_appearances"] = len(a.appearanceMap)
-
- // Count by minimum client version
- versionCounts := make(map[int16]int)
- for _, appearance := range a.appearanceMap {
- versionCounts[appearance.GetMinClientVersion()]++
- }
- stats["appearances_by_min_client"] = versionCounts
-
- // Find ID range
- if len(a.appearanceMap) > 0 {
- var minID, maxID int32
- first := true
-
- for id := range a.appearanceMap {
- if first {
- minID = id
- maxID = id
- first = false
- } else {
- if id < minID {
- minID = id
- }
- if id > maxID {
- maxID = id
- }
- }
- }
-
- stats["min_id"] = minID
- stats["max_id"] = maxID
- stats["id_range"] = maxID - minID
- }
-
- return stats
-}
-
-// 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/appearances/appearances_test.go b/internal/appearances/appearances_test.go
deleted file mode 100644
index dbf6e5a..0000000
--- a/internal/appearances/appearances_test.go
+++ /dev/null
@@ -1,1550 +0,0 @@
-package appearances
-
-import (
- "fmt"
- "sync"
- "testing"
-)
-
-func TestNewAppearance(t *testing.T) {
- tests := []struct {
- name string
- id int32
- appearanceName string
- minClientVersion int16
- wantNil bool
- }{
- {
- name: "valid appearance",
- id: 100,
- appearanceName: "Test Appearance",
- minClientVersion: 1096,
- wantNil: false,
- },
- {
- name: "empty name returns nil",
- id: 200,
- appearanceName: "",
- minClientVersion: 1096,
- wantNil: true,
- },
- {
- name: "negative id allowed",
- id: -50,
- appearanceName: "Negative ID",
- minClientVersion: 0,
- wantNil: false,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- app := NewAppearance(tt.id, tt.appearanceName, tt.minClientVersion)
-
- if tt.wantNil {
- if app != nil {
- t.Errorf("expected nil appearance, got %v", app)
- }
- return
- }
-
- if app == nil {
- t.Fatal("expected non-nil appearance, got nil")
- }
-
- if app.GetID() != tt.id {
- t.Errorf("ID = %v, want %v", app.GetID(), tt.id)
- }
-
- if app.GetName() != tt.appearanceName {
- t.Errorf("Name = %v, want %v", app.GetName(), tt.appearanceName)
- }
-
- if app.GetMinClientVersion() != tt.minClientVersion {
- t.Errorf("MinClientVersion = %v, want %v", app.GetMinClientVersion(), tt.minClientVersion)
- }
- })
- }
-}
-
-func TestAppearanceGetters(t *testing.T) {
- app := NewAppearance(123, "Test Appearance", 1096)
-
- if id := app.GetID(); id != 123 {
- t.Errorf("GetID() = %v, want 123", id)
- }
-
- if name := app.GetName(); name != "Test Appearance" {
- t.Errorf("GetName() = %v, want Test Appearance", name)
- }
-
- if nameStr := app.GetNameString(); nameStr != "Test Appearance" {
- t.Errorf("GetNameString() = %v, want Test Appearance", nameStr)
- }
-
- if minVer := app.GetMinClientVersion(); minVer != 1096 {
- t.Errorf("GetMinClientVersion() = %v, want 1096", minVer)
- }
-}
-
-func TestAppearanceSetters(t *testing.T) {
- app := NewAppearance(100, "Original", 1000)
-
- app.SetName("Modified Name")
- if app.GetName() != "Modified Name" {
- t.Errorf("SetName failed: got %v, want Modified Name", app.GetName())
- }
-
- app.SetMinClientVersion(2000)
- if app.GetMinClientVersion() != 2000 {
- t.Errorf("SetMinClientVersion failed: got %v, want 2000", app.GetMinClientVersion())
- }
-}
-
-func TestIsCompatibleWithClient(t *testing.T) {
- app := NewAppearance(100, "Test", 1096)
-
- tests := []struct {
- clientVersion int16
- want bool
- }{
- {1095, false}, // Below minimum
- {1096, true}, // Exact minimum
- {1097, true}, // Above minimum
- {2000, true}, // Well above minimum
- {0, false}, // Zero version
- }
-
- for _, tt := range tests {
- t.Run("", func(t *testing.T) {
- if got := app.IsCompatibleWithClient(tt.clientVersion); got != tt.want {
- t.Errorf("IsCompatibleWithClient(%v) = %v, want %v", tt.clientVersion, got, tt.want)
- }
- })
- }
-}
-
-func TestAppearanceClone(t *testing.T) {
- original := NewAppearance(500, "Original Appearance", 1200)
- clone := original.Clone()
-
- if clone == nil {
- t.Fatal("Clone returned nil")
- }
-
- if clone == original {
- t.Error("Clone returned same pointer as original")
- }
-
- if clone.GetID() != original.GetID() {
- t.Errorf("Clone ID = %v, want %v", clone.GetID(), original.GetID())
- }
-
- if clone.GetName() != original.GetName() {
- t.Errorf("Clone Name = %v, want %v", clone.GetName(), original.GetName())
- }
-
- if clone.GetMinClientVersion() != original.GetMinClientVersion() {
- t.Errorf("Clone MinClientVersion = %v, want %v", clone.GetMinClientVersion(), original.GetMinClientVersion())
- }
-
- // Verify modification independence
- clone.SetName("Modified Clone")
- if original.GetName() == "Modified Clone" {
- t.Error("Modifying clone affected original")
- }
-}
-
-func TestNewAppearances(t *testing.T) {
- apps := NewAppearances()
-
- if apps == nil {
- t.Fatal("NewAppearances returned nil")
- }
-
- if count := apps.GetAppearanceCount(); count != 0 {
- t.Errorf("New appearances collection should be empty, got count %v", count)
- }
-}
-
-func TestAppearancesInsertAndFind(t *testing.T) {
- apps := NewAppearances()
-
- // Test nil insertion
- err := apps.InsertAppearance(nil)
- if err == nil {
- t.Error("InsertAppearance(nil) should return error")
- }
-
- // Insert valid appearances
- app1 := NewAppearance(100, "Appearance 1", 1000)
- app2 := NewAppearance(200, "Appearance 2", 1100)
-
- if err := apps.InsertAppearance(app1); err != nil {
- t.Errorf("InsertAppearance failed: %v", err)
- }
-
- if err := apps.InsertAppearance(app2); err != nil {
- t.Errorf("InsertAppearance failed: %v", err)
- }
-
- // Test finding by ID
- found := apps.FindAppearanceByID(100)
- if found == nil {
- t.Error("FindAppearanceByID(100) returned nil")
- } else if found.GetName() != "Appearance 1" {
- t.Errorf("FindAppearanceByID(100) returned wrong appearance: %v", found.GetName())
- }
-
- // Test finding non-existent ID
- notFound := apps.FindAppearanceByID(999)
- if notFound != nil {
- t.Errorf("FindAppearanceByID(999) should return nil, got %v", notFound)
- }
-}
-
-func TestAppearancesHasAppearance(t *testing.T) {
- apps := NewAppearances()
- app := NewAppearance(300, "Test", 1000)
- apps.InsertAppearance(app)
-
- if !apps.HasAppearance(300) {
- t.Error("HasAppearance(300) should return true")
- }
-
- if apps.HasAppearance(999) {
- t.Error("HasAppearance(999) should return false")
- }
-}
-
-func TestAppearancesGetAllAndCount(t *testing.T) {
- apps := NewAppearances()
-
- // Add multiple appearances
- for i := int32(1); i <= 5; i++ {
- app := NewAppearance(i*100, "Appearance", 1000)
- apps.InsertAppearance(app)
- }
-
- if count := apps.GetAppearanceCount(); count != 5 {
- t.Errorf("GetAppearanceCount() = %v, want 5", count)
- }
-
- all := apps.GetAllAppearances()
- if len(all) != 5 {
- t.Errorf("GetAllAppearances() returned %v items, want 5", len(all))
- }
-
- // Verify it's a copy by modifying returned map
- delete(all, 100)
- if apps.GetAppearanceCount() != 5 {
- t.Error("Modifying returned map affected internal state")
- }
-}
-
-func TestAppearancesGetIDs(t *testing.T) {
- apps := NewAppearances()
- expectedIDs := []int32{100, 200, 300}
-
- for _, id := range expectedIDs {
- app := NewAppearance(id, "Test", 1000)
- apps.InsertAppearance(app)
- }
-
- ids := apps.GetAppearanceIDs()
- if len(ids) != len(expectedIDs) {
- t.Errorf("GetAppearanceIDs() returned %v IDs, want %v", len(ids), len(expectedIDs))
- }
-
- // Check all expected IDs are present
- idMap := make(map[int32]bool)
- for _, id := range ids {
- idMap[id] = true
- }
-
- for _, expected := range expectedIDs {
- if !idMap[expected] {
- t.Errorf("Expected ID %v not found in returned IDs", expected)
- }
- }
-}
-
-func TestAppearancesFindByName(t *testing.T) {
- apps := NewAppearances()
-
- apps.InsertAppearance(NewAppearance(1, "Human Male", 1000))
- apps.InsertAppearance(NewAppearance(2, "Human Female", 1000))
- apps.InsertAppearance(NewAppearance(3, "Elf Male", 1000))
- apps.InsertAppearance(NewAppearance(4, "Dwarf Female", 1000))
-
- // Test partial matching
- humanApps := apps.FindAppearancesByName("Human")
- if len(humanApps) != 2 {
- t.Errorf("FindAppearancesByName('Human') returned %v results, want 2", len(humanApps))
- }
-
- // Test case sensitivity
- maleApps := apps.FindAppearancesByName("Male")
- if len(maleApps) != 2 {
- t.Errorf("FindAppearancesByName('Male') returned %v results, want 2", len(maleApps))
- }
-
- // Test empty substring
- allApps := apps.FindAppearancesByName("")
- if len(allApps) != 4 {
- t.Errorf("FindAppearancesByName('') returned %v results, want 4", len(allApps))
- }
-
- // Test no matches
- noMatches := apps.FindAppearancesByName("Orc")
- if len(noMatches) != 0 {
- t.Errorf("FindAppearancesByName('Orc') returned %v results, want 0", len(noMatches))
- }
-}
-
-func TestAppearancesFindByMinClient(t *testing.T) {
- apps := NewAppearances()
-
- apps.InsertAppearance(NewAppearance(1, "Old", 1000))
- apps.InsertAppearance(NewAppearance(2, "Medium1", 1096))
- apps.InsertAppearance(NewAppearance(3, "Medium2", 1096))
- apps.InsertAppearance(NewAppearance(4, "New", 1200))
-
- results := apps.FindAppearancesByMinClient(1096)
- if len(results) != 2 {
- t.Errorf("FindAppearancesByMinClient(1096) returned %v results, want 2", len(results))
- }
-
- results = apps.FindAppearancesByMinClient(999)
- if len(results) != 0 {
- t.Errorf("FindAppearancesByMinClient(999) returned %v results, want 0", len(results))
- }
-}
-
-func TestAppearancesGetCompatible(t *testing.T) {
- apps := NewAppearances()
-
- apps.InsertAppearance(NewAppearance(1, "Old", 1000))
- apps.InsertAppearance(NewAppearance(2, "Medium", 1096))
- apps.InsertAppearance(NewAppearance(3, "New", 1200))
- apps.InsertAppearance(NewAppearance(4, "Newer", 1300))
-
- // Client version 1100 should get Old and Medium
- compatible := apps.GetCompatibleAppearances(1100)
- if len(compatible) != 2 {
- t.Errorf("GetCompatibleAppearances(1100) returned %v results, want 2", len(compatible))
- }
-
- // Client version 1500 should get all
- compatible = apps.GetCompatibleAppearances(1500)
- if len(compatible) != 4 {
- t.Errorf("GetCompatibleAppearances(1500) returned %v results, want 4", len(compatible))
- }
-
- // Client version 500 should get none
- compatible = apps.GetCompatibleAppearances(500)
- if len(compatible) != 0 {
- t.Errorf("GetCompatibleAppearances(500) returned %v results, want 0", len(compatible))
- }
-}
-
-func TestAppearancesRemove(t *testing.T) {
- apps := NewAppearances()
- app := NewAppearance(100, "Test", 1000)
- apps.InsertAppearance(app)
-
- // Remove existing
- if !apps.RemoveAppearance(100) {
- t.Error("RemoveAppearance(100) should return true")
- }
-
- if apps.HasAppearance(100) {
- t.Error("Appearance 100 should have been removed")
- }
-
- // Remove non-existent
- if apps.RemoveAppearance(100) {
- t.Error("RemoveAppearance(100) should return false for non-existent ID")
- }
-}
-
-func TestAppearancesUpdate(t *testing.T) {
- apps := NewAppearances()
-
- // Test updating nil
- err := apps.UpdateAppearance(nil)
- if err == nil {
- t.Error("UpdateAppearance(nil) should return error")
- }
-
- // Insert and update
- original := NewAppearance(100, "Original", 1000)
- apps.InsertAppearance(original)
-
- updated := NewAppearance(100, "Updated", 1100)
- err = apps.UpdateAppearance(updated)
- if err != nil {
- t.Errorf("UpdateAppearance failed: %v", err)
- }
-
- found := apps.FindAppearanceByID(100)
- if found.GetName() != "Updated" {
- t.Errorf("Updated appearance name = %v, want Updated", found.GetName())
- }
-
- // Update non-existent (should insert)
- new := NewAppearance(200, "New", 1200)
- err = apps.UpdateAppearance(new)
- if err != nil {
- t.Errorf("UpdateAppearance failed: %v", err)
- }
-
- if !apps.HasAppearance(200) {
- t.Error("UpdateAppearance should insert non-existent appearance")
- }
-}
-
-func TestAppearancesGetByIDRange(t *testing.T) {
- apps := NewAppearances()
-
- // Insert appearances with various IDs
- for _, id := range []int32{5, 10, 15, 20, 25, 30} {
- apps.InsertAppearance(NewAppearance(id, "Test", 1000))
- }
-
- results := apps.GetAppearancesByIDRange(10, 20)
- if len(results) != 3 { // Should get 10, 15, 20
- t.Errorf("GetAppearancesByIDRange(10, 20) returned %v results, want 3", len(results))
- }
-
- // Verify correct IDs
- idMap := make(map[int32]bool)
- for _, app := range results {
- idMap[app.GetID()] = true
- }
-
- for _, expectedID := range []int32{10, 15, 20} {
- if !idMap[expectedID] {
- t.Errorf("Expected ID %v not found in range results", expectedID)
- }
- }
-
- // Test empty range
- results = apps.GetAppearancesByIDRange(100, 200)
- if len(results) != 0 {
- t.Errorf("GetAppearancesByIDRange(100, 200) returned %v results, want 0", len(results))
- }
-}
-
-func TestAppearancesValidate(t *testing.T) {
- apps := NewAppearances()
-
- // Valid appearances
- apps.InsertAppearance(NewAppearance(100, "Valid", 1000))
-
- issues := apps.ValidateAppearances()
- if len(issues) != 0 {
- t.Errorf("ValidateAppearances() returned issues for valid data: %v", issues)
- }
-
- if !apps.IsValid() {
- t.Error("IsValid() should return true for valid data")
- }
-
- // Force invalid state by directly modifying map
- apps.mutex.Lock()
- apps.appearanceMap[200] = nil
- apps.appearanceMap[300] = NewAppearance(301, "", 1000) // ID mismatch and empty name
- apps.appearanceMap[400] = NewAppearance(400, "Negative", -100)
- apps.mutex.Unlock()
-
- issues = apps.ValidateAppearances()
- if len(issues) < 3 {
- t.Errorf("ValidateAppearances() should return at least 3 issues, got %v", len(issues))
- }
-
- if apps.IsValid() {
- t.Error("IsValid() should return false for invalid data")
- }
-}
-
-func TestAppearancesStatistics(t *testing.T) {
- apps := NewAppearances()
-
- // Add appearances with different client versions
- apps.InsertAppearance(NewAppearance(10, "A", 1000))
- apps.InsertAppearance(NewAppearance(20, "B", 1000))
- apps.InsertAppearance(NewAppearance(30, "C", 1096))
- apps.InsertAppearance(NewAppearance(40, "D", 1096))
- apps.InsertAppearance(NewAppearance(50, "E", 1096))
-
- stats := apps.GetStatistics()
-
- if total, ok := stats["total_appearances"].(int); !ok || total != 5 {
- t.Errorf("total_appearances = %v, want 5", stats["total_appearances"])
- }
-
- if minID, ok := stats["min_id"].(int32); !ok || minID != 10 {
- t.Errorf("min_id = %v, want 10", stats["min_id"])
- }
-
- if maxID, ok := stats["max_id"].(int32); !ok || maxID != 50 {
- t.Errorf("max_id = %v, want 50", stats["max_id"])
- }
-
- if idRange, ok := stats["id_range"].(int32); !ok || idRange != 40 {
- t.Errorf("id_range = %v, want 40", stats["id_range"])
- }
-
- if versionCounts, ok := stats["appearances_by_min_client"].(map[int16]int); ok {
- if versionCounts[1000] != 2 {
- t.Errorf("appearances with min client 1000 = %v, want 2", versionCounts[1000])
- }
- if versionCounts[1096] != 3 {
- t.Errorf("appearances with min client 1096 = %v, want 3", versionCounts[1096])
- }
- } else {
- t.Error("appearances_by_min_client not found in statistics")
- }
-}
-
-func TestAppearancesClearAndReset(t *testing.T) {
- apps := NewAppearances()
-
- // Add some appearances
- for i := int32(1); i <= 3; i++ {
- apps.InsertAppearance(NewAppearance(i*100, "Test", 1000))
- }
-
- if apps.GetAppearanceCount() != 3 {
- t.Error("Setup failed: should have 3 appearances")
- }
-
- // Test ClearAppearances
- apps.ClearAppearances()
- if apps.GetAppearanceCount() != 0 {
- t.Errorf("ClearAppearances() failed: count = %v, want 0", apps.GetAppearanceCount())
- }
-
- // Add again and test Reset
- for i := int32(1); i <= 3; i++ {
- apps.InsertAppearance(NewAppearance(i*100, "Test", 1000))
- }
-
- apps.Reset()
- if apps.GetAppearanceCount() != 0 {
- t.Errorf("Reset() failed: count = %v, want 0", apps.GetAppearanceCount())
- }
-}
-
-func TestAppearancesConcurrency(t *testing.T) {
- apps := NewAppearances()
- var wg sync.WaitGroup
-
- // Concurrent insertions
- for i := 0; i < 100; i++ {
- wg.Add(1)
- go func(id int32) {
- defer wg.Done()
- app := NewAppearance(id, "Concurrent", 1000)
- apps.InsertAppearance(app)
- }(int32(i))
- }
-
- // Concurrent reads
- for i := 0; i < 50; i++ {
- wg.Add(1)
- go func() {
- defer wg.Done()
- _ = apps.GetAppearanceCount()
- _ = apps.GetAllAppearances()
- _ = apps.FindAppearanceByID(25)
- }()
- }
-
- // Concurrent searches
- for i := 0; i < 20; i++ {
- wg.Add(1)
- go func() {
- defer wg.Done()
- _ = apps.FindAppearancesByName("Concurrent")
- _ = apps.GetCompatibleAppearances(1100)
- }()
- }
-
- wg.Wait()
-
- // Verify all insertions succeeded
- if count := apps.GetAppearanceCount(); count != 100 {
- t.Errorf("After concurrent operations, count = %v, want 100", count)
- }
-}
-
-func TestContainsFunction(t *testing.T) {
- tests := []struct {
- str string
- substr string
- want bool
- }{
- {"hello world", "world", true},
- {"hello world", "World", false}, // Case sensitive
- {"hello", "hello world", false},
- {"hello", "", true},
- {"", "hello", false},
- {"", "", true},
- {"abcdef", "cde", true},
- {"abcdef", "xyz", false},
- }
-
- for _, tt := range tests {
- t.Run("", func(t *testing.T) {
- if got := contains(tt.str, tt.substr); got != tt.want {
- t.Errorf("contains(%q, %q) = %v, want %v", tt.str, tt.substr, got, tt.want)
- }
- })
- }
-}
-
-// Benchmarks
-func BenchmarkAppearanceInsert(b *testing.B) {
- apps := NewAppearances()
-
- b.ResetTimer()
- for i := 0; i < b.N; i++ {
- app := NewAppearance(int32(i), "Benchmark", 1000)
- apps.InsertAppearance(app)
- }
-}
-
-func BenchmarkAppearanceFindByID(b *testing.B) {
- apps := NewAppearances()
-
- // Pre-populate
- for i := 0; i < 10000; i++ {
- app := NewAppearance(int32(i), "Benchmark", 1000)
- apps.InsertAppearance(app)
- }
-
- b.ResetTimer()
- for i := 0; i < b.N; i++ {
- apps.FindAppearanceByID(int32(i % 10000))
- }
-}
-
-func BenchmarkAppearanceFindByName(b *testing.B) {
- apps := NewAppearances()
-
- // Pre-populate with varied names
- names := []string{"Human Male", "Human Female", "Elf Male", "Elf Female", "Dwarf Male"}
- for i := 0; i < 1000; i++ {
- app := NewAppearance(int32(i), names[i%len(names)], 1000)
- apps.InsertAppearance(app)
- }
-
- b.ResetTimer()
- for i := 0; i < b.N; i++ {
- apps.FindAppearancesByName("Male")
- }
-}
-
-// Mock implementations
-type MockDatabase struct {
- appearances []*Appearance
- saveError error
- loadError error
- deleteError error
-}
-
-func (m *MockDatabase) LoadAllAppearances() ([]*Appearance, error) {
- if m.loadError != nil {
- return nil, m.loadError
- }
- return m.appearances, nil
-}
-
-func (m *MockDatabase) SaveAppearance(appearance *Appearance) error {
- return m.saveError
-}
-
-func (m *MockDatabase) DeleteAppearance(id int32) error {
- return m.deleteError
-}
-
-func (m *MockDatabase) LoadAppearancesByClientVersion(minClientVersion int16) ([]*Appearance, error) {
- var results []*Appearance
- for _, app := range m.appearances {
- if app.GetMinClientVersion() == minClientVersion {
- results = append(results, app)
- }
- }
- return results, nil
-}
-
-type MockLogger struct {
- logs []string
-}
-
-func (m *MockLogger) LogInfo(message string, args ...any) {
- m.logs = append(m.logs, fmt.Sprintf("INFO: "+message, args...))
-}
-
-func (m *MockLogger) LogError(message string, args ...any) {
- m.logs = append(m.logs, fmt.Sprintf("ERROR: "+message, args...))
-}
-
-func (m *MockLogger) LogDebug(message string, args ...any) {
- m.logs = append(m.logs, fmt.Sprintf("DEBUG: "+message, args...))
-}
-
-func (m *MockLogger) LogWarning(message string, args ...any) {
- m.logs = append(m.logs, fmt.Sprintf("WARNING: "+message, args...))
-}
-
-type MockEntity struct {
- id int32
- name string
- databaseID int32
-}
-
-func (m *MockEntity) GetID() int32 { return m.id }
-func (m *MockEntity) GetName() string { return m.name }
-func (m *MockEntity) GetDatabaseID() int32 { return m.databaseID }
-
-type MockClient struct {
- version int16
- sendError error
-}
-
-func (m *MockClient) GetVersion() int16 { return m.version }
-func (m *MockClient) SendAppearanceUpdate(appearanceID int32) error { return m.sendError }
-
-// Manager tests
-func TestNewManager(t *testing.T) {
- db := &MockDatabase{}
- logger := &MockLogger{}
-
- manager := NewManager(db, logger)
-
- if manager == nil {
- t.Fatal("NewManager returned nil")
- }
-
- if manager.database != db {
- t.Error("Manager database not set correctly")
- }
-
- if manager.logger != logger {
- t.Error("Manager logger not set correctly")
- }
-
- if manager.appearances == nil {
- t.Error("Manager appearances not initialized")
- }
-}
-
-func TestManagerInitialize(t *testing.T) {
- tests := []struct {
- name string
- database *MockDatabase
- wantError bool
- wantCount int
- wantLogInfo bool
- }{
- {
- name: "successful initialization",
- database: &MockDatabase{
- appearances: []*Appearance{
- NewAppearance(1, "Test1", 1000),
- NewAppearance(2, "Test2", 1096),
- },
- },
- wantError: false,
- wantCount: 2,
- wantLogInfo: true,
- },
- {
- name: "nil database",
- database: nil,
- wantError: false,
- wantCount: 0,
- wantLogInfo: true,
- },
- {
- name: "database load error",
- database: &MockDatabase{
- loadError: fmt.Errorf("database error"),
- },
- wantError: true,
- wantCount: 0,
- wantLogInfo: true,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- logger := &MockLogger{}
- var manager *Manager
- if tt.database != nil {
- manager = NewManager(tt.database, logger)
- } else {
- manager = &Manager{
- appearances: NewAppearances(),
- database: nil,
- logger: logger,
- }
- }
-
- err := manager.Initialize()
-
- if (err != nil) != tt.wantError {
- t.Errorf("Initialize() error = %v, wantError %v", err, tt.wantError)
- }
-
- if count := manager.GetAppearanceCount(); count != tt.wantCount {
- t.Errorf("GetAppearanceCount() = %v, want %v", count, tt.wantCount)
- }
-
- if tt.wantLogInfo && len(logger.logs) == 0 {
- t.Error("Expected log messages, got none")
- }
- })
- }
-}
-
-func TestManagerFindAppearanceByID(t *testing.T) {
- db := &MockDatabase{
- appearances: []*Appearance{
- NewAppearance(100, "Test", 1000),
- },
- }
- logger := &MockLogger{}
- manager := NewManager(db, logger)
- manager.Initialize()
-
- // Test successful lookup
- appearance := manager.FindAppearanceByID(100)
- if appearance == nil {
- t.Error("FindAppearanceByID(100) returned nil")
- }
-
- // Test failed lookup
- appearance = manager.FindAppearanceByID(999)
- if appearance != nil {
- t.Error("FindAppearanceByID(999) should return nil")
- }
-
- // Check statistics
- stats := manager.GetStatistics()
- if totalLookups, ok := stats["total_lookups"].(int64); !ok || totalLookups != 2 {
- t.Errorf("total_lookups = %v, want 2", stats["total_lookups"])
- }
-
- if successfulLookups, ok := stats["successful_lookups"].(int64); !ok || successfulLookups != 1 {
- t.Errorf("successful_lookups = %v, want 1", stats["successful_lookups"])
- }
-
- if failedLookups, ok := stats["failed_lookups"].(int64); !ok || failedLookups != 1 {
- t.Errorf("failed_lookups = %v, want 1", stats["failed_lookups"])
- }
-}
-
-func TestManagerAddAppearance(t *testing.T) {
- tests := []struct {
- name string
- appearance *Appearance
- saveError error
- existingID int32
- wantError bool
- errorContains string
- }{
- {
- name: "successful add",
- appearance: NewAppearance(100, "Test", 1000),
- wantError: false,
- },
- {
- name: "nil appearance",
- appearance: nil,
- wantError: true,
- errorContains: "cannot be nil",
- },
- {
- name: "empty name",
- appearance: &Appearance{id: 100, name: "", minClient: 1000},
- wantError: true,
- errorContains: "name cannot be empty",
- },
- {
- name: "invalid ID",
- appearance: NewAppearance(0, "Test", 1000),
- wantError: true,
- errorContains: "must be positive",
- },
- {
- name: "duplicate ID",
- appearance: NewAppearance(100, "Test", 1000),
- existingID: 100,
- wantError: true,
- errorContains: "already exists",
- },
- {
- name: "database save error",
- appearance: NewAppearance(200, "Test", 1000),
- saveError: fmt.Errorf("save failed"),
- wantError: true,
- errorContains: "database",
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- db := &MockDatabase{saveError: tt.saveError}
- logger := &MockLogger{}
- manager := NewManager(db, logger)
-
- if tt.existingID > 0 {
- manager.appearances.InsertAppearance(NewAppearance(tt.existingID, "Existing", 1000))
- }
-
- err := manager.AddAppearance(tt.appearance)
-
- if (err != nil) != tt.wantError {
- t.Errorf("AddAppearance() error = %v, wantError %v", err, tt.wantError)
- }
-
- if err != nil && tt.errorContains != "" && !contains(err.Error(), tt.errorContains) {
- t.Errorf("Error message %q doesn't contain %q", err.Error(), tt.errorContains)
- }
-
- // Verify appearance was added/not added
- if !tt.wantError && tt.appearance != nil {
- if !manager.appearances.HasAppearance(tt.appearance.GetID()) {
- t.Error("Appearance was not added to collection")
- }
- }
- })
- }
-}
-
-func TestManagerUpdateAppearance(t *testing.T) {
- db := &MockDatabase{}
- logger := &MockLogger{}
- manager := NewManager(db, logger)
-
- // Add initial appearance
- original := NewAppearance(100, "Original", 1000)
- manager.AddAppearance(original)
-
- // Test successful update
- updated := NewAppearance(100, "Updated", 1100)
- err := manager.UpdateAppearance(updated)
- if err != nil {
- t.Errorf("UpdateAppearance failed: %v", err)
- }
-
- found := manager.FindAppearanceByID(100)
- if found.GetName() != "Updated" {
- t.Error("Appearance was not updated")
- }
-
- // Test update non-existent
- notExist := NewAppearance(999, "NotExist", 1000)
- err = manager.UpdateAppearance(notExist)
- if err == nil {
- t.Error("UpdateAppearance should fail for non-existent appearance")
- }
-
- // Test nil appearance
- err = manager.UpdateAppearance(nil)
- if err == nil {
- t.Error("UpdateAppearance should fail for nil appearance")
- }
-}
-
-func TestManagerRemoveAppearance(t *testing.T) {
- db := &MockDatabase{}
- logger := &MockLogger{}
- manager := NewManager(db, logger)
-
- // Add appearance
- app := NewAppearance(100, "Test", 1000)
- manager.AddAppearance(app)
-
- // Test successful removal
- err := manager.RemoveAppearance(100)
- if err != nil {
- t.Errorf("RemoveAppearance failed: %v", err)
- }
-
- if manager.appearances.HasAppearance(100) {
- t.Error("Appearance was not removed")
- }
-
- // Test removing non-existent
- err = manager.RemoveAppearance(999)
- if err == nil {
- t.Error("RemoveAppearance should fail for non-existent appearance")
- }
-
- // Test database delete error
- db.deleteError = fmt.Errorf("delete failed")
- manager.AddAppearance(NewAppearance(200, "Test2", 1000))
- err = manager.RemoveAppearance(200)
- if err == nil {
- t.Error("RemoveAppearance should fail when database delete fails")
- }
-}
-
-func TestManagerCommands(t *testing.T) {
- db := &MockDatabase{
- appearances: []*Appearance{
- NewAppearance(100, "Human Male", 1000),
- NewAppearance(200, "Human Female", 1096),
- },
- }
- logger := &MockLogger{}
- manager := NewManager(db, logger)
- manager.Initialize()
-
- // Test stats command
- result, err := manager.ProcessCommand("stats", nil)
- if err != nil {
- t.Errorf("ProcessCommand(stats) failed: %v", err)
- }
- if !contains(result, "Appearance System Statistics") {
- t.Error("Stats command output incorrect")
- }
-
- // Test validate command
- result, err = manager.ProcessCommand("validate", nil)
- if err != nil {
- t.Errorf("ProcessCommand(validate) failed: %v", err)
- }
- if !contains(result, "valid") {
- t.Error("Validate command output incorrect")
- }
-
- // Test search command
- result, err = manager.ProcessCommand("search", []string{"Human"})
- if err != nil {
- t.Errorf("ProcessCommand(search) failed: %v", err)
- }
- if !contains(result, "Found 2 appearances") {
- t.Error("Search command output incorrect")
- }
-
- // Test search without args
- _, err = manager.ProcessCommand("search", nil)
- if err == nil {
- t.Error("Search command should fail without arguments")
- }
-
- // Test info command
- result, err = manager.ProcessCommand("info", []string{"100"})
- if err != nil {
- t.Errorf("ProcessCommand(info) failed: %v", err)
- }
- if !contains(result, "Human Male") {
- t.Error("Info command output incorrect")
- }
-
- // Test info without args
- _, err = manager.ProcessCommand("info", nil)
- if err == nil {
- t.Error("Info command should fail without arguments")
- }
-
- // Test unknown command
- _, err = manager.ProcessCommand("unknown", nil)
- if err == nil {
- t.Error("Unknown command should return error")
- }
-}
-
-func TestManagerResetStatistics(t *testing.T) {
- manager := NewManager(nil, nil)
-
- // Perform some lookups
- manager.FindAppearanceByID(100)
- manager.FindAppearanceByID(200)
-
- stats := manager.GetStatistics()
- if totalLookups, ok := stats["total_lookups"].(int64); !ok || totalLookups != 2 {
- t.Error("Statistics not tracked correctly")
- }
-
- // Reset statistics
- manager.ResetStatistics()
-
- stats = manager.GetStatistics()
- if totalLookups, ok := stats["total_lookups"].(int64); !ok || totalLookups != 0 {
- t.Error("Statistics not reset correctly")
- }
-}
-
-func TestManagerShutdown(t *testing.T) {
- logger := &MockLogger{}
- manager := NewManager(nil, logger)
-
- manager.appearances.InsertAppearance(NewAppearance(100, "Test", 1000))
-
- manager.Shutdown()
-
- if manager.GetAppearanceCount() != 0 {
- t.Error("Shutdown did not clear appearances")
- }
-
- // Check for shutdown log
- found := false
- for _, log := range logger.logs {
- if contains(log, "Shutting down") {
- found = true
- break
- }
- }
- if !found {
- t.Error("Shutdown did not log message")
- }
-}
-
-// EntityAppearanceAdapter tests
-func TestEntityAppearanceAdapter(t *testing.T) {
- entity := &MockEntity{id: 1, name: "TestEntity", databaseID: 100}
- logger := &MockLogger{}
- manager := NewManager(nil, logger)
-
- // Add test appearance
- app := NewAppearance(500, "TestAppearance", 1000)
- manager.AddAppearance(app)
-
- adapter := NewEntityAppearanceAdapter(entity, manager, logger)
-
- // Test initial state
- if adapter.GetAppearanceID() != 0 {
- t.Error("Initial appearance ID should be 0")
- }
-
- if adapter.GetAppearance() != nil {
- t.Error("Initial appearance should be nil")
- }
-
- // Test setting appearance ID
- adapter.SetAppearanceID(500)
- if adapter.GetAppearanceID() != 500 {
- t.Error("SetAppearanceID failed")
- }
-
- // Test getting appearance
- appearance := adapter.GetAppearance()
- if appearance == nil || appearance.GetID() != 500 {
- t.Error("GetAppearance failed")
- }
-
- // Test appearance name
- if name := adapter.GetAppearanceName(); name != "TestAppearance" {
- t.Errorf("GetAppearanceName() = %v, want TestAppearance", name)
- }
-
- // Test validation
- if err := adapter.ValidateAppearance(); err != nil {
- t.Errorf("ValidateAppearance() failed: %v", err)
- }
-
- // Test invalid appearance
- adapter.SetAppearanceID(999)
- if err := adapter.ValidateAppearance(); err == nil {
- t.Error("ValidateAppearance() should fail for invalid ID")
- }
-}
-
-func TestEntityAppearanceAdapterClientCompatibility(t *testing.T) {
- entity := &MockEntity{id: 1, name: "TestEntity"}
- manager := NewManager(nil, nil)
-
- // Add appearance with version requirement
- app := NewAppearance(100, "Test", 1096)
- manager.AddAppearance(app)
-
- adapter := NewEntityAppearanceAdapter(entity, manager, nil)
- adapter.SetAppearanceID(100)
-
- // Test compatible client
- if !adapter.IsCompatibleWithClient(1100) {
- t.Error("Should be compatible with client version 1100")
- }
-
- // Test incompatible client
- if adapter.IsCompatibleWithClient(1000) {
- t.Error("Should not be compatible with client version 1000")
- }
-
- // Test no appearance (always compatible)
- adapter.SetAppearanceID(0)
- if !adapter.IsCompatibleWithClient(500) {
- t.Error("No appearance should be compatible with all clients")
- }
-}
-
-func TestEntityAppearanceAdapterSendToClient(t *testing.T) {
- entity := &MockEntity{id: 1, name: "TestEntity"}
- logger := &MockLogger{}
- manager := NewManager(nil, logger)
-
- app := NewAppearance(100, "Test", 1096)
- manager.AddAppearance(app)
-
- adapter := NewEntityAppearanceAdapter(entity, manager, logger)
- adapter.SetAppearanceID(100)
-
- // Test successful send
- client := &MockClient{version: 1100}
- err := adapter.SendAppearanceToClient(client)
- if err != nil {
- t.Errorf("SendAppearanceToClient failed: %v", err)
- }
-
- // Test incompatible client
- lowClient := &MockClient{version: 1000}
- err = adapter.SendAppearanceToClient(lowClient)
- if err == nil {
- t.Error("SendAppearanceToClient should fail for incompatible client")
- }
-
- // Test client send error
- errorClient := &MockClient{version: 1100, sendError: fmt.Errorf("send failed")}
- err = adapter.SendAppearanceToClient(errorClient)
- if err == nil {
- t.Error("SendAppearanceToClient should propagate client error")
- }
-
- // Test nil client
- err = adapter.SendAppearanceToClient(nil)
- if err == nil {
- t.Error("SendAppearanceToClient should fail for nil client")
- }
-}
-
-// Cache tests
-func TestSimpleAppearanceCache(t *testing.T) {
- cache := NewSimpleAppearanceCache()
-
- app1 := NewAppearance(100, "Test1", 1000)
- app2 := NewAppearance(200, "Test2", 1096)
-
- // Test Set and Get
- cache.Set(100, app1)
- cache.Set(200, app2)
-
- if got := cache.Get(100); got != app1 {
- t.Error("Cache Get(100) failed")
- }
-
- if got := cache.Get(999); got != nil {
- t.Error("Cache Get(999) should return nil")
- }
-
- // Test GetSize
- if size := cache.GetSize(); size != 2 {
- t.Errorf("GetSize() = %v, want 2", size)
- }
-
- // Test Remove
- cache.Remove(100)
- if cache.Get(100) != nil {
- t.Error("Remove(100) failed")
- }
-
- if size := cache.GetSize(); size != 1 {
- t.Errorf("GetSize() after remove = %v, want 1", size)
- }
-
- // Test Clear
- cache.Clear()
- if size := cache.GetSize(); size != 0 {
- t.Errorf("GetSize() after clear = %v, want 0", size)
- }
-}
-
-func TestCachedAppearanceManager(t *testing.T) {
- db := &MockDatabase{}
- logger := &MockLogger{}
- baseManager := NewManager(db, logger)
-
- // Add test appearances
- app1 := NewAppearance(100, "Test1", 1000)
- app2 := NewAppearance(200, "Test2", 1096)
- baseManager.AddAppearance(app1)
- baseManager.AddAppearance(app2)
-
- cache := NewSimpleAppearanceCache()
- cachedManager := NewCachedAppearanceManager(baseManager, cache)
-
- // Test FindAppearanceByID with caching
- found := cachedManager.FindAppearanceByID(100)
- if found == nil || found.GetID() != 100 {
- t.Error("FindAppearanceByID(100) failed")
- }
-
- // Verify it was cached
- if cache.GetSize() != 1 {
- t.Error("Appearance was not cached")
- }
-
- // Test cache hit
- found2 := cachedManager.FindAppearanceByID(100)
- if found2 != found {
- t.Error("Should have returned cached appearance")
- }
-
- // Test AddAppearance updates cache
- app3 := NewAppearance(300, "Test3", 1200)
- err := cachedManager.AddAppearance(app3)
- if err != nil {
- t.Errorf("AddAppearance failed: %v", err)
- }
-
- if cache.Get(300) == nil {
- t.Error("Added appearance was not cached")
- }
-
- // Test UpdateAppearance updates cache
- updated := NewAppearance(100, "Updated", 1100)
- err = cachedManager.UpdateAppearance(updated)
- if err != nil {
- t.Errorf("UpdateAppearance failed: %v", err)
- }
-
- cached := cache.Get(100)
- if cached == nil || cached.GetName() != "Updated" {
- t.Error("Cache was not updated")
- }
-
- // Test RemoveAppearance updates cache
- err = cachedManager.RemoveAppearance(200)
- if err != nil {
- t.Errorf("RemoveAppearance failed: %v", err)
- }
-
- if cache.Get(200) != nil {
- t.Error("Removed appearance still in cache")
- }
-
- // Test ClearCache
- cachedManager.ClearCache()
- if cache.GetSize() != 0 {
- t.Error("ClearCache failed")
- }
-}
-
-func TestCacheConcurrency(t *testing.T) {
- cache := NewSimpleAppearanceCache()
- var wg sync.WaitGroup
-
- // Concurrent operations
- for i := 0; i < 100; i++ {
- wg.Add(3)
-
- // Writer
- go func(id int32) {
- defer wg.Done()
- app := NewAppearance(id, "Test", 1000)
- cache.Set(id, app)
- }(int32(i))
-
- // Reader
- go func(id int32) {
- defer wg.Done()
- _ = cache.Get(id)
- }(int32(i))
-
- // Size checker
- go func() {
- defer wg.Done()
- _ = cache.GetSize()
- }()
- }
-
- wg.Wait()
-
- // Final size should be predictable
- if size := cache.GetSize(); size > 100 {
- t.Errorf("Cache size %v is too large", size)
- }
-}
-
-// Additional tests for uncovered code paths
-func TestEntityAppearanceAdapterErrorCases(t *testing.T) {
- entity := &MockEntity{id: 1, name: "TestEntity"}
- logger := &MockLogger{}
-
- // Test with nil manager
- adapter := NewEntityAppearanceAdapter(entity, nil, logger)
- adapter.SetAppearanceID(100)
-
- // GetAppearance should return nil and log error
- appearance := adapter.GetAppearance()
- if appearance != nil {
- t.Error("GetAppearance should return nil with nil manager")
- }
-
- // Test UpdateAppearance with nil manager
- err := adapter.UpdateAppearance(100)
- if err == nil {
- t.Error("UpdateAppearance should fail with nil manager")
- }
-
- // Test UpdateAppearance with non-existent appearance
- manager := NewManager(nil, logger)
- adapter = NewEntityAppearanceAdapter(entity, manager, logger)
- err = adapter.UpdateAppearance(999)
- if err == nil {
- t.Error("UpdateAppearance should fail for non-existent appearance")
- }
-
- // Test SendAppearanceToClient with no appearance set
- client := &MockClient{version: 1100}
- adapter.SetAppearanceID(0)
- err = adapter.SendAppearanceToClient(client)
- if err != nil {
- t.Errorf("SendAppearanceToClient should succeed with no appearance: %v", err)
- }
-}
-
-func TestManagerGetAppearances(t *testing.T) {
- manager := NewManager(nil, nil)
-
- appearances := manager.GetAppearances()
- if appearances == nil {
- t.Error("GetAppearances should not return nil")
- }
-
- if appearances != manager.appearances {
- t.Error("GetAppearances should return internal appearances collection")
- }
-}
-
-func TestManagerCompatibleAndSearch(t *testing.T) {
- manager := NewManager(nil, nil)
-
- // Add test appearances
- app1 := NewAppearance(100, "Human Male", 1000)
- app2 := NewAppearance(200, "Human Female", 1096)
- manager.AddAppearance(app1)
- manager.AddAppearance(app2)
-
- // Test GetCompatibleAppearances
- compatible := manager.GetCompatibleAppearances(1050)
- if len(compatible) != 1 {
- t.Errorf("GetCompatibleAppearances(1050) returned %v results, want 1", len(compatible))
- }
-
- // Test SearchAppearancesByName
- results := manager.SearchAppearancesByName("Human")
- if len(results) != 2 {
- t.Errorf("SearchAppearancesByName('Human') returned %v results, want 2", len(results))
- }
-}
-
-func TestManagerReloadFromDatabase(t *testing.T) {
- // Test with nil database
- manager := NewManager(nil, nil)
- err := manager.ReloadFromDatabase()
- if err == nil {
- t.Error("ReloadFromDatabase should fail with nil database")
- }
-
- // Test successful reload
- db := &MockDatabase{
- appearances: []*Appearance{
- NewAppearance(100, "Test", 1000),
- },
- }
- manager = NewManager(db, nil)
-
- // Add some appearances first
- manager.AddAppearance(NewAppearance(200, "Existing", 1000))
-
- err = manager.ReloadFromDatabase()
- if err != nil {
- t.Errorf("ReloadFromDatabase failed: %v", err)
- }
-
- // Should only have the database appearance now
- if count := manager.GetAppearanceCount(); count != 1 {
- t.Errorf("After reload, count = %v, want 1", count)
- }
-
- if !manager.appearances.HasAppearance(100) {
- t.Error("Reloaded appearance not found")
- }
-
- if manager.appearances.HasAppearance(200) {
- t.Error("Previous appearance should be cleared")
- }
-}
-
-func TestManagerCommandEdgeCases(t *testing.T) {
- manager := NewManager(nil, nil)
-
- // Test reload command without database
- result, err := manager.ProcessCommand("reload", nil)
- if err == nil {
- t.Error("Reload command should fail without database")
- }
-
- // Test info command with invalid ID
- _, err = manager.ProcessCommand("info", []string{"invalid"})
- if err == nil {
- t.Error("Info command should fail with invalid ID")
- }
-
- // Test info command with non-existent ID
- result, err = manager.ProcessCommand("info", []string{"999"})
- if err != nil {
- t.Errorf("Info command failed: %v", err)
- }
- if !contains(result, "not found") {
- t.Error("Info command should indicate appearance not found")
- }
-
- // Test search with no results
- result, err = manager.ProcessCommand("search", []string{"nonexistent"})
- if err != nil {
- t.Errorf("Search command failed: %v", err)
- }
- if !contains(result, "No appearances found") {
- t.Error("Search command should indicate no results")
- }
-}
-
-func TestManagerValidateAllAppearances(t *testing.T) {
- manager := NewManager(nil, nil)
-
- // Add valid appearance
- manager.AddAppearance(NewAppearance(100, "Valid", 1000))
-
- issues := manager.ValidateAllAppearances()
- if len(issues) != 0 {
- t.Errorf("ValidateAllAppearances returned issues for valid data: %v", issues)
- }
-}
-
-func TestEntityAppearanceAdapterGetAppearanceName(t *testing.T) {
- entity := &MockEntity{id: 1, name: "TestEntity"}
- manager := NewManager(nil, nil)
- adapter := NewEntityAppearanceAdapter(entity, manager, nil)
-
- // Test with no appearance
- if name := adapter.GetAppearanceName(); name != "" {
- t.Errorf("GetAppearanceName() with no appearance = %v, want empty string", name)
- }
-
- // Test with appearance
- app := NewAppearance(100, "TestName", 1000)
- manager.AddAppearance(app)
- adapter.SetAppearanceID(100)
-
- if name := adapter.GetAppearanceName(); name != "TestName" {
- t.Errorf("GetAppearanceName() = %v, want TestName", name)
- }
-}
diff --git a/internal/appearances/constants.go b/internal/appearances/constants.go
index 36d6418..6087475 100644
--- a/internal/appearances/constants.go
+++ b/internal/appearances/constants.go
@@ -11,3 +11,134 @@ const (
MinimumClientVersion = 0
DefaultClientVersion = 283
)
+
+// Appearance type constants from the original EQ2EMu implementation
+// These correspond to different appearance features like hair, skin, clothing, etc.
+const (
+ AppearanceSOGAHairFaceHighlightColor = 0 // APPEARANCE_SOGA_HFHC
+ AppearanceSOGAHairTypeHighlightColor = 1 // APPEARANCE_SOGA_HTHC
+ AppearanceSOGAHairFaceColor = 2 // APPEARANCE_SOGA_HFC
+ AppearanceSOGAHairTypeColor = 3 // APPEARANCE_SOGA_HTC
+ AppearanceSOGAHairHighlight = 4 // APPEARANCE_SOGA_HH
+ AppearanceSOGAHairColor1 = 5 // APPEARANCE_SOGA_HC1
+ AppearanceSOGAHairColor2 = 6 // APPEARANCE_SOGA_HC2
+ AppearanceSOGASkinColor = 7 // APPEARANCE_SOGA_SC
+ AppearanceSOGAEyeColor = 8 // APPEARANCE_SOGA_EC
+ AppearanceHairTypeHighlightColor = 9 // APPEARANCE_HTHC
+ AppearanceHairFaceHighlightColor = 10 // APPEARANCE_HFHC
+ AppearanceHairTypeColor = 11 // APPEARANCE_HTC
+ AppearanceHairFaceColor = 12 // APPEARANCE_HFC
+ AppearanceHairHighlight = 13 // APPEARANCE_HH
+ AppearanceHairColor1 = 14 // APPEARANCE_HC1
+ AppearanceHairColor2 = 15 // APPEARANCE_HC2
+ AppearanceWingColor1 = 16 // APPEARANCE_WC1
+ AppearanceWingColor2 = 17 // APPEARANCE_WC2
+ AppearanceSkinColor = 18 // APPEARANCE_SC
+ AppearanceEyeColor = 19 // APPEARANCE_EC
+ AppearanceShirt = 20 // APPEARANCE_SHIRT
+ AppearanceUnknownClothingColor = 21 // APPEARANCE_UCC
+ AppearancePants = 22 // APPEARANCE_PANTS
+ AppearanceUnknownLegColor = 23 // APPEARANCE_ULC
+ AppearanceUnknown9 = 24 // APPEARANCE_U9
+ AppearanceBodySize = 25 // APPEARANCE_BODY_SIZE
+ AppearanceSOGAWingColor1 = 26 // APPEARANCE_SOGA_WC1
+ AppearanceSOGAWingColor2 = 27 // APPEARANCE_SOGA_WC2
+ AppearanceSOGAShirt = 28 // APPEARANCE_SOGA_SHIRT
+ AppearanceSOGAUnknownClothingColor = 29 // APPEARANCE_SOGA_UCC
+ AppearanceSOGAPants = 30 // APPEARANCE_SOGA_PANTS
+ AppearanceSOGAUnknownLegColor = 31 // APPEARANCE_SOGA_ULC
+ AppearanceSOGAUnknown13 = 32 // APPEARANCE_SOGA_U13
+ AppearanceSOGAEyebrowType = 33 // APPEARANCE_SOGA_EBT
+ AppearanceSOGACheekType = 34 // APPEARANCE_SOGA_CHEEKT
+ AppearanceSOGANoseType = 35 // APPEARANCE_SOGA_NT
+ AppearanceSOGAChinType = 36 // APPEARANCE_SOGA_CHINT
+ AppearanceSOGALipType = 37 // APPEARANCE_SOGA_LT
+ AppearanceSOGAEarType = 38 // APPEARANCE_SOGA_EART
+ AppearanceSOGAEyeType = 39 // APPEARANCE_SOGA_EYET
+ AppearanceEyebrowType = 40 // APPEARANCE_EBT
+ AppearanceCheekType = 41 // APPEARANCE_CHEEKT
+ AppearanceNoseType = 42 // APPEARANCE_NT
+ AppearanceChinType = 43 // APPEARANCE_CHINT
+ AppearanceEarType = 44 // APPEARANCE_EART
+ AppearanceEyeType = 45 // APPEARANCE_EYET
+ AppearanceLipType = 46 // APPEARANCE_LT
+ AppearanceBodyAge = 47 // APPEARANCE_BODY_AGE
+ AppearanceModelColor = 48 // APPEARANCE_MC
+ AppearanceSOGAModelColor = 49 // APPEARANCE_SMC
+ AppearanceSOGABodySize = 50 // APPEARANCE_SBS
+ AppearanceSOGABodyAge = 51 // APPEARANCE_SBA
+)
+
+// AppearanceTypeNames maps appearance type constants to human-readable names
+var AppearanceTypeNames = map[int8]string{
+ AppearanceSOGAHairFaceHighlightColor: "soga_hair_face_highlight_color",
+ AppearanceSOGAHairTypeHighlightColor: "soga_hair_type_highlight_color",
+ AppearanceSOGAHairFaceColor: "soga_hair_face_color",
+ AppearanceSOGAHairTypeColor: "soga_hair_type_color",
+ AppearanceSOGAHairHighlight: "soga_hair_highlight",
+ AppearanceSOGAHairColor1: "soga_hair_color1",
+ AppearanceSOGAHairColor2: "soga_hair_color2",
+ AppearanceSOGASkinColor: "soga_skin_color",
+ AppearanceSOGAEyeColor: "soga_eye_color",
+ AppearanceHairTypeHighlightColor: "hair_type_highlight_color",
+ AppearanceHairFaceHighlightColor: "hair_face_highlight_color",
+ AppearanceHairTypeColor: "hair_type_color",
+ AppearanceHairFaceColor: "hair_face_color",
+ AppearanceHairHighlight: "hair_highlight",
+ AppearanceHairColor1: "hair_color1",
+ AppearanceHairColor2: "hair_color2",
+ AppearanceWingColor1: "wing_color1",
+ AppearanceWingColor2: "wing_color2",
+ AppearanceSkinColor: "skin_color",
+ AppearanceEyeColor: "eye_color",
+ AppearanceShirt: "shirt_color",
+ AppearanceUnknownClothingColor: "unknown_chest_color",
+ AppearancePants: "pants_color",
+ AppearanceUnknownLegColor: "unknown_leg_color",
+ AppearanceUnknown9: "unknown9",
+ AppearanceBodySize: "body_size",
+ AppearanceSOGAWingColor1: "soga_wing_color1",
+ AppearanceSOGAWingColor2: "soga_wing_color2",
+ AppearanceSOGAShirt: "soga_shirt_color",
+ AppearanceSOGAUnknownClothingColor: "soga_unknown_chest_color",
+ AppearanceSOGAPants: "soga_pants_color",
+ AppearanceSOGAUnknownLegColor: "soga_unknown_leg_color",
+ AppearanceSOGAUnknown13: "soga_unknown13",
+ AppearanceSOGAEyebrowType: "soga_eye_brow_type",
+ AppearanceSOGACheekType: "soga_cheek_type",
+ AppearanceSOGANoseType: "soga_nose_type",
+ AppearanceSOGAChinType: "soga_chin_type",
+ AppearanceSOGALipType: "soga_lip_type",
+ AppearanceSOGAEarType: "soga_ear_type",
+ AppearanceSOGAEyeType: "soga_eye_type",
+ AppearanceEyebrowType: "eye_brow_type",
+ AppearanceCheekType: "cheek_type",
+ AppearanceNoseType: "nose_type",
+ AppearanceChinType: "chin_type",
+ AppearanceEarType: "ear_type",
+ AppearanceEyeType: "eye_type",
+ AppearanceLipType: "lip_type",
+ AppearanceBodyAge: "body_age",
+ AppearanceModelColor: "model_color",
+ AppearanceSOGAModelColor: "soga_model_color",
+ AppearanceSOGABodySize: "soga_body_size",
+ AppearanceSOGABodyAge: "soga_body_age",
+}
+
+// GetAppearanceType returns the appearance type constant for a given type name
+func GetAppearanceType(typeName string) int8 {
+ for typeConst, name := range AppearanceTypeNames {
+ if name == typeName {
+ return typeConst
+ }
+ }
+ return -1 // Unknown type
+}
+
+// GetAppearanceTypeName returns the type name for a given appearance type constant
+func GetAppearanceTypeName(typeConst int8) string {
+ if name, exists := AppearanceTypeNames[typeConst]; exists {
+ return name
+ }
+ return "unknown"
+}
diff --git a/internal/appearances/doc.go b/internal/appearances/doc.go
new file mode 100644
index 0000000..e2ea6b4
--- /dev/null
+++ b/internal/appearances/doc.go
@@ -0,0 +1,45 @@
+// Package appearances provides appearance management for the EverQuest II server emulator.
+//
+// Appearances define the visual characteristics of entities in the game world, including
+// player character customization options, NPC appearances, and visual variations.
+// The system supports client version compatibility to ensure proper rendering across
+// different EQ2 client versions.
+//
+// Basic Usage:
+//
+// db, _ := database.NewSQLite("appearances.db")
+//
+// // Create new appearance
+// appearance := appearances.New(db)
+// appearance.ID = 1001
+// appearance.Name = "Human Male"
+// appearance.MinClient = 1096
+// appearance.Save()
+//
+// // Load existing appearance
+// loaded, _ := appearances.Load(db, 1001)
+// loaded.Delete()
+//
+// Master List:
+//
+// masterList := appearances.NewMasterList()
+// masterList.LoadAllAppearances(db)
+// masterList.AddAppearance(appearance)
+//
+// // Find appearances
+// found := masterList.GetAppearance(1001)
+// compatible := masterList.GetCompatibleAppearances(1096)
+// humans := masterList.FindAppearancesByName("Human")
+//
+// Appearance Types:
+//
+// // Get appearance type from name
+// hairType := appearances.GetAppearanceType("hair_color1")
+//
+// // Get name from type constant
+// typeName := appearances.GetAppearanceTypeName(appearances.AppearanceHairColor1)
+//
+// The package includes all appearance type constants from the original EQ2EMu
+// implementation, supporting hair colors, skin tones, facial features, clothing,
+// and body customization options for both standard and SOGA character models.
+package appearances
diff --git a/internal/appearances/interfaces.go b/internal/appearances/interfaces.go
deleted file mode 100644
index ad7700f..0000000
--- a/internal/appearances/interfaces.go
+++ /dev/null
@@ -1,308 +0,0 @@
-package appearances
-
-import (
- "fmt"
- "sync"
-)
-
-// Database interface for appearance persistence
-type Database interface {
- LoadAllAppearances() ([]*Appearance, error)
- SaveAppearance(appearance *Appearance) error
- DeleteAppearance(id int32) error
- LoadAppearancesByClientVersion(minClientVersion int16) ([]*Appearance, error)
-}
-
-// Logger interface for appearance logging
-type Logger interface {
- LogInfo(message string, args ...any)
- LogError(message string, args ...any)
- LogDebug(message string, args ...any)
- LogWarning(message string, args ...any)
-}
-
-// AppearanceProvider interface for entities that provide appearances
-type AppearanceProvider interface {
- GetAppearanceID() int32
- SetAppearanceID(id int32)
- GetAppearance() *Appearance
- IsCompatibleWithClient(clientVersion int16) bool
-}
-
-// AppearanceAware interface for entities that use appearances
-type AppearanceAware interface {
- GetAppearanceManager() *Manager
- FindAppearanceByID(id int32) *Appearance
- GetCompatibleAppearances(clientVersion int16) []*Appearance
-}
-
-// Client interface for appearance-related client operations
-type Client interface {
- GetVersion() int16
- SendAppearanceUpdate(appearanceID int32) error
-}
-
-// AppearanceCache interface for caching appearance data
-type AppearanceCache interface {
- Get(id int32) *Appearance
- Set(id int32, appearance *Appearance)
- Remove(id int32)
- Clear()
- GetSize() int
-}
-
-// EntityAppearanceAdapter provides appearance functionality for entities
-type EntityAppearanceAdapter struct {
- entity Entity
- appearanceID int32
- manager *Manager
- logger Logger
-}
-
-// Entity interface for things that can have appearances
-type Entity interface {
- GetID() int32
- GetName() string
- GetDatabaseID() int32
-}
-
-// NewEntityAppearanceAdapter creates a new entity appearance adapter
-func NewEntityAppearanceAdapter(entity Entity, manager *Manager, logger Logger) *EntityAppearanceAdapter {
- return &EntityAppearanceAdapter{
- entity: entity,
- appearanceID: 0,
- manager: manager,
- logger: logger,
- }
-}
-
-// GetAppearanceID returns the entity's appearance ID
-func (eaa *EntityAppearanceAdapter) GetAppearanceID() int32 {
- return eaa.appearanceID
-}
-
-// SetAppearanceID sets the entity's appearance ID
-func (eaa *EntityAppearanceAdapter) SetAppearanceID(id int32) {
- eaa.appearanceID = id
-
- if eaa.logger != nil {
- eaa.logger.LogDebug("Entity %d (%s): Set appearance ID to %d",
- eaa.entity.GetID(), eaa.entity.GetName(), id)
- }
-}
-
-// GetAppearance returns the entity's appearance object
-func (eaa *EntityAppearanceAdapter) GetAppearance() *Appearance {
- if eaa.appearanceID == 0 {
- return nil
- }
-
- if eaa.manager == nil {
- if eaa.logger != nil {
- eaa.logger.LogError("Entity %d (%s): No appearance manager available",
- eaa.entity.GetID(), eaa.entity.GetName())
- }
- return nil
- }
-
- return eaa.manager.FindAppearanceByID(eaa.appearanceID)
-}
-
-// IsCompatibleWithClient checks if the entity's appearance is compatible with client version
-func (eaa *EntityAppearanceAdapter) IsCompatibleWithClient(clientVersion int16) bool {
- appearance := eaa.GetAppearance()
- if appearance == nil {
- return true // No appearance means compatible with all clients
- }
-
- return appearance.IsCompatibleWithClient(clientVersion)
-}
-
-// GetAppearanceName returns the name of the entity's appearance
-func (eaa *EntityAppearanceAdapter) GetAppearanceName() string {
- appearance := eaa.GetAppearance()
- if appearance == nil {
- return ""
- }
-
- return appearance.GetName()
-}
-
-// ValidateAppearance validates that the entity's appearance exists and is valid
-func (eaa *EntityAppearanceAdapter) ValidateAppearance() error {
- if eaa.appearanceID == 0 {
- return nil // No appearance is valid
- }
-
- appearance := eaa.GetAppearance()
- if appearance == nil {
- return fmt.Errorf("appearance ID %d not found", eaa.appearanceID)
- }
-
- return nil
-}
-
-// UpdateAppearance updates the entity's appearance from the manager
-func (eaa *EntityAppearanceAdapter) UpdateAppearance(id int32) error {
- if eaa.manager == nil {
- return fmt.Errorf("no appearance manager available")
- }
-
- appearance := eaa.manager.FindAppearanceByID(id)
- if appearance == nil {
- return fmt.Errorf("appearance ID %d not found", id)
- }
-
- eaa.SetAppearanceID(id)
-
- if eaa.logger != nil {
- eaa.logger.LogInfo("Entity %d (%s): Updated appearance to %d (%s)",
- eaa.entity.GetID(), eaa.entity.GetName(), id, appearance.GetName())
- }
-
- return nil
-}
-
-// SendAppearanceToClient sends the appearance to a client
-func (eaa *EntityAppearanceAdapter) SendAppearanceToClient(client Client) error {
- if client == nil {
- return fmt.Errorf("client is nil")
- }
-
- if eaa.appearanceID == 0 {
- return nil // No appearance to send
- }
-
- // Check client compatibility
- if !eaa.IsCompatibleWithClient(client.GetVersion()) {
- if eaa.logger != nil {
- eaa.logger.LogWarning("Entity %d (%s): Appearance %d not compatible with client version %d",
- eaa.entity.GetID(), eaa.entity.GetName(), eaa.appearanceID, client.GetVersion())
- }
- return fmt.Errorf("appearance not compatible with client version %d", client.GetVersion())
- }
-
- return client.SendAppearanceUpdate(eaa.appearanceID)
-}
-
-// SimpleAppearanceCache is a basic in-memory appearance cache
-type SimpleAppearanceCache struct {
- cache map[int32]*Appearance
- mutex sync.RWMutex
-}
-
-// NewSimpleAppearanceCache creates a new simple appearance cache
-func NewSimpleAppearanceCache() *SimpleAppearanceCache {
- return &SimpleAppearanceCache{
- cache: make(map[int32]*Appearance),
- }
-}
-
-// Get retrieves an appearance from cache
-func (sac *SimpleAppearanceCache) Get(id int32) *Appearance {
- sac.mutex.RLock()
- defer sac.mutex.RUnlock()
-
- return sac.cache[id]
-}
-
-// Set stores an appearance in cache
-func (sac *SimpleAppearanceCache) Set(id int32, appearance *Appearance) {
- sac.mutex.Lock()
- defer sac.mutex.Unlock()
-
- sac.cache[id] = appearance
-}
-
-// Remove removes an appearance from cache
-func (sac *SimpleAppearanceCache) Remove(id int32) {
- sac.mutex.Lock()
- defer sac.mutex.Unlock()
-
- delete(sac.cache, id)
-}
-
-// Clear removes all appearances from cache
-func (sac *SimpleAppearanceCache) Clear() {
- sac.mutex.Lock()
- defer sac.mutex.Unlock()
-
- sac.cache = make(map[int32]*Appearance)
-}
-
-// GetSize returns the number of cached appearances
-func (sac *SimpleAppearanceCache) GetSize() int {
- sac.mutex.RLock()
- defer sac.mutex.RUnlock()
-
- return len(sac.cache)
-}
-
-// CachedAppearanceManager wraps a Manager with caching functionality
-type CachedAppearanceManager struct {
- *Manager
- cache AppearanceCache
-}
-
-// NewCachedAppearanceManager creates a new cached appearance manager
-func NewCachedAppearanceManager(manager *Manager, cache AppearanceCache) *CachedAppearanceManager {
- return &CachedAppearanceManager{
- Manager: manager,
- cache: cache,
- }
-}
-
-// FindAppearanceByID finds an appearance with caching
-func (cam *CachedAppearanceManager) FindAppearanceByID(id int32) *Appearance {
- // Check cache first
- if appearance := cam.cache.Get(id); appearance != nil {
- return appearance
- }
-
- // Load from manager
- appearance := cam.Manager.FindAppearanceByID(id)
- if appearance != nil {
- // Cache the result
- cam.cache.Set(id, appearance)
- }
-
- return appearance
-}
-
-// AddAppearance adds an appearance and updates cache
-func (cam *CachedAppearanceManager) AddAppearance(appearance *Appearance) error {
- err := cam.Manager.AddAppearance(appearance)
- if err == nil {
- // Update cache
- cam.cache.Set(appearance.GetID(), appearance)
- }
-
- return err
-}
-
-// UpdateAppearance updates an appearance and cache
-func (cam *CachedAppearanceManager) UpdateAppearance(appearance *Appearance) error {
- err := cam.Manager.UpdateAppearance(appearance)
- if err == nil {
- // Update cache
- cam.cache.Set(appearance.GetID(), appearance)
- }
-
- return err
-}
-
-// RemoveAppearance removes an appearance and updates cache
-func (cam *CachedAppearanceManager) RemoveAppearance(id int32) error {
- err := cam.Manager.RemoveAppearance(id)
- if err == nil {
- // Remove from cache
- cam.cache.Remove(id)
- }
-
- return err
-}
-
-// ClearCache clears the appearance cache
-func (cam *CachedAppearanceManager) ClearCache() {
- cam.cache.Clear()
-}
diff --git a/internal/appearances/manager.go b/internal/appearances/manager.go
deleted file mode 100644
index f692b1a..0000000
--- a/internal/appearances/manager.go
+++ /dev/null
@@ -1,392 +0,0 @@
-package appearances
-
-import (
- "fmt"
- "sync"
-)
-
-// Manager provides high-level management of the appearance system
-type Manager struct {
- appearances *Appearances
- database Database
- logger Logger
- mutex sync.RWMutex
-
- // Statistics
- totalLookups int64
- successfulLookups int64
- failedLookups int64
- cacheHits int64
- cacheMisses int64
-}
-
-// NewManager creates a new appearance manager
-func NewManager(database Database, logger Logger) *Manager {
- return &Manager{
- appearances: NewAppearances(),
- database: database,
- logger: logger,
- }
-}
-
-// Initialize loads appearances from database
-func (m *Manager) Initialize() error {
- if m.logger != nil {
- m.logger.LogInfo("Initializing appearance manager...")
- }
-
- if m.database == nil {
- if m.logger != nil {
- m.logger.LogWarning("No database provided, starting with empty appearance list")
- }
- return nil
- }
-
- appearances, err := m.database.LoadAllAppearances()
- if err != nil {
- return fmt.Errorf("failed to load appearances from database: %w", err)
- }
-
- for _, appearance := range appearances {
- if err := m.appearances.InsertAppearance(appearance); err != nil {
- if m.logger != nil {
- m.logger.LogError("Failed to insert appearance %d: %v", appearance.GetID(), err)
- }
- }
- }
-
- if m.logger != nil {
- m.logger.LogInfo("Loaded %d appearances from database", len(appearances))
- }
-
- return nil
-}
-
-// GetAppearances returns the appearances collection
-func (m *Manager) GetAppearances() *Appearances {
- return m.appearances
-}
-
-// FindAppearanceByID finds an appearance by ID with statistics tracking
-func (m *Manager) FindAppearanceByID(id int32) *Appearance {
- m.mutex.Lock()
- m.totalLookups++
- m.mutex.Unlock()
-
- appearance := m.appearances.FindAppearanceByID(id)
-
- m.mutex.Lock()
- if appearance != nil {
- m.successfulLookups++
- m.cacheHits++
- } else {
- m.failedLookups++
- m.cacheMisses++
- }
- m.mutex.Unlock()
-
- if m.logger != nil && appearance == nil {
- m.logger.LogDebug("Appearance lookup failed for ID: %d", id)
- }
-
- return appearance
-}
-
-// AddAppearance adds a new appearance
-func (m *Manager) AddAppearance(appearance *Appearance) error {
- if appearance == nil {
- return fmt.Errorf("appearance cannot be nil")
- }
-
- // Validate the appearance
- if len(appearance.GetName()) == 0 {
- return fmt.Errorf("appearance name cannot be empty")
- }
-
- if appearance.GetID() <= 0 {
- return fmt.Errorf("appearance ID must be positive")
- }
-
- // Check if appearance already exists
- if m.appearances.HasAppearance(appearance.GetID()) {
- return fmt.Errorf("appearance with ID %d already exists", appearance.GetID())
- }
-
- // Add to collection
- if err := m.appearances.InsertAppearance(appearance); err != nil {
- return fmt.Errorf("failed to insert appearance: %w", err)
- }
-
- // Save to database if available
- if m.database != nil {
- if err := m.database.SaveAppearance(appearance); err != nil {
- // Remove from collection if database save failed
- m.appearances.RemoveAppearance(appearance.GetID())
- return fmt.Errorf("failed to save appearance to database: %w", err)
- }
- }
-
- if m.logger != nil {
- m.logger.LogInfo("Added appearance %d: %s (min client: %d)",
- appearance.GetID(), appearance.GetName(), appearance.GetMinClientVersion())
- }
-
- return nil
-}
-
-// UpdateAppearance updates an existing appearance
-func (m *Manager) UpdateAppearance(appearance *Appearance) error {
- if appearance == nil {
- return fmt.Errorf("appearance cannot be nil")
- }
-
- // Check if appearance exists
- if !m.appearances.HasAppearance(appearance.GetID()) {
- return fmt.Errorf("appearance with ID %d does not exist", appearance.GetID())
- }
-
- // Update in collection
- if err := m.appearances.UpdateAppearance(appearance); err != nil {
- return fmt.Errorf("failed to update appearance: %w", err)
- }
-
- // Save to database if available
- if m.database != nil {
- if err := m.database.SaveAppearance(appearance); err != nil {
- return fmt.Errorf("failed to save appearance to database: %w", err)
- }
- }
-
- if m.logger != nil {
- m.logger.LogInfo("Updated appearance %d: %s", appearance.GetID(), appearance.GetName())
- }
-
- return nil
-}
-
-// RemoveAppearance removes an appearance
-func (m *Manager) RemoveAppearance(id int32) error {
- // Check if appearance exists
- if !m.appearances.HasAppearance(id) {
- return fmt.Errorf("appearance with ID %d does not exist", id)
- }
-
- // Remove from database first if available
- if m.database != nil {
- if err := m.database.DeleteAppearance(id); err != nil {
- return fmt.Errorf("failed to delete appearance from database: %w", err)
- }
- }
-
- // Remove from collection
- if !m.appearances.RemoveAppearance(id) {
- return fmt.Errorf("failed to remove appearance from collection")
- }
-
- if m.logger != nil {
- m.logger.LogInfo("Removed appearance %d", id)
- }
-
- return nil
-}
-
-// GetCompatibleAppearances returns appearances compatible with client version
-func (m *Manager) GetCompatibleAppearances(clientVersion int16) []*Appearance {
- return m.appearances.GetCompatibleAppearances(clientVersion)
-}
-
-// SearchAppearancesByName searches for appearances by name substring
-func (m *Manager) SearchAppearancesByName(nameSubstring string) []*Appearance {
- return m.appearances.FindAppearancesByName(nameSubstring)
-}
-
-// GetStatistics returns appearance system statistics
-func (m *Manager) GetStatistics() map[string]any {
- m.mutex.RLock()
- defer m.mutex.RUnlock()
-
- // Get basic appearance statistics
- stats := m.appearances.GetStatistics()
-
- // Add manager statistics
- stats["total_lookups"] = m.totalLookups
- stats["successful_lookups"] = m.successfulLookups
- stats["failed_lookups"] = m.failedLookups
- stats["cache_hits"] = m.cacheHits
- stats["cache_misses"] = m.cacheMisses
-
- if m.totalLookups > 0 {
- stats["success_rate"] = float64(m.successfulLookups) / float64(m.totalLookups) * 100
- stats["cache_hit_rate"] = float64(m.cacheHits) / float64(m.totalLookups) * 100
- }
-
- return stats
-}
-
-// ResetStatistics resets all statistics
-func (m *Manager) ResetStatistics() {
- m.mutex.Lock()
- defer m.mutex.Unlock()
-
- m.totalLookups = 0
- m.successfulLookups = 0
- m.failedLookups = 0
- m.cacheHits = 0
- m.cacheMisses = 0
-}
-
-// ValidateAllAppearances validates all appearances in the system
-func (m *Manager) ValidateAllAppearances() []string {
- return m.appearances.ValidateAppearances()
-}
-
-// ReloadFromDatabase reloads all appearances from database
-func (m *Manager) ReloadFromDatabase() error {
- if m.database == nil {
- return fmt.Errorf("no database available")
- }
-
- // Clear current appearances
- m.appearances.ClearAppearances()
-
- // Reload from database
- return m.Initialize()
-}
-
-// GetAppearanceCount returns the total number of appearances
-func (m *Manager) GetAppearanceCount() int {
- return m.appearances.GetAppearanceCount()
-}
-
-// ProcessCommand handles appearance-related commands
-func (m *Manager) ProcessCommand(command string, args []string) (string, error) {
- switch command {
- case "stats":
- return m.handleStatsCommand(args)
- case "validate":
- return m.handleValidateCommand(args)
- case "search":
- return m.handleSearchCommand(args)
- case "info":
- return m.handleInfoCommand(args)
- case "reload":
- return m.handleReloadCommand(args)
- default:
- return "", fmt.Errorf("unknown appearance command: %s", command)
- }
-}
-
-// handleStatsCommand shows appearance system statistics
-func (m *Manager) handleStatsCommand(args []string) (string, error) {
- stats := m.GetStatistics()
-
- result := "Appearance System Statistics:\n"
- result += fmt.Sprintf("Total Appearances: %d\n", stats["total_appearances"])
- result += fmt.Sprintf("Total Lookups: %d\n", stats["total_lookups"])
- result += fmt.Sprintf("Successful Lookups: %d\n", stats["successful_lookups"])
- result += fmt.Sprintf("Failed Lookups: %d\n", stats["failed_lookups"])
-
- if successRate, exists := stats["success_rate"]; exists {
- result += fmt.Sprintf("Success Rate: %.1f%%\n", successRate)
- }
-
- if cacheHitRate, exists := stats["cache_hit_rate"]; exists {
- result += fmt.Sprintf("Cache Hit Rate: %.1f%%\n", cacheHitRate)
- }
-
- if minID, exists := stats["min_id"]; exists {
- result += fmt.Sprintf("ID Range: %d - %d\n", minID, stats["max_id"])
- }
-
- return result, nil
-}
-
-// handleValidateCommand validates all appearances
-func (m *Manager) handleValidateCommand(_ []string) (string, error) {
- issues := m.ValidateAllAppearances()
-
- if len(issues) == 0 {
- return "All appearances are valid.", nil
- }
-
- result := fmt.Sprintf("Found %d issues with appearances:\n", len(issues))
- for i, issue := range issues {
- if i >= 10 { // Limit output
- result += "... (and more)\n"
- break
- }
- result += fmt.Sprintf("%d. %s\n", i+1, issue)
- }
-
- return result, nil
-}
-
-// handleSearchCommand searches for appearances by name
-func (m *Manager) handleSearchCommand(args []string) (string, error) {
- if len(args) == 0 {
- return "", fmt.Errorf("search term required")
- }
-
- searchTerm := args[0]
- results := m.SearchAppearancesByName(searchTerm)
-
- if len(results) == 0 {
- return fmt.Sprintf("No appearances found matching '%s'.", searchTerm), nil
- }
-
- result := fmt.Sprintf("Found %d appearances matching '%s':\n", len(results), searchTerm)
- for i, appearance := range results {
- if i >= 20 { // Limit output
- result += "... (and more)\n"
- break
- }
- result += fmt.Sprintf(" %d: %s (min client: %d)\n",
- appearance.GetID(), appearance.GetName(), appearance.GetMinClientVersion())
- }
-
- return result, nil
-}
-
-// handleInfoCommand shows information about a specific appearance
-func (m *Manager) handleInfoCommand(args []string) (string, error) {
- if len(args) == 0 {
- return "", fmt.Errorf("appearance ID required")
- }
-
- var appearanceID int32
- if _, err := fmt.Sscanf(args[0], "%d", &appearanceID); err != nil {
- return "", fmt.Errorf("invalid appearance ID: %s", args[0])
- }
-
- appearance := m.FindAppearanceByID(appearanceID)
- if appearance == nil {
- return fmt.Sprintf("Appearance %d not found.", appearanceID), nil
- }
-
- result := "Appearance Information:\n"
- result += fmt.Sprintf("ID: %d\n", appearance.GetID())
- result += fmt.Sprintf("Name: %s\n", appearance.GetName())
- result += fmt.Sprintf("Min Client Version: %d\n", appearance.GetMinClientVersion())
-
- return result, nil
-}
-
-// handleReloadCommand reloads appearances from database
-func (m *Manager) handleReloadCommand(_ []string) (string, error) {
- if err := m.ReloadFromDatabase(); err != nil {
- return "", fmt.Errorf("failed to reload appearances: %w", err)
- }
-
- count := m.GetAppearanceCount()
- return fmt.Sprintf("Successfully reloaded %d appearances from database.", count), nil
-}
-
-// Shutdown gracefully shuts down the manager
-func (m *Manager) Shutdown() {
- if m.logger != nil {
- m.logger.LogInfo("Shutting down appearance manager...")
- }
-
- // Clear appearances
- m.appearances.ClearAppearances()
-}
diff --git a/internal/appearances/master.go b/internal/appearances/master.go
new file mode 100644
index 0000000..187b3ba
--- /dev/null
+++ b/internal/appearances/master.go
@@ -0,0 +1,235 @@
+package appearances
+
+import (
+ "fmt"
+
+ "eq2emu/internal/common"
+ "eq2emu/internal/database"
+)
+
+// MasterList manages a collection of appearances using the generic MasterList base
+type MasterList struct {
+ *common.MasterList[int32, *Appearance]
+}
+
+// NewMasterList creates a new appearance master list
+func NewMasterList() *MasterList {
+ return &MasterList{
+ MasterList: common.NewMasterList[int32, *Appearance](),
+ }
+}
+
+// AddAppearance adds an appearance to the master list
+func (ml *MasterList) AddAppearance(appearance *Appearance) bool {
+ return ml.Add(appearance)
+}
+
+// GetAppearance retrieves an appearance by ID
+func (ml *MasterList) GetAppearance(id int32) *Appearance {
+ return ml.Get(id)
+}
+
+// GetAppearanceSafe retrieves an appearance by ID with existence check
+func (ml *MasterList) GetAppearanceSafe(id int32) (*Appearance, bool) {
+ return ml.GetSafe(id)
+}
+
+// HasAppearance checks if an appearance exists by ID
+func (ml *MasterList) HasAppearance(id int32) bool {
+ return ml.Exists(id)
+}
+
+// RemoveAppearance removes an appearance by ID
+func (ml *MasterList) RemoveAppearance(id int32) bool {
+ return ml.Remove(id)
+}
+
+// GetAllAppearances returns all appearances as a map
+func (ml *MasterList) GetAllAppearances() map[int32]*Appearance {
+ return ml.GetAll()
+}
+
+// GetAllAppearancesList returns all appearances as a slice
+func (ml *MasterList) GetAllAppearancesList() []*Appearance {
+ return ml.GetAllSlice()
+}
+
+// GetAppearanceCount returns the number of appearances
+func (ml *MasterList) GetAppearanceCount() int {
+ return ml.Size()
+}
+
+// ClearAppearances removes all appearances from the list
+func (ml *MasterList) ClearAppearances() {
+ ml.Clear()
+}
+
+// FindAppearancesByName finds appearances containing the given name substring
+func (ml *MasterList) FindAppearancesByName(nameSubstring string) []*Appearance {
+ return ml.Filter(func(appearance *Appearance) bool {
+ return contains(appearance.GetName(), nameSubstring)
+ })
+}
+
+// FindAppearancesByMinClient finds appearances with specific minimum client version
+func (ml *MasterList) FindAppearancesByMinClient(minClient int16) []*Appearance {
+ return ml.Filter(func(appearance *Appearance) bool {
+ return appearance.GetMinClientVersion() == minClient
+ })
+}
+
+// GetCompatibleAppearances returns appearances compatible with the given client version
+func (ml *MasterList) GetCompatibleAppearances(clientVersion int16) []*Appearance {
+ return ml.Filter(func(appearance *Appearance) bool {
+ return appearance.IsCompatibleWithClient(clientVersion)
+ })
+}
+
+// GetAppearancesByIDRange returns appearances within the given ID range (inclusive)
+func (ml *MasterList) GetAppearancesByIDRange(minID, maxID int32) []*Appearance {
+ return ml.Filter(func(appearance *Appearance) bool {
+ id := appearance.GetID()
+ return id >= minID && id <= maxID
+ })
+}
+
+// ValidateAppearances checks all appearances for consistency
+func (ml *MasterList) ValidateAppearances() []string {
+ var issues []string
+
+ ml.ForEach(func(id int32, appearance *Appearance) {
+ if appearance == nil {
+ issues = append(issues, fmt.Sprintf("Appearance ID %d is nil", id))
+ return
+ }
+
+ 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 {
+ stats := make(map[string]any)
+ stats["total_appearances"] = ml.Size()
+
+ if ml.IsEmpty() {
+ return stats
+ }
+
+ // Count by minimum client version
+ versionCounts := make(map[int16]int)
+ var minID, maxID int32
+ first := true
+
+ ml.ForEach(func(id int32, appearance *Appearance) {
+ 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/appearances/types.go b/internal/appearances/types.go
deleted file mode 100644
index e001ae9..0000000
--- a/internal/appearances/types.go
+++ /dev/null
@@ -1,65 +0,0 @@
-package appearances
-
-// Appearance represents a single appearance with ID, name, and client version requirements
-type Appearance struct {
- id int32 // Appearance ID
- name string // Appearance name
- minClient int16 // Minimum client version required
-}
-
-// NewAppearance creates a new appearance with the given parameters
-func NewAppearance(id int32, name string, minClientVersion int16) *Appearance {
- if len(name) == 0 {
- return nil
- }
-
- return &Appearance{
- id: id,
- name: name,
- minClient: minClientVersion,
- }
-}
-
-// GetID returns the appearance ID
-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
-}
-
-// Clone creates a copy of the appearance
-func (a *Appearance) Clone() *Appearance {
- return &Appearance{
- id: a.id,
- name: a.name,
- minClient: a.minClient,
- }
-}
diff --git a/test_empty_packets.go b/test_empty_packets.go
deleted file mode 100644
index cb9dc34..0000000
--- a/test_empty_packets.go
+++ /dev/null
@@ -1,33 +0,0 @@
-package main
-
-import (
- "fmt"
- "log"
- "eq2emu/internal/packets"
-)
-
-func main() {
- // Enable all logging to see warnings
- log.SetFlags(log.LstdFlags | log.Lshortfile)
-
- // This will trigger the init() function in the packets loader
- count := packets.GetPacketCount()
- fmt.Printf("Loaded %d packet definitions\n", count)
-
- // Get all packet names to look for any patterns
- names := packets.GetPacketNames()
- if len(names) == 0 {
- fmt.Println("No packets loaded!")
- return
- }
-
- // Look for packets that might be empty
- fmt.Println("Checking for potentially problematic packets...")
- for _, name := range names {
- if packet, exists := packets.GetPacket(name); exists {
- if len(packet.Fields) == 0 {
- fmt.Printf("Empty packet found: %s\n", name)
- }
- }
- }
-}
\ No newline at end of file
diff --git a/test_specific_empty.go b/test_specific_empty.go
deleted file mode 100644
index c0022cd..0000000
--- a/test_specific_empty.go
+++ /dev/null
@@ -1,39 +0,0 @@
-package main
-
-import (
- "fmt"
- "eq2emu/internal/packets/parser"
-)
-
-func main() {
- // Test parsing an empty packet that might fail
- emptyPacketXML := `
-
-
-`
-
- fmt.Println("Testing empty packet parsing...")
- packets, err := parser.Parse(emptyPacketXML)
- if err != nil {
- fmt.Printf("ERROR parsing empty packet: %v\n", err)
- } else {
- fmt.Printf("SUCCESS: Parsed %d packets\n", len(packets))
- if packet, exists := packets["EmptyTest"]; exists {
- fmt.Printf("EmptyTest packet has %d fields\n", len(packet.Fields))
- }
- }
-
- // Test a completely self-closing packet
- selfClosingXML := ``
-
- fmt.Println("\nTesting self-closing packet parsing...")
- packets2, err2 := parser.Parse(selfClosingXML)
- if err2 != nil {
- fmt.Printf("ERROR parsing self-closing packet: %v\n", err2)
- } else {
- fmt.Printf("SUCCESS: Parsed %d packets\n", len(packets2))
- if packet, exists := packets2["SelfClosingTest"]; exists {
- fmt.Printf("SelfClosingTest packet has %d fields\n", len(packet.Fields))
- }
- }
-}
\ No newline at end of file