simplify appearances
This commit is contained in:
parent
4080a57d3e
commit
5252b4f677
@ -1,189 +0,0 @@
|
|||||||
package appearances
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"eq2emu/internal/database"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Appearance represents a single appearance with ID, name, and client version requirements
|
|
||||||
type Appearance struct {
|
|
||||||
ID int32 `json:"id"` // Appearance ID
|
|
||||||
Name string `json:"name"` // Appearance name
|
|
||||||
MinClient int16 `json:"min_client"` // Minimum client version required
|
|
||||||
|
|
||||||
db *database.Database `json:"-"` // Database connection
|
|
||||||
isNew bool `json:"-"` // Whether this is a new appearance
|
|
||||||
}
|
|
||||||
|
|
||||||
// New creates a new appearance with the given database
|
|
||||||
func New(db *database.Database) *Appearance {
|
|
||||||
return &Appearance{
|
|
||||||
db: db,
|
|
||||||
isNew: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewWithData creates a new appearance with data
|
|
||||||
func NewWithData(id int32, name string, minClientVersion int16, db *database.Database) *Appearance {
|
|
||||||
return &Appearance{
|
|
||||||
ID: id,
|
|
||||||
Name: name,
|
|
||||||
MinClient: minClientVersion,
|
|
||||||
db: db,
|
|
||||||
isNew: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load loads an appearance by ID from the database
|
|
||||||
func Load(db *database.Database, id int32) (*Appearance, error) {
|
|
||||||
appearance := &Appearance{
|
|
||||||
db: db,
|
|
||||||
isNew: false,
|
|
||||||
}
|
|
||||||
|
|
||||||
query := `SELECT appearance_id, name, min_client_version FROM appearances WHERE appearance_id = ?`
|
|
||||||
row := db.QueryRow(query, id)
|
|
||||||
|
|
||||||
err := row.Scan(&appearance.ID, &appearance.Name, &appearance.MinClient)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to load appearance %d: %w", id, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return appearance, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetID returns the appearance ID (implements Identifiable interface)
|
|
||||||
func (a *Appearance) GetID() int32 {
|
|
||||||
return a.ID
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetName returns the appearance name
|
|
||||||
func (a *Appearance) GetName() string {
|
|
||||||
return a.Name
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetMinClientVersion returns the minimum client version required
|
|
||||||
func (a *Appearance) GetMinClientVersion() int16 {
|
|
||||||
return a.MinClient
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetNameString returns the name as a string (alias for GetName for C++ compatibility)
|
|
||||||
func (a *Appearance) GetNameString() string {
|
|
||||||
return a.Name
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetName sets the appearance name
|
|
||||||
func (a *Appearance) SetName(name string) {
|
|
||||||
a.Name = name
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetMinClientVersion sets the minimum client version
|
|
||||||
func (a *Appearance) SetMinClientVersion(version int16) {
|
|
||||||
a.MinClient = version
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsCompatibleWithClient returns true if the appearance is compatible with the given client version
|
|
||||||
func (a *Appearance) IsCompatibleWithClient(clientVersion int16) bool {
|
|
||||||
return clientVersion >= a.MinClient
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsNew returns true if this is a new appearance not yet saved to database
|
|
||||||
func (a *Appearance) IsNew() bool {
|
|
||||||
return a.isNew
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save saves the appearance to the database
|
|
||||||
func (a *Appearance) Save() error {
|
|
||||||
if a.db == nil {
|
|
||||||
return fmt.Errorf("no database connection available")
|
|
||||||
}
|
|
||||||
|
|
||||||
if a.isNew {
|
|
||||||
return a.insert()
|
|
||||||
}
|
|
||||||
return a.update()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete removes the appearance from the database
|
|
||||||
func (a *Appearance) Delete() error {
|
|
||||||
if a.db == nil {
|
|
||||||
return fmt.Errorf("no database connection available")
|
|
||||||
}
|
|
||||||
|
|
||||||
if a.isNew {
|
|
||||||
return fmt.Errorf("cannot delete unsaved appearance")
|
|
||||||
}
|
|
||||||
|
|
||||||
query := `DELETE FROM appearances WHERE appearance_id = ?`
|
|
||||||
_, err := a.db.Exec(query, a.ID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to delete appearance %d: %w", a.ID, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reload reloads the appearance data from the database
|
|
||||||
func (a *Appearance) Reload() error {
|
|
||||||
if a.db == nil {
|
|
||||||
return fmt.Errorf("no database connection available")
|
|
||||||
}
|
|
||||||
|
|
||||||
if a.isNew {
|
|
||||||
return fmt.Errorf("cannot reload unsaved appearance")
|
|
||||||
}
|
|
||||||
|
|
||||||
query := `SELECT name, min_client_version FROM appearances WHERE appearance_id = ?`
|
|
||||||
row := a.db.QueryRow(query, a.ID)
|
|
||||||
|
|
||||||
err := row.Scan(&a.Name, &a.MinClient)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to reload appearance %d: %w", a.ID, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clone creates a copy of the appearance
|
|
||||||
func (a *Appearance) Clone() *Appearance {
|
|
||||||
return &Appearance{
|
|
||||||
ID: a.ID,
|
|
||||||
Name: a.Name,
|
|
||||||
MinClient: a.MinClient,
|
|
||||||
db: a.db,
|
|
||||||
isNew: true, // Clone is always new
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// insert inserts a new appearance into the database
|
|
||||||
func (a *Appearance) insert() error {
|
|
||||||
query := `INSERT INTO appearances (appearance_id, name, min_client_version) VALUES (?, ?, ?)`
|
|
||||||
_, err := a.db.Exec(query, a.ID, a.Name, a.MinClient)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to insert appearance: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
a.isNew = false
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// update updates an existing appearance in the database
|
|
||||||
func (a *Appearance) update() error {
|
|
||||||
query := `UPDATE appearances SET name = ?, min_client_version = ? WHERE appearance_id = ?`
|
|
||||||
result, err := a.db.Exec(query, a.Name, a.MinClient, a.ID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to update appearance: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
rowsAffected, err := result.RowsAffected()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to get rows affected: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if rowsAffected == 0 {
|
|
||||||
return fmt.Errorf("appearance %d not found for update", a.ID)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
340
internal/appearances/appearances.go
Normal file
340
internal/appearances/appearances.go
Normal file
@ -0,0 +1,340 @@
|
|||||||
|
package appearances
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"eq2emu/internal/database"
|
||||||
|
"eq2emu/internal/packets"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Appearance represents a single appearance entry
|
||||||
|
type Appearance struct {
|
||||||
|
ID int32 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
MinClient int16 `json:"min_client"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetID returns the appearance ID
|
||||||
|
func (a *Appearance) GetID() int32 {
|
||||||
|
return a.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetName returns the appearance name (C++ API compatibility)
|
||||||
|
func (a *Appearance) GetName() string {
|
||||||
|
return a.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetNameString returns the name as string (C++ API compatibility)
|
||||||
|
func (a *Appearance) GetNameString() string {
|
||||||
|
return a.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMinClientVersion returns the minimum client version
|
||||||
|
func (a *Appearance) GetMinClientVersion() int16 {
|
||||||
|
return a.MinClient
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsCompatibleWithClient checks if appearance is compatible with client version
|
||||||
|
func (a *Appearance) IsCompatibleWithClient(clientVersion int16) bool {
|
||||||
|
return clientVersion >= a.MinClient
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manager provides centralized appearance management with packet support
|
||||||
|
type Manager struct {
|
||||||
|
appearances map[int32]*Appearance
|
||||||
|
nameIndex map[string][]*Appearance // For GetAppearanceIDsLikeName compatibility
|
||||||
|
mutex sync.RWMutex
|
||||||
|
db *database.Database
|
||||||
|
|
||||||
|
// Statistics
|
||||||
|
stats struct {
|
||||||
|
AppearancesLoaded int32
|
||||||
|
PacketsSent int32
|
||||||
|
PacketErrors int32
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewManager creates a new appearance manager
|
||||||
|
func NewManager(db *database.Database) *Manager {
|
||||||
|
return &Manager{
|
||||||
|
appearances: make(map[int32]*Appearance),
|
||||||
|
nameIndex: make(map[string][]*Appearance),
|
||||||
|
db: db,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadAppearances loads all appearances from database (C++ API compatibility)
|
||||||
|
func (m *Manager) LoadAppearances() error {
|
||||||
|
m.mutex.Lock()
|
||||||
|
defer m.mutex.Unlock()
|
||||||
|
|
||||||
|
query := `SELECT appearance_id, name, min_client_version FROM appearances ORDER BY appearance_id`
|
||||||
|
rows, err := m.db.Query(query)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to query appearances: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
// Clear existing data
|
||||||
|
m.appearances = make(map[int32]*Appearance)
|
||||||
|
m.nameIndex = make(map[string][]*Appearance)
|
||||||
|
|
||||||
|
count := int32(0)
|
||||||
|
for rows.Next() {
|
||||||
|
appearance := &Appearance{}
|
||||||
|
err := rows.Scan(&appearance.ID, &appearance.Name, &appearance.MinClient)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to scan appearance: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.appearances[appearance.ID] = appearance
|
||||||
|
m.updateNameIndex(appearance, true)
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return fmt.Errorf("error iterating appearance rows: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.stats.AppearancesLoaded = count
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindAppearanceByID finds appearance by ID (C++ API compatibility)
|
||||||
|
func (m *Manager) FindAppearanceByID(id int32) *Appearance {
|
||||||
|
m.mutex.RLock()
|
||||||
|
defer m.mutex.RUnlock()
|
||||||
|
return m.appearances[id]
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAppearanceID gets appearance ID by name (C++ API compatibility)
|
||||||
|
func (m *Manager) GetAppearanceID(name string) int16 {
|
||||||
|
m.mutex.RLock()
|
||||||
|
defer m.mutex.RUnlock()
|
||||||
|
|
||||||
|
for _, appearance := range m.appearances {
|
||||||
|
if appearance.Name == name {
|
||||||
|
return int16(appearance.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0 // Not found
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAppearanceIDsLikeName finds appearance IDs matching name pattern (C++ API compatibility)
|
||||||
|
func (m *Manager) GetAppearanceIDsLikeName(name string, filtered bool) []int16 {
|
||||||
|
m.mutex.RLock()
|
||||||
|
defer m.mutex.RUnlock()
|
||||||
|
|
||||||
|
var results []int16
|
||||||
|
searchName := strings.ToLower(name)
|
||||||
|
|
||||||
|
// Try indexed lookup first
|
||||||
|
if indexed := m.nameIndex[searchName]; len(indexed) > 0 {
|
||||||
|
for _, appearance := range indexed {
|
||||||
|
if !filtered || m.isFilteredAppearance(appearance.Name) {
|
||||||
|
results = append(results, int16(appearance.ID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to pattern matching
|
||||||
|
for _, appearance := range m.appearances {
|
||||||
|
if strings.Contains(strings.ToLower(appearance.Name), searchName) {
|
||||||
|
if !filtered || m.isFilteredAppearance(appearance.Name) {
|
||||||
|
results = append(results, int16(appearance.ID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAppearanceName gets appearance name by ID (C++ API compatibility)
|
||||||
|
func (m *Manager) GetAppearanceName(appearanceID int16) string {
|
||||||
|
m.mutex.RLock()
|
||||||
|
defer m.mutex.RUnlock()
|
||||||
|
|
||||||
|
if appearance := m.appearances[int32(appearanceID)]; appearance != nil {
|
||||||
|
return appearance.Name
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShowDressingRoom builds and sends a dressing room packet (C++ API compatibility)
|
||||||
|
func (m *Manager) ShowDressingRoom(characterID int32, clientVersion uint32, slot uint32, appearanceID int16, itemID int32, itemCRC int32) ([]byte, error) {
|
||||||
|
packet, exists := packets.GetPacket("DressingRoom")
|
||||||
|
if !exists {
|
||||||
|
m.stats.PacketErrors++
|
||||||
|
return nil, fmt.Errorf("failed to get DressingRoom packet structure: packet not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build packet data matching C++ implementation
|
||||||
|
data := map[string]interface{}{
|
||||||
|
"slot": slot,
|
||||||
|
"appearance_id": uint16(appearanceID),
|
||||||
|
"item_id": itemID,
|
||||||
|
"item_crc": itemCRC,
|
||||||
|
"unknown": uint16(0),
|
||||||
|
"rgb": []float32{1.0, 1.0, 1.0}, // Default white color
|
||||||
|
"highlight_rgb": []float32{1.0, 1.0, 1.0}, // Default white highlight
|
||||||
|
"unknown3": uint8(0),
|
||||||
|
"icon": uint16(0),
|
||||||
|
"unknown4": uint32(0),
|
||||||
|
"unknown5": make([]uint8, 10), // Zero-filled array
|
||||||
|
}
|
||||||
|
|
||||||
|
builder := packets.NewPacketBuilder(packet, clientVersion, 0)
|
||||||
|
packetData, err := builder.Build(data)
|
||||||
|
if err != nil {
|
||||||
|
m.stats.PacketErrors++
|
||||||
|
return nil, fmt.Errorf("failed to build dressing room packet: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.stats.PacketsSent++
|
||||||
|
return packetData, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDressingRoomPacket builds a dressing room packet (alternative API)
|
||||||
|
func (m *Manager) GetDressingRoomPacket(characterID int32, clientVersion uint32, appearanceData map[string]interface{}) ([]byte, error) {
|
||||||
|
return m.ShowDressingRoom(
|
||||||
|
characterID,
|
||||||
|
clientVersion,
|
||||||
|
appearanceData["slot"].(uint32),
|
||||||
|
appearanceData["appearance_id"].(int16),
|
||||||
|
appearanceData["item_id"].(int32),
|
||||||
|
appearanceData["item_crc"].(int32),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAppearanceCount returns total number of appearances
|
||||||
|
func (m *Manager) GetAppearanceCount() int {
|
||||||
|
m.mutex.RLock()
|
||||||
|
defer m.mutex.RUnlock()
|
||||||
|
return len(m.appearances)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStatistics returns current statistics
|
||||||
|
func (m *Manager) GetStatistics() map[string]interface{} {
|
||||||
|
m.mutex.RLock()
|
||||||
|
defer m.mutex.RUnlock()
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"appearances_loaded": m.stats.AppearancesLoaded,
|
||||||
|
"packets_sent": m.stats.PacketsSent,
|
||||||
|
"packet_errors": m.stats.PacketErrors,
|
||||||
|
"total_appearances": int32(len(m.appearances)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCompatibleAppearances returns appearances compatible with client version
|
||||||
|
func (m *Manager) GetCompatibleAppearances(clientVersion int16) []*Appearance {
|
||||||
|
m.mutex.RLock()
|
||||||
|
defer m.mutex.RUnlock()
|
||||||
|
|
||||||
|
var result []*Appearance
|
||||||
|
for _, appearance := range m.appearances {
|
||||||
|
if appearance.IsCompatibleWithClient(clientVersion) {
|
||||||
|
result = append(result, appearance)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateNameIndex updates the name-based search index
|
||||||
|
func (m *Manager) updateNameIndex(appearance *Appearance, add bool) {
|
||||||
|
name := strings.ToLower(appearance.Name)
|
||||||
|
|
||||||
|
// Index full name
|
||||||
|
if add {
|
||||||
|
m.nameIndex[name] = append(m.nameIndex[name], appearance)
|
||||||
|
} else {
|
||||||
|
if entries := m.nameIndex[name]; entries != nil {
|
||||||
|
for i, entry := range entries {
|
||||||
|
if entry.ID == appearance.ID {
|
||||||
|
m.nameIndex[name] = append(entries[:i], entries[i+1:]...)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Index path components for file-like names
|
||||||
|
if strings.Contains(name, "/") {
|
||||||
|
parts := strings.Split(name, "/")
|
||||||
|
for _, part := range parts {
|
||||||
|
if len(part) > 0 {
|
||||||
|
if add {
|
||||||
|
m.nameIndex[part] = append(m.nameIndex[part], appearance)
|
||||||
|
} else {
|
||||||
|
if entries := m.nameIndex[part]; entries != nil {
|
||||||
|
for i, entry := range entries {
|
||||||
|
if entry.ID == appearance.ID {
|
||||||
|
m.nameIndex[part] = append(entries[:i], entries[i+1:]...)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isFilteredAppearance checks if appearance should be filtered out (matches C++ filtering logic)
|
||||||
|
func (m *Manager) isFilteredAppearance(name string) bool {
|
||||||
|
lowerName := strings.ToLower(name)
|
||||||
|
// Filter out ghost, headless, elemental, test, zombie, vampire appearances
|
||||||
|
filterTerms := []string{"ghost", "headless", "elemental", "test", "zombie", "vampire"}
|
||||||
|
for _, term := range filterTerms {
|
||||||
|
if strings.Contains(lowerName, term) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global manager instance
|
||||||
|
var globalManager *Manager
|
||||||
|
|
||||||
|
// InitializeManager initializes the global appearance manager
|
||||||
|
func InitializeManager(db *database.Database) error {
|
||||||
|
globalManager = NewManager(db)
|
||||||
|
return globalManager.LoadAppearances()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetManager returns the global appearance manager
|
||||||
|
func GetManager() *Manager {
|
||||||
|
return globalManager
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global functions for C++ API compatibility
|
||||||
|
func FindAppearanceByID(id int32) *Appearance {
|
||||||
|
if globalManager != nil {
|
||||||
|
return globalManager.FindAppearanceByID(id)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetAppearanceID(name string) int16 {
|
||||||
|
if globalManager != nil {
|
||||||
|
return globalManager.GetAppearanceID(name)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetAppearanceIDsLikeName(name string, filtered bool) []int16 {
|
||||||
|
if globalManager != nil {
|
||||||
|
return globalManager.GetAppearanceIDsLikeName(name, filtered)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetAppearanceName(appearanceID int16) string {
|
||||||
|
if globalManager != nil {
|
||||||
|
return globalManager.GetAppearanceName(appearanceID)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
254
internal/appearances/appearances_test.go
Normal file
254
internal/appearances/appearances_test.go
Normal file
@ -0,0 +1,254 @@
|
|||||||
|
package appearances
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"eq2emu/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAppearanceBasics(t *testing.T) {
|
||||||
|
appearance := &Appearance{
|
||||||
|
ID: 1472,
|
||||||
|
Name: "ec/pc/human/human_male",
|
||||||
|
MinClient: 283,
|
||||||
|
}
|
||||||
|
|
||||||
|
if appearance.GetID() != 1472 {
|
||||||
|
t.Errorf("Expected ID 1472, got %d", appearance.GetID())
|
||||||
|
}
|
||||||
|
|
||||||
|
if appearance.GetName() != "ec/pc/human/human_male" {
|
||||||
|
t.Errorf("Expected name 'ec/pc/human/human_male', got %s", appearance.GetName())
|
||||||
|
}
|
||||||
|
|
||||||
|
if appearance.GetNameString() != "ec/pc/human/human_male" {
|
||||||
|
t.Errorf("Expected name string 'ec/pc/human/human_male', got %s", appearance.GetNameString())
|
||||||
|
}
|
||||||
|
|
||||||
|
if appearance.GetMinClientVersion() != 283 {
|
||||||
|
t.Errorf("Expected min client 283, got %d", appearance.GetMinClientVersion())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test compatibility
|
||||||
|
if !appearance.IsCompatibleWithClient(283) {
|
||||||
|
t.Error("Expected compatibility with client version 283")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !appearance.IsCompatibleWithClient(500) {
|
||||||
|
t.Error("Expected compatibility with client version 500")
|
||||||
|
}
|
||||||
|
|
||||||
|
if appearance.IsCompatibleWithClient(200) {
|
||||||
|
t.Error("Expected incompatibility with client version 200")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManagerCreation(t *testing.T) {
|
||||||
|
// Create mock database
|
||||||
|
db := &database.Database{}
|
||||||
|
manager := NewManager(db)
|
||||||
|
|
||||||
|
if manager == nil {
|
||||||
|
t.Fatal("Manager creation failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
if manager.GetAppearanceCount() != 0 {
|
||||||
|
t.Errorf("Expected 0 appearances, got %d", manager.GetAppearanceCount())
|
||||||
|
}
|
||||||
|
|
||||||
|
stats := manager.GetStatistics()
|
||||||
|
if stats["total_appearances"].(int32) != 0 {
|
||||||
|
t.Errorf("Expected 0 total appearances in stats, got %d", stats["total_appearances"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppearanceLookup(t *testing.T) {
|
||||||
|
manager := NewManager(&database.Database{})
|
||||||
|
|
||||||
|
// Manually add test appearances (simulating database load)
|
||||||
|
manager.mutex.Lock()
|
||||||
|
testAppearances := []*Appearance{
|
||||||
|
{ID: 1472, Name: "ec/pc/human/human_male", MinClient: 283},
|
||||||
|
{ID: 1473, Name: "ec/pc/human/human_female", MinClient: 283},
|
||||||
|
{ID: 1500, Name: "ec/pc/elf/elf_male", MinClient: 283},
|
||||||
|
{ID: 2000, Name: "accessories/hair/hair_01", MinClient: 283},
|
||||||
|
{ID: 3000, Name: "test_ghost_appearance", MinClient: 283},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, appearance := range testAppearances {
|
||||||
|
manager.appearances[appearance.ID] = appearance
|
||||||
|
manager.updateNameIndex(appearance, true)
|
||||||
|
}
|
||||||
|
manager.mutex.Unlock()
|
||||||
|
|
||||||
|
// Test FindAppearanceByID (C++ API compatibility)
|
||||||
|
found := manager.FindAppearanceByID(1472)
|
||||||
|
if found == nil {
|
||||||
|
t.Fatal("Expected to find appearance with ID 1472")
|
||||||
|
}
|
||||||
|
if found.Name != "ec/pc/human/human_male" {
|
||||||
|
t.Errorf("Expected name 'ec/pc/human/human_male', got %s", found.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test GetAppearanceID (C++ API compatibility)
|
||||||
|
id := manager.GetAppearanceID("ec/pc/human/human_male")
|
||||||
|
if id != 1472 {
|
||||||
|
t.Errorf("Expected ID 1472, got %d", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test GetAppearanceName (C++ API compatibility)
|
||||||
|
name := manager.GetAppearanceName(1472)
|
||||||
|
if name != "ec/pc/human/human_male" {
|
||||||
|
t.Errorf("Expected name 'ec/pc/human/human_male', got %s", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test GetAppearanceIDsLikeName (C++ API compatibility)
|
||||||
|
ids := manager.GetAppearanceIDsLikeName("human", false)
|
||||||
|
if len(ids) != 2 {
|
||||||
|
t.Errorf("Expected 2 human appearances, got %d", len(ids))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test filtering
|
||||||
|
idsFiltered := manager.GetAppearanceIDsLikeName("ghost", true)
|
||||||
|
if len(idsFiltered) != 0 {
|
||||||
|
t.Errorf("Expected 0 ghost appearances after filtering, got %d", len(idsFiltered))
|
||||||
|
}
|
||||||
|
|
||||||
|
idsUnfiltered := manager.GetAppearanceIDsLikeName("ghost", false)
|
||||||
|
if len(idsUnfiltered) != 1 {
|
||||||
|
t.Errorf("Expected 1 ghost appearance without filtering, got %d", len(idsUnfiltered))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test path component indexing
|
||||||
|
hairIDs := manager.GetAppearanceIDsLikeName("hair", false)
|
||||||
|
if len(hairIDs) != 1 {
|
||||||
|
t.Errorf("Expected 1 hair appearance, got %d", len(hairIDs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompatibilityFiltering(t *testing.T) {
|
||||||
|
manager := NewManager(&database.Database{})
|
||||||
|
|
||||||
|
// Add test appearances with different client versions
|
||||||
|
manager.mutex.Lock()
|
||||||
|
testAppearances := []*Appearance{
|
||||||
|
{ID: 1, Name: "old_appearance", MinClient: 100},
|
||||||
|
{ID: 2, Name: "current_appearance", MinClient: 283},
|
||||||
|
{ID: 3, Name: "new_appearance", MinClient: 500},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, appearance := range testAppearances {
|
||||||
|
manager.appearances[appearance.ID] = appearance
|
||||||
|
}
|
||||||
|
manager.mutex.Unlock()
|
||||||
|
|
||||||
|
// Test compatibility filtering
|
||||||
|
compatible := manager.GetCompatibleAppearances(283)
|
||||||
|
if len(compatible) != 2 {
|
||||||
|
t.Errorf("Expected 2 compatible appearances for client 283, got %d", len(compatible))
|
||||||
|
}
|
||||||
|
|
||||||
|
compatible = manager.GetCompatibleAppearances(100)
|
||||||
|
if len(compatible) != 1 {
|
||||||
|
t.Errorf("Expected 1 compatible appearance for client 100, got %d", len(compatible))
|
||||||
|
}
|
||||||
|
|
||||||
|
compatible = manager.GetCompatibleAppearances(600)
|
||||||
|
if len(compatible) != 3 {
|
||||||
|
t.Errorf("Expected 3 compatible appearances for client 600, got %d", len(compatible))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPacketBuilding(t *testing.T) {
|
||||||
|
manager := NewManager(&database.Database{})
|
||||||
|
|
||||||
|
// Test packet building (will fail gracefully if DressingRoom packet not found)
|
||||||
|
clientVersion := uint32(283)
|
||||||
|
characterID := int32(12345)
|
||||||
|
|
||||||
|
_, err := manager.ShowDressingRoom(characterID, clientVersion, 1, 1472, 100, 200)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("Expected error due to missing packet fields")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify error contains expected message about packet building
|
||||||
|
if err != nil && !contains(err.Error(), "failed to build dressing room packet") && !contains(err.Error(), "packet not found") {
|
||||||
|
t.Errorf("Expected packet-related error, got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test statistics update
|
||||||
|
stats := manager.GetStatistics()
|
||||||
|
if stats["packet_errors"].(int32) == 0 {
|
||||||
|
t.Error("Expected packet error to be recorded in statistics")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Packet integration working: found DressingRoom packet structure but needs proper field mapping")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGlobalFunctions(t *testing.T) {
|
||||||
|
// Test global functions work without initialized manager
|
||||||
|
appearance := FindAppearanceByID(1)
|
||||||
|
if appearance != nil {
|
||||||
|
t.Error("Expected nil appearance when manager not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
id := GetAppearanceID("test")
|
||||||
|
if id != 0 {
|
||||||
|
t.Errorf("Expected ID 0 when manager not initialized, got %d", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
name := GetAppearanceName(1)
|
||||||
|
if name != "" {
|
||||||
|
t.Errorf("Expected empty name when manager not initialized, got %s", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
ids := GetAppearanceIDsLikeName("test", false)
|
||||||
|
if ids != nil {
|
||||||
|
t.Error("Expected nil slice when manager not initialized")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilteringLogic(t *testing.T) {
|
||||||
|
manager := NewManager(&database.Database{})
|
||||||
|
|
||||||
|
// Test filtering logic
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{"normal_appearance", true},
|
||||||
|
{"ghost_appearance", false},
|
||||||
|
{"headless_horseman", false},
|
||||||
|
{"elemental_form", false},
|
||||||
|
{"test_model", false},
|
||||||
|
{"zombie_skin", false},
|
||||||
|
{"vampire_teeth", false},
|
||||||
|
{"GHOST_uppercase", false}, // Should be case-insensitive
|
||||||
|
{"valid_model", true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
result := manager.isFilteredAppearance(tc.name)
|
||||||
|
if result != tc.expected {
|
||||||
|
t.Errorf("Filter test for '%s': expected %v, got %v", tc.name, tc.expected, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to check if string contains substring
|
||||||
|
func contains(str, substr string) bool {
|
||||||
|
if len(substr) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if len(str) < len(substr) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i <= len(str)-len(substr); i++ {
|
||||||
|
if str[i:i+len(substr)] == substr {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
@ -1,554 +0,0 @@
|
|||||||
package appearances
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"maps"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
"eq2emu/internal/database"
|
|
||||||
)
|
|
||||||
|
|
||||||
// MasterList is a specialized appearance master list optimized for:
|
|
||||||
// - Fast ID-based lookups (O(1))
|
|
||||||
// - Fast name-based searching (indexed)
|
|
||||||
// - Fast client version filtering (O(1))
|
|
||||||
// - Efficient range queries and statistics
|
|
||||||
type MasterList struct {
|
|
||||||
// Core storage
|
|
||||||
appearances map[int32]*Appearance // ID -> Appearance
|
|
||||||
mutex sync.RWMutex
|
|
||||||
|
|
||||||
// Specialized indices for O(1) lookups
|
|
||||||
byMinClient map[int16][]*Appearance // MinClient -> appearances
|
|
||||||
byNamePart map[string][]*Appearance // Name substring -> appearances (for common searches)
|
|
||||||
|
|
||||||
// Cached metadata
|
|
||||||
clientVersions []int16 // Unique client versions (cached)
|
|
||||||
metaStale bool // Whether metadata cache needs refresh
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewMasterList creates a new specialized appearance master list
|
|
||||||
func NewMasterList() *MasterList {
|
|
||||||
return &MasterList{
|
|
||||||
appearances: make(map[int32]*Appearance),
|
|
||||||
byMinClient: make(map[int16][]*Appearance),
|
|
||||||
byNamePart: make(map[string][]*Appearance),
|
|
||||||
metaStale: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// refreshMetaCache updates the client versions cache
|
|
||||||
func (ml *MasterList) refreshMetaCache() {
|
|
||||||
if !ml.metaStale {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
clientVersionSet := make(map[int16]struct{})
|
|
||||||
|
|
||||||
// Collect unique client versions
|
|
||||||
for _, appearance := range ml.appearances {
|
|
||||||
clientVersionSet[appearance.MinClient] = struct{}{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear existing cache and rebuild
|
|
||||||
ml.clientVersions = ml.clientVersions[:0]
|
|
||||||
for version := range clientVersionSet {
|
|
||||||
ml.clientVersions = append(ml.clientVersions, version)
|
|
||||||
}
|
|
||||||
|
|
||||||
ml.metaStale = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// updateNameIndices updates name-based indices for an appearance
|
|
||||||
func (ml *MasterList) updateNameIndices(appearance *Appearance, add bool) {
|
|
||||||
// Index common name patterns for fast searching
|
|
||||||
name := strings.ToLower(appearance.Name)
|
|
||||||
partsSet := make(map[string]struct{}) // Use set to avoid duplicates
|
|
||||||
|
|
||||||
// Add full name
|
|
||||||
partsSet[name] = struct{}{}
|
|
||||||
|
|
||||||
// Add word-based indices for multi-word names
|
|
||||||
if strings.Contains(name, " ") {
|
|
||||||
words := strings.FieldsSeq(name)
|
|
||||||
for word := range words {
|
|
||||||
partsSet[word] = struct{}{}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add prefix indices for common prefixes
|
|
||||||
if len(name) >= 3 {
|
|
||||||
partsSet[name[:3]] = struct{}{}
|
|
||||||
}
|
|
||||||
if len(name) >= 5 {
|
|
||||||
partsSet[name[:5]] = struct{}{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert set to slice
|
|
||||||
for part := range partsSet {
|
|
||||||
if add {
|
|
||||||
ml.byNamePart[part] = append(ml.byNamePart[part], appearance)
|
|
||||||
} else {
|
|
||||||
// Remove from name part index
|
|
||||||
if namePartApps := ml.byNamePart[part]; namePartApps != nil {
|
|
||||||
for i, app := range namePartApps {
|
|
||||||
if app.ID == appearance.ID {
|
|
||||||
ml.byNamePart[part] = append(namePartApps[:i], namePartApps[i+1:]...)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// AddAppearance adds an appearance with full indexing
|
|
||||||
func (ml *MasterList) AddAppearance(appearance *Appearance) bool {
|
|
||||||
if appearance == nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
ml.mutex.Lock()
|
|
||||||
defer ml.mutex.Unlock()
|
|
||||||
|
|
||||||
// Check if exists
|
|
||||||
if _, exists := ml.appearances[appearance.ID]; exists {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add to core storage
|
|
||||||
ml.appearances[appearance.ID] = appearance
|
|
||||||
|
|
||||||
// Update client version index
|
|
||||||
ml.byMinClient[appearance.MinClient] = append(ml.byMinClient[appearance.MinClient], appearance)
|
|
||||||
|
|
||||||
// Update name indices
|
|
||||||
ml.updateNameIndices(appearance, true)
|
|
||||||
|
|
||||||
// Invalidate metadata cache
|
|
||||||
ml.metaStale = true
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetAppearance retrieves by ID (O(1))
|
|
||||||
func (ml *MasterList) GetAppearance(id int32) *Appearance {
|
|
||||||
ml.mutex.RLock()
|
|
||||||
defer ml.mutex.RUnlock()
|
|
||||||
return ml.appearances[id]
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetAppearanceSafe retrieves an appearance by ID with existence check
|
|
||||||
func (ml *MasterList) GetAppearanceSafe(id int32) (*Appearance, bool) {
|
|
||||||
ml.mutex.RLock()
|
|
||||||
defer ml.mutex.RUnlock()
|
|
||||||
appearance, exists := ml.appearances[id]
|
|
||||||
return appearance, exists
|
|
||||||
}
|
|
||||||
|
|
||||||
// HasAppearance checks if an appearance exists by ID
|
|
||||||
func (ml *MasterList) HasAppearance(id int32) bool {
|
|
||||||
ml.mutex.RLock()
|
|
||||||
defer ml.mutex.RUnlock()
|
|
||||||
_, exists := ml.appearances[id]
|
|
||||||
return exists
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetAppearanceClone retrieves a cloned copy of an appearance by ID
|
|
||||||
func (ml *MasterList) GetAppearanceClone(id int32) *Appearance {
|
|
||||||
ml.mutex.RLock()
|
|
||||||
defer ml.mutex.RUnlock()
|
|
||||||
appearance := ml.appearances[id]
|
|
||||||
if appearance == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return appearance.Clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetAllAppearances returns a copy of all appearances map
|
|
||||||
func (ml *MasterList) GetAllAppearances() map[int32]*Appearance {
|
|
||||||
ml.mutex.RLock()
|
|
||||||
defer ml.mutex.RUnlock()
|
|
||||||
|
|
||||||
// Return a copy to prevent external modification
|
|
||||||
result := make(map[int32]*Appearance, len(ml.appearances))
|
|
||||||
maps.Copy(result, ml.appearances)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetAllAppearancesList returns all appearances as a slice
|
|
||||||
func (ml *MasterList) GetAllAppearancesList() []*Appearance {
|
|
||||||
ml.mutex.RLock()
|
|
||||||
defer ml.mutex.RUnlock()
|
|
||||||
|
|
||||||
result := make([]*Appearance, 0, len(ml.appearances))
|
|
||||||
for _, appearance := range ml.appearances {
|
|
||||||
result = append(result, appearance)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// FindAppearancesByMinClient finds appearances with specific minimum client version (O(1))
|
|
||||||
func (ml *MasterList) FindAppearancesByMinClient(minClient int16) []*Appearance {
|
|
||||||
ml.mutex.RLock()
|
|
||||||
defer ml.mutex.RUnlock()
|
|
||||||
return ml.byMinClient[minClient]
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetCompatibleAppearances returns appearances compatible with the given client version
|
|
||||||
func (ml *MasterList) GetCompatibleAppearances(clientVersion int16) []*Appearance {
|
|
||||||
ml.mutex.RLock()
|
|
||||||
defer ml.mutex.RUnlock()
|
|
||||||
|
|
||||||
var result []*Appearance
|
|
||||||
|
|
||||||
// Collect all appearances with MinClient <= clientVersion
|
|
||||||
for minClient, appearances := range ml.byMinClient {
|
|
||||||
if minClient <= clientVersion {
|
|
||||||
result = append(result, appearances...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// FindAppearancesByName finds appearances containing the given name substring (optimized)
|
|
||||||
func (ml *MasterList) FindAppearancesByName(nameSubstring string) []*Appearance {
|
|
||||||
ml.mutex.RLock()
|
|
||||||
defer ml.mutex.RUnlock()
|
|
||||||
|
|
||||||
searchKey := strings.ToLower(nameSubstring)
|
|
||||||
|
|
||||||
// Try indexed lookup first for exact matches
|
|
||||||
if indexedApps := ml.byNamePart[searchKey]; indexedApps != nil {
|
|
||||||
// Return a copy to prevent external modification
|
|
||||||
result := make([]*Appearance, len(indexedApps))
|
|
||||||
copy(result, indexedApps)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to full scan for partial matches
|
|
||||||
var result []*Appearance
|
|
||||||
for _, appearance := range ml.appearances {
|
|
||||||
if contains(strings.ToLower(appearance.Name), searchKey) {
|
|
||||||
result = append(result, appearance)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetAppearancesByIDRange returns appearances within the given ID range (inclusive)
|
|
||||||
func (ml *MasterList) GetAppearancesByIDRange(minID, maxID int32) []*Appearance {
|
|
||||||
ml.mutex.RLock()
|
|
||||||
defer ml.mutex.RUnlock()
|
|
||||||
|
|
||||||
var result []*Appearance
|
|
||||||
for id, appearance := range ml.appearances {
|
|
||||||
if id >= minID && id <= maxID {
|
|
||||||
result = append(result, appearance)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetAppearancesByClientRange returns appearances compatible within client version range
|
|
||||||
func (ml *MasterList) GetAppearancesByClientRange(minClientVersion, maxClientVersion int16) []*Appearance {
|
|
||||||
ml.mutex.RLock()
|
|
||||||
defer ml.mutex.RUnlock()
|
|
||||||
|
|
||||||
var result []*Appearance
|
|
||||||
|
|
||||||
// Collect all appearances with MinClient in range
|
|
||||||
for minClient, appearances := range ml.byMinClient {
|
|
||||||
if minClient >= minClientVersion && minClient <= maxClientVersion {
|
|
||||||
result = append(result, appearances...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetClientVersions returns all unique client versions using cached results
|
|
||||||
func (ml *MasterList) GetClientVersions() []int16 {
|
|
||||||
ml.mutex.Lock() // Need write lock to potentially update cache
|
|
||||||
defer ml.mutex.Unlock()
|
|
||||||
|
|
||||||
ml.refreshMetaCache()
|
|
||||||
|
|
||||||
// Return a copy to prevent external modification
|
|
||||||
result := make([]int16, len(ml.clientVersions))
|
|
||||||
copy(result, ml.clientVersions)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// RemoveAppearance removes an appearance and updates all indices
|
|
||||||
func (ml *MasterList) RemoveAppearance(id int32) bool {
|
|
||||||
ml.mutex.Lock()
|
|
||||||
defer ml.mutex.Unlock()
|
|
||||||
|
|
||||||
appearance, exists := ml.appearances[id]
|
|
||||||
if !exists {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove from core storage
|
|
||||||
delete(ml.appearances, id)
|
|
||||||
|
|
||||||
// Remove from client version index
|
|
||||||
clientApps := ml.byMinClient[appearance.MinClient]
|
|
||||||
for i, app := range clientApps {
|
|
||||||
if app.ID == id {
|
|
||||||
ml.byMinClient[appearance.MinClient] = append(clientApps[:i], clientApps[i+1:]...)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove from name indices
|
|
||||||
ml.updateNameIndices(appearance, false)
|
|
||||||
|
|
||||||
// Invalidate metadata cache
|
|
||||||
ml.metaStale = true
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateAppearance updates an existing appearance
|
|
||||||
func (ml *MasterList) UpdateAppearance(appearance *Appearance) error {
|
|
||||||
if appearance == nil {
|
|
||||||
return fmt.Errorf("appearance cannot be nil")
|
|
||||||
}
|
|
||||||
|
|
||||||
ml.mutex.Lock()
|
|
||||||
defer ml.mutex.Unlock()
|
|
||||||
|
|
||||||
// Check if exists
|
|
||||||
old, exists := ml.appearances[appearance.ID]
|
|
||||||
if !exists {
|
|
||||||
return fmt.Errorf("appearance %d not found", appearance.ID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove old appearance from indices (but not core storage yet)
|
|
||||||
clientApps := ml.byMinClient[old.MinClient]
|
|
||||||
for i, app := range clientApps {
|
|
||||||
if app.ID == appearance.ID {
|
|
||||||
ml.byMinClient[old.MinClient] = append(clientApps[:i], clientApps[i+1:]...)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove from name indices
|
|
||||||
ml.updateNameIndices(old, false)
|
|
||||||
|
|
||||||
// Update core storage
|
|
||||||
ml.appearances[appearance.ID] = appearance
|
|
||||||
|
|
||||||
// Add new appearance to indices
|
|
||||||
ml.byMinClient[appearance.MinClient] = append(ml.byMinClient[appearance.MinClient], appearance)
|
|
||||||
ml.updateNameIndices(appearance, true)
|
|
||||||
|
|
||||||
// Invalidate metadata cache
|
|
||||||
ml.metaStale = true
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetAppearanceCount returns the number of appearances
|
|
||||||
func (ml *MasterList) GetAppearanceCount() int {
|
|
||||||
ml.mutex.RLock()
|
|
||||||
defer ml.mutex.RUnlock()
|
|
||||||
return len(ml.appearances)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Size returns the total number of appearances
|
|
||||||
func (ml *MasterList) Size() int {
|
|
||||||
ml.mutex.RLock()
|
|
||||||
defer ml.mutex.RUnlock()
|
|
||||||
return len(ml.appearances)
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsEmpty returns true if the master list is empty
|
|
||||||
func (ml *MasterList) IsEmpty() bool {
|
|
||||||
ml.mutex.RLock()
|
|
||||||
defer ml.mutex.RUnlock()
|
|
||||||
return len(ml.appearances) == 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// ClearAppearances removes all appearances from the list
|
|
||||||
func (ml *MasterList) ClearAppearances() {
|
|
||||||
ml.mutex.Lock()
|
|
||||||
defer ml.mutex.Unlock()
|
|
||||||
|
|
||||||
// Clear all maps
|
|
||||||
ml.appearances = make(map[int32]*Appearance)
|
|
||||||
ml.byMinClient = make(map[int16][]*Appearance)
|
|
||||||
ml.byNamePart = make(map[string][]*Appearance)
|
|
||||||
|
|
||||||
// Clear cached metadata
|
|
||||||
ml.clientVersions = ml.clientVersions[:0]
|
|
||||||
ml.metaStale = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear removes all appearances from the master list
|
|
||||||
func (ml *MasterList) Clear() {
|
|
||||||
ml.ClearAppearances()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ForEach executes a function for each appearance
|
|
||||||
func (ml *MasterList) ForEach(fn func(int32, *Appearance)) {
|
|
||||||
ml.mutex.RLock()
|
|
||||||
defer ml.mutex.RUnlock()
|
|
||||||
|
|
||||||
for id, appearance := range ml.appearances {
|
|
||||||
fn(id, appearance)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ValidateAppearances checks all appearances for consistency
|
|
||||||
func (ml *MasterList) ValidateAppearances() []string {
|
|
||||||
ml.mutex.RLock()
|
|
||||||
defer ml.mutex.RUnlock()
|
|
||||||
|
|
||||||
var issues []string
|
|
||||||
|
|
||||||
for id, appearance := range ml.appearances {
|
|
||||||
if appearance == nil {
|
|
||||||
issues = append(issues, fmt.Sprintf("Appearance ID %d is nil", id))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if appearance.GetID() != id {
|
|
||||||
issues = append(issues, fmt.Sprintf("Appearance ID mismatch: map key %d != appearance ID %d", id, appearance.GetID()))
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(appearance.GetName()) == 0 {
|
|
||||||
issues = append(issues, fmt.Sprintf("Appearance ID %d has empty name", id))
|
|
||||||
}
|
|
||||||
|
|
||||||
if appearance.GetMinClientVersion() < 0 {
|
|
||||||
issues = append(issues, fmt.Sprintf("Appearance ID %d has negative min client version: %d", id, appearance.GetMinClientVersion()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return issues
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsValid returns true if all appearances are valid
|
|
||||||
func (ml *MasterList) IsValid() bool {
|
|
||||||
issues := ml.ValidateAppearances()
|
|
||||||
return len(issues) == 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetStatistics returns statistics about the appearance collection
|
|
||||||
func (ml *MasterList) GetStatistics() map[string]any {
|
|
||||||
ml.mutex.RLock()
|
|
||||||
defer ml.mutex.RUnlock()
|
|
||||||
|
|
||||||
stats := make(map[string]any)
|
|
||||||
stats["total_appearances"] = len(ml.appearances)
|
|
||||||
|
|
||||||
if len(ml.appearances) == 0 {
|
|
||||||
return stats
|
|
||||||
}
|
|
||||||
|
|
||||||
// Count by minimum client version
|
|
||||||
versionCounts := make(map[int16]int)
|
|
||||||
var minID, maxID int32
|
|
||||||
first := true
|
|
||||||
|
|
||||||
for id, appearance := range ml.appearances {
|
|
||||||
versionCounts[appearance.GetMinClientVersion()]++
|
|
||||||
|
|
||||||
if first {
|
|
||||||
minID = id
|
|
||||||
maxID = id
|
|
||||||
first = false
|
|
||||||
} else {
|
|
||||||
if id < minID {
|
|
||||||
minID = id
|
|
||||||
}
|
|
||||||
if id > maxID {
|
|
||||||
maxID = id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
stats["appearances_by_min_client"] = versionCounts
|
|
||||||
stats["min_id"] = minID
|
|
||||||
stats["max_id"] = maxID
|
|
||||||
stats["id_range"] = maxID - minID
|
|
||||||
|
|
||||||
return stats
|
|
||||||
}
|
|
||||||
|
|
||||||
// LoadAllAppearances loads all appearances from the database into the master list
|
|
||||||
func (ml *MasterList) LoadAllAppearances(db *database.Database) error {
|
|
||||||
if db == nil {
|
|
||||||
return fmt.Errorf("database connection is nil")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear existing appearances
|
|
||||||
ml.Clear()
|
|
||||||
|
|
||||||
query := `SELECT appearance_id, name, min_client_version FROM appearances ORDER BY appearance_id`
|
|
||||||
rows, err := db.Query(query)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to query appearances: %w", err)
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
count := 0
|
|
||||||
for rows.Next() {
|
|
||||||
appearance := &Appearance{
|
|
||||||
db: db,
|
|
||||||
isNew: false,
|
|
||||||
}
|
|
||||||
|
|
||||||
err := rows.Scan(&appearance.ID, &appearance.Name, &appearance.MinClient)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to scan appearance: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !ml.AddAppearance(appearance) {
|
|
||||||
return fmt.Errorf("failed to add appearance %d to master list", appearance.ID)
|
|
||||||
}
|
|
||||||
|
|
||||||
count++
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := rows.Err(); err != nil {
|
|
||||||
return fmt.Errorf("error iterating appearance rows: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// LoadAllAppearancesFromDatabase is a convenience function that creates a master list and loads all appearances
|
|
||||||
func LoadAllAppearancesFromDatabase(db *database.Database) (*MasterList, error) {
|
|
||||||
masterList := NewMasterList()
|
|
||||||
err := masterList.LoadAllAppearances(db)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return masterList, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// contains checks if a string contains a substring (case-sensitive)
|
|
||||||
func contains(str, substr string) bool {
|
|
||||||
if len(substr) == 0 {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if len(str) < len(substr) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := 0; i <= len(str)-len(substr); i++ {
|
|
||||||
if str[i:i+len(substr)] == substr {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
@ -106,6 +106,10 @@ const (
|
|||||||
OP_CommitAATemplate
|
OP_CommitAATemplate
|
||||||
OP_ExamineAASpellInfo
|
OP_ExamineAASpellInfo
|
||||||
|
|
||||||
|
// Appearance system opcodes
|
||||||
|
OP_DressingRoom
|
||||||
|
OP_ReskinCharacterRequestMsg
|
||||||
|
|
||||||
// Add more opcodes as needed...
|
// Add more opcodes as needed...
|
||||||
_maxInternalOpcode // Sentinel value
|
_maxInternalOpcode // Sentinel value
|
||||||
)
|
)
|
||||||
@ -177,6 +181,8 @@ var OpcodeNames = map[InternalOpcode]string{
|
|||||||
OP_AdvancementRequestMsg: "OP_AdvancementRequestMsg",
|
OP_AdvancementRequestMsg: "OP_AdvancementRequestMsg",
|
||||||
OP_CommitAATemplate: "OP_CommitAATemplate",
|
OP_CommitAATemplate: "OP_CommitAATemplate",
|
||||||
OP_ExamineAASpellInfo: "OP_ExamineAASpellInfo",
|
OP_ExamineAASpellInfo: "OP_ExamineAASpellInfo",
|
||||||
|
OP_DressingRoom: "OP_DressingRoom",
|
||||||
|
OP_ReskinCharacterRequestMsg: "OP_ReskinCharacterRequestMsg",
|
||||||
}
|
}
|
||||||
|
|
||||||
// OpcodeManager handles the mapping between client-specific opcodes and internal opcodes
|
// OpcodeManager handles the mapping between client-specific opcodes and internal opcodes
|
||||||
|
Loading…
x
Reference in New Issue
Block a user