diff --git a/internal/collections/README.md b/internal/collections/README.md deleted file mode 100644 index 326c612..0000000 --- a/internal/collections/README.md +++ /dev/null @@ -1,315 +0,0 @@ -# Collections System - -The collections system provides comprehensive achievement-based item collection functionality for EverQuest II server emulation, converted from the original C++ EQ2EMu implementation. - -## Overview - -The collections system allows players to find specific items scattered throughout the game world and combine them into collections for rewards. When players complete a collection by finding all required items, they receive rewards such as experience, coins, items, or a choice of selectable items. - -## Architecture - -### Core Components - -**Collection** - Individual collection with required items, rewards, and completion tracking -**MasterCollectionList** - Registry of all available collections in the game -**PlayerCollectionList** - Per-player collection progress and completion tracking -**CollectionManager** - High-level collection system coordinator -**CollectionService** - Service layer for game integration and client communication - -### Key Features - -- **Item-Based Collections**: Players find specific items to complete collections -- **Multiple Reward Types**: Coins, experience points, fixed items, and selectable items -- **Progress Tracking**: Real-time tracking of collection completion progress -- **Category Organization**: Collections organized by categories for easy browsing -- **Level Restrictions**: Collections appropriate for different player levels -- **Thread Safety**: All operations use proper Go concurrency patterns -- **Database Persistence**: Player progress saved automatically - -## Collection Structure - -### Collection Data -- **ID**: Unique collection identifier -- **Name**: Display name for the collection -- **Category**: Organizational category (e.g., "Artifacts", "Shinies") -- **Level**: Recommended level for the collection -- **Items**: List of required items with index positions -- **Rewards**: Coins, XP, items, and selectable items - -### Item States -- **Not Found** (0): Player hasn't found this item yet -- **Found** (1): Player has found and added this item to the collection - -### Collection States -- **Incomplete**: Not all required items have been found -- **Ready to Turn In**: All items found but not yet completed -- **Completed**: Collection has been turned in and rewards claimed - -## Database Schema - -### Collections Table -```sql -CREATE TABLE collections ( - id INTEGER PRIMARY KEY, - collection_name TEXT NOT NULL, - collection_category TEXT NOT NULL DEFAULT '', - level INTEGER NOT NULL DEFAULT 0, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP -); -``` - -### Collection Details Table -```sql -CREATE TABLE collection_details ( - collection_id INTEGER NOT NULL, - item_id INTEGER NOT NULL, - item_index INTEGER NOT NULL DEFAULT 0, - PRIMARY KEY (collection_id, item_id), - FOREIGN KEY (collection_id) REFERENCES collections(id) ON DELETE CASCADE -); -``` - -### Collection Rewards Table -```sql -CREATE TABLE collection_rewards ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - collection_id INTEGER NOT NULL, - reward_type TEXT NOT NULL, -- 'Item', 'Selectable', 'Coin', 'XP' - reward_value TEXT NOT NULL, - reward_quantity INTEGER NOT NULL DEFAULT 1, - FOREIGN KEY (collection_id) REFERENCES collections(id) ON DELETE CASCADE -); -``` - -### Player Collections Table -```sql -CREATE TABLE character_collections ( - char_id INTEGER NOT NULL, - collection_id INTEGER NOT NULL, - completed INTEGER NOT NULL DEFAULT 0, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (char_id, collection_id), - FOREIGN KEY (collection_id) REFERENCES collections(id) ON DELETE CASCADE -); -``` - -### Player Collection Items Table -```sql -CREATE TABLE character_collection_items ( - char_id INTEGER NOT NULL, - collection_id INTEGER NOT NULL, - collection_item_id INTEGER NOT NULL, - found_at DATETIME DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (char_id, collection_id, collection_item_id), - FOREIGN KEY (char_id, collection_id) REFERENCES character_collections(char_id, collection_id) ON DELETE CASCADE -); -``` - -## Usage Examples - -### System Initialization - -```go -// Initialize collection service -database := NewDatabaseCollectionManager(db) -itemLookup := NewItemLookupService() -clientManager := NewClientManager() - -service := NewCollectionService(database, itemLookup, clientManager) -err := service.Initialize(ctx) -``` - -### Player Operations - -```go -// Load player collections when they log in -err := service.LoadPlayerCollections(ctx, characterID) - -// Process when player finds an item -err := service.ProcessItemFound(characterID, itemID) - -// Complete a collection -rewardProvider := NewRewardProvider() -err := service.CompleteCollection(characterID, collectionID, rewardProvider) - -// Get player's collection progress -progress, err := service.GetPlayerCollectionProgress(characterID) - -// Unload when player logs out -err := service.UnloadPlayerCollections(ctx, characterID) -``` - -### Collection Management - -```go -// Get all collections in a category -collections := manager.GetCollectionsByCategory("Artifacts") - -// Search collections by name -collections := manager.SearchCollections("Ancient") - -// Get collections appropriate for player level -collections := manager.GetAvailableCollections(playerLevel) - -// Check if item is needed by any collection -needed := masterList.NeedsItem(itemID) -``` - -## Reward System - -### Reward Types - -**Coin Rewards** -```go -collection.SetRewardCoin(50000) // 5 gold -``` - -**Experience Rewards** -```go -collection.SetRewardXP(10000) // 10,000 XP -``` - -**Item Rewards** (automatically given) -```go -collection.AddRewardItem(CollectionRewardItem{ - ItemID: 12345, - Quantity: 1, -}) -``` - -**Selectable Rewards** (player chooses one) -```go -collection.AddSelectableRewardItem(CollectionRewardItem{ - ItemID: 12346, - Quantity: 1, -}) -``` - -## Integration Interfaces - -### ItemLookup -Provides item information and validation for collections. - -### ClientManager -Handles client communication for collection updates and lists. - -### RewardProvider -Manages distribution of collection rewards to players. - -### CollectionEventHandler -Handles collection-related events for logging and notifications. - -## Thread Safety - -All operations are thread-safe using: -- `sync.RWMutex` for collection and list operations -- Atomic updates for collection progress -- Database transactions for consistency -- Proper locking hierarchies to prevent deadlocks - -## Performance Features - -- **Efficient Lookups**: Hash-based collection and item lookups -- **Lazy Loading**: Player collections loaded only when needed -- **Batch Operations**: Multiple items and collections processed together -- **Connection Pooling**: Efficient database connection management -- **Caching**: Master collections cached in memory - -## Event System - -The collections system provides comprehensive event handling: - -```go -// Item found event -OnItemFound(characterID, collectionID, itemID int32) - -// Collection completed event -OnCollectionCompleted(characterID, collectionID int32) - -// Rewards claimed event -OnRewardClaimed(characterID, collectionID int32, rewards []CollectionRewardItem, coin, xp int64) -``` - -## Statistics and Monitoring - -### System Statistics -- Total collections available -- Collections per category -- Total items across all collections -- Reward distribution statistics - -### Player Statistics -- Collections completed -- Collections in progress -- Items found -- Progress percentages - -## Error Handling - -Comprehensive error handling covers: -- Database connection failures -- Invalid collection or item IDs -- Reward distribution failures -- Concurrent access issues -- Data validation errors - -## Future Enhancements - -Areas marked for future implementation: -- Collection discovery mechanics -- Rare item collection bonuses -- Collection sharing and trading -- Achievement integration -- Collection leaderboards -- Seasonal collections - -## File Structure - -``` -internal/collections/ -├── README.md # This documentation -├── constants.go # Collection constants and limits -├── types.go # Core data structures -├── interfaces.go # Integration interfaces -├── collections.go # Collection implementation -├── master_list.go # Master collection registry -├── player_list.go # Player collection tracking -├── database.go # Database operations -└── manager.go # High-level collection services -``` - -## Dependencies - -- `eq2emu/internal/database` - Database wrapper -- Standard library: `context`, `sync`, `fmt`, `strings`, `time` - -## Testing - -The collections system is designed for comprehensive testing: -- Mock interfaces for all dependencies -- Unit tests for collection logic -- Integration tests with database -- Concurrent operation testing -- Performance benchmarking - -## Migration from C++ - -Key changes from the C++ implementation: -- Go interfaces for better modularity -- Context-based operations for cancellation -- Proper error handling with wrapped errors -- Thread-safe operations using sync primitives -- Database connection pooling -- Event-driven architecture for notifications - -## Integration Notes - -When integrating with the game server: -1. Initialize the collection service at server startup -2. Load player collections on character login -3. Process item finds during gameplay -4. Handle collection completion through UI interactions -5. Save collections on logout or periodic intervals -6. Clean up resources during server shutdown \ No newline at end of file diff --git a/internal/collections/collection.go b/internal/collections/collection.go new file mode 100644 index 0000000..67b36d9 --- /dev/null +++ b/internal/collections/collection.go @@ -0,0 +1,423 @@ +package collections + +import ( + "fmt" + "time" + + "eq2emu/internal/database" +) + +// Collection represents a collection that players can complete +type Collection struct { + ID int32 `json:"id"` + Name string `json:"name"` + Category string `json:"category"` + Level int8 `json:"level"` + RewardCoin int64 `json:"reward_coin"` + RewardXP int64 `json:"reward_xp"` + Completed bool `json:"completed"` + SaveNeeded bool `json:"-"` + CollectionItems []CollectionItem `json:"collection_items"` + RewardItems []CollectionRewardItem `json:"reward_items"` + SelectableRewardItems []CollectionRewardItem `json:"selectable_reward_items"` + LastModified time.Time `json:"last_modified"` + + db *database.Database `json:"-"` + isNew bool `json:"-"` +} + +// CollectionItem represents an item required for a collection +type CollectionItem struct { + ItemID int32 `json:"item_id"` + Index int8 `json:"index"` + Found int8 `json:"found"` +} + +// CollectionRewardItem represents a reward item for completing a collection +type CollectionRewardItem struct { + ItemID int32 `json:"item_id"` + Quantity int8 `json:"quantity"` +} + +// New creates a new collection with the given database +func New(db *database.Database) *Collection { + return &Collection{ + db: db, + isNew: true, + CollectionItems: make([]CollectionItem, 0), + RewardItems: make([]CollectionRewardItem, 0), + SelectableRewardItems: make([]CollectionRewardItem, 0), + LastModified: time.Now(), + } +} + +// NewWithData creates a new collection with data +func NewWithData(id int32, name, category string, level int8, db *database.Database) *Collection { + return &Collection{ + ID: id, + Name: name, + Category: category, + Level: level, + db: db, + isNew: true, + CollectionItems: make([]CollectionItem, 0), + RewardItems: make([]CollectionRewardItem, 0), + SelectableRewardItems: make([]CollectionRewardItem, 0), + LastModified: time.Now(), + } +} + +// Load loads a collection by ID from the database +func Load(db *database.Database, id int32) (*Collection, error) { + collection := &Collection{ + db: db, + isNew: false, + CollectionItems: make([]CollectionItem, 0), + RewardItems: make([]CollectionRewardItem, 0), + SelectableRewardItems: make([]CollectionRewardItem, 0), + } + + // Load collection base data + query := `SELECT id, collection_name, collection_category, level FROM collections WHERE id = ?` + row := db.QueryRow(query, id) + + err := row.Scan(&collection.ID, &collection.Name, &collection.Category, &collection.Level) + if err != nil { + return nil, fmt.Errorf("failed to load collection %d: %w", id, err) + } + + // Load collection items + itemQuery := `SELECT item_id, item_index, found FROM collection_items WHERE collection_id = ? ORDER BY item_index` + rows, err := db.Query(itemQuery, id) + if err != nil { + return nil, fmt.Errorf("failed to load collection items: %w", err) + } + defer rows.Close() + + for rows.Next() { + var item CollectionItem + if err := rows.Scan(&item.ItemID, &item.Index, &item.Found); err != nil { + return nil, fmt.Errorf("failed to scan collection item: %w", err) + } + collection.CollectionItems = append(collection.CollectionItems, item) + } + + // Load reward data + rewardQuery := `SELECT reward_type, reward_value, reward_quantity FROM collection_rewards WHERE collection_id = ?` + rows, err = db.Query(rewardQuery, id) + if err != nil { + return nil, fmt.Errorf("failed to load collection rewards: %w", err) + } + defer rows.Close() + + for rows.Next() { + var rewardType string + var rewardValue string + var quantity int8 + + if err := rows.Scan(&rewardType, &rewardValue, &quantity); err != nil { + return nil, fmt.Errorf("failed to scan collection reward: %w", err) + } + + switch rewardType { + case "coin": + fmt.Sscanf(rewardValue, "%d", &collection.RewardCoin) + case "xp": + fmt.Sscanf(rewardValue, "%d", &collection.RewardXP) + case "item": + var itemID int32 + fmt.Sscanf(rewardValue, "%d", &itemID) + collection.RewardItems = append(collection.RewardItems, CollectionRewardItem{ + ItemID: itemID, + Quantity: quantity, + }) + case "selectable_item": + var itemID int32 + fmt.Sscanf(rewardValue, "%d", &itemID) + collection.SelectableRewardItems = append(collection.SelectableRewardItems, CollectionRewardItem{ + ItemID: itemID, + Quantity: quantity, + }) + } + } + + collection.LastModified = time.Now() + return collection, nil +} + +// GetID returns the collection ID (implements Identifiable interface) +func (c *Collection) GetID() int32 { + return c.ID +} + +// GetName returns the collection name +func (c *Collection) GetName() string { + return c.Name +} + +// GetCategory returns the collection category +func (c *Collection) GetCategory() string { + return c.Category +} + +// GetLevel returns the collection level +func (c *Collection) GetLevel() int8 { + return c.Level +} + +// GetIsReadyToTurnIn returns true if all items have been found +func (c *Collection) GetIsReadyToTurnIn() bool { + if c.Completed { + return false + } + + for _, item := range c.CollectionItems { + if item.Found == 0 { + return false + } + } + return true +} + +// NeedsItem checks if the collection needs a specific item +func (c *Collection) NeedsItem(itemID int32) bool { + for _, item := range c.CollectionItems { + if item.ItemID == itemID && item.Found == 0 { + return true + } + } + return false +} + +// GetCollectionItemByItemID returns the collection item by item ID +func (c *Collection) GetCollectionItemByItemID(itemID int32) *CollectionItem { + for i := range c.CollectionItems { + if c.CollectionItems[i].ItemID == itemID { + return &c.CollectionItems[i] + } + } + return nil +} + +// MarkItemFound marks an item as found in the collection +func (c *Collection) MarkItemFound(itemID int32) bool { + for i := range c.CollectionItems { + if c.CollectionItems[i].ItemID == itemID && c.CollectionItems[i].Found == 0 { + c.CollectionItems[i].Found = 1 + c.SaveNeeded = true + c.LastModified = time.Now() + return true + } + } + return false +} + +// GetProgress returns the collection progress percentage +func (c *Collection) GetProgress() float64 { + if len(c.CollectionItems) == 0 { + return 0.0 + } + + found := 0 + for _, item := range c.CollectionItems { + if item.Found != 0 { + found++ + } + } + + return float64(found) / float64(len(c.CollectionItems)) * 100.0 +} + +// IsNew returns true if this is a new collection not yet saved to database +func (c *Collection) IsNew() bool { + return c.isNew +} + +// Save saves the collection to the database +func (c *Collection) Save() error { + if c.db == nil { + return fmt.Errorf("no database connection available") + } + + if c.isNew { + return c.insert() + } + return c.update() +} + +// Delete removes the collection from the database +func (c *Collection) Delete() error { + if c.db == nil { + return fmt.Errorf("no database connection available") + } + + if c.isNew { + return fmt.Errorf("cannot delete unsaved collection") + } + + // Delete collection items first + _, err := c.db.Exec(`DELETE FROM collection_items WHERE collection_id = ?`, c.ID) + if err != nil { + return fmt.Errorf("failed to delete collection items: %w", err) + } + + // Delete collection rewards + _, err = c.db.Exec(`DELETE FROM collection_rewards WHERE collection_id = ?`, c.ID) + if err != nil { + return fmt.Errorf("failed to delete collection rewards: %w", err) + } + + // Delete collection + _, err = c.db.Exec(`DELETE FROM collections WHERE id = ?`, c.ID) + if err != nil { + return fmt.Errorf("failed to delete collection %d: %w", c.ID, err) + } + + return nil +} + +// Reload reloads the collection data from the database +func (c *Collection) Reload() error { + if c.db == nil { + return fmt.Errorf("no database connection available") + } + + if c.isNew { + return fmt.Errorf("cannot reload unsaved collection") + } + + reloaded, err := Load(c.db, c.ID) + if err != nil { + return err + } + + // Copy reloaded data + c.Name = reloaded.Name + c.Category = reloaded.Category + c.Level = reloaded.Level + c.RewardCoin = reloaded.RewardCoin + c.RewardXP = reloaded.RewardXP + c.CollectionItems = reloaded.CollectionItems + c.RewardItems = reloaded.RewardItems + c.SelectableRewardItems = reloaded.SelectableRewardItems + c.LastModified = reloaded.LastModified + + return nil +} + +// Clone creates a copy of the collection +func (c *Collection) Clone() *Collection { + newCollection := &Collection{ + ID: c.ID, + Name: c.Name, + Category: c.Category, + Level: c.Level, + RewardCoin: c.RewardCoin, + RewardXP: c.RewardXP, + Completed: c.Completed, + SaveNeeded: c.SaveNeeded, + db: c.db, + isNew: true, // Clone is always new + CollectionItems: make([]CollectionItem, len(c.CollectionItems)), + RewardItems: make([]CollectionRewardItem, len(c.RewardItems)), + SelectableRewardItems: make([]CollectionRewardItem, len(c.SelectableRewardItems)), + LastModified: time.Now(), + } + + copy(newCollection.CollectionItems, c.CollectionItems) + copy(newCollection.RewardItems, c.RewardItems) + copy(newCollection.SelectableRewardItems, c.SelectableRewardItems) + + return newCollection +} + +// insert inserts a new collection into the database +func (c *Collection) insert() error { + query := `INSERT INTO collections (collection_name, collection_category, level) VALUES (?, ?, ?)` + result, err := c.db.Exec(query, c.Name, c.Category, c.Level) + if err != nil { + return fmt.Errorf("failed to insert collection: %w", err) + } + + id, err := result.LastInsertId() + if err != nil { + return fmt.Errorf("failed to get inserted collection ID: %w", err) + } + c.ID = int32(id) + + // Insert collection items + for _, item := range c.CollectionItems { + _, err = c.db.Exec(`INSERT INTO collection_items (collection_id, item_id, item_index, found) VALUES (?, ?, ?, ?)`, + c.ID, item.ItemID, item.Index, item.Found) + if err != nil { + return fmt.Errorf("failed to insert collection item: %w", err) + } + } + + // Insert rewards + if c.RewardCoin > 0 { + _, err = c.db.Exec(`INSERT INTO collection_rewards (collection_id, reward_type, reward_value, reward_quantity) VALUES (?, 'coin', ?, 1)`, + c.ID, fmt.Sprintf("%d", c.RewardCoin)) + if err != nil { + return fmt.Errorf("failed to insert coin reward: %w", err) + } + } + + if c.RewardXP > 0 { + _, err = c.db.Exec(`INSERT INTO collection_rewards (collection_id, reward_type, reward_value, reward_quantity) VALUES (?, 'xp', ?, 1)`, + c.ID, fmt.Sprintf("%d", c.RewardXP)) + if err != nil { + return fmt.Errorf("failed to insert XP reward: %w", err) + } + } + + for _, reward := range c.RewardItems { + _, err = c.db.Exec(`INSERT INTO collection_rewards (collection_id, reward_type, reward_value, reward_quantity) VALUES (?, 'item', ?, ?)`, + c.ID, fmt.Sprintf("%d", reward.ItemID), reward.Quantity) + if err != nil { + return fmt.Errorf("failed to insert item reward: %w", err) + } + } + + for _, reward := range c.SelectableRewardItems { + _, err = c.db.Exec(`INSERT INTO collection_rewards (collection_id, reward_type, reward_value, reward_quantity) VALUES (?, 'selectable_item', ?, ?)`, + c.ID, fmt.Sprintf("%d", reward.ItemID), reward.Quantity) + if err != nil { + return fmt.Errorf("failed to insert selectable item reward: %w", err) + } + } + + c.isNew = false + c.SaveNeeded = false + return nil +} + +// update updates an existing collection in the database +func (c *Collection) update() error { + query := `UPDATE collections SET collection_name = ?, collection_category = ?, level = ? WHERE id = ?` + result, err := c.db.Exec(query, c.Name, c.Category, c.Level, c.ID) + if err != nil { + return fmt.Errorf("failed to update collection: %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("collection %d not found for update", c.ID) + } + + // Update collection items (just update found status) + for _, item := range c.CollectionItems { + _, err = c.db.Exec(`UPDATE collection_items SET found = ? WHERE collection_id = ? AND item_id = ?`, + item.Found, c.ID, item.ItemID) + if err != nil { + return fmt.Errorf("failed to update collection item: %w", err) + } + } + + c.SaveNeeded = false + return nil +} \ No newline at end of file diff --git a/internal/collections/collection_test.go b/internal/collections/collection_test.go new file mode 100644 index 0000000..d7d1739 --- /dev/null +++ b/internal/collections/collection_test.go @@ -0,0 +1,325 @@ +package collections + +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 collection + collection := New(db) + if collection == nil { + t.Fatal("New returned nil") + } + + if !collection.IsNew() { + t.Error("New collection should be marked as new") + } + + if len(collection.CollectionItems) != 0 { + t.Error("New collection should have empty items slice") + } + + if len(collection.RewardItems) != 0 { + t.Error("New collection should have empty reward items slice") + } +} + +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() + + collection := NewWithData(100, "Test Collection", "Heritage", 20, db) + if collection == nil { + t.Fatal("NewWithData returned nil") + } + + if collection.GetID() != 100 { + t.Errorf("Expected ID 100, got %d", collection.GetID()) + } + + if collection.GetName() != "Test Collection" { + t.Errorf("Expected name 'Test Collection', got '%s'", collection.GetName()) + } + + if collection.GetCategory() != "Heritage" { + t.Errorf("Expected category 'Heritage', got '%s'", collection.GetCategory()) + } + + if collection.GetLevel() != 20 { + t.Errorf("Expected level 20, got %d", collection.GetLevel()) + } + + if !collection.IsNew() { + t.Error("NewWithData should create new collection") + } +} + +func TestCollectionItems(t *testing.T) { + db, _ := database.NewSQLite("file::memory:?mode=memory&cache=shared") + defer db.Close() + + collection := NewWithData(100, "Test", "Heritage", 20, db) + + // Add collection items + collection.CollectionItems = append(collection.CollectionItems, CollectionItem{ + ItemID: 12345, + Index: 0, + Found: ItemNotFound, + }) + + collection.CollectionItems = append(collection.CollectionItems, CollectionItem{ + ItemID: 12346, + Index: 1, + Found: ItemNotFound, + }) + + // Test NeedsItem + if !collection.NeedsItem(12345) { + t.Error("Collection should need item 12345") + } + + if collection.NeedsItem(99999) { + t.Error("Collection should not need item 99999") + } + + // Test GetCollectionItemByItemID + item := collection.GetCollectionItemByItemID(12345) + if item == nil { + t.Error("Should find collection item by ID") + } + + if item.ItemID != 12345 { + t.Errorf("Expected item ID 12345, got %d", item.ItemID) + } + + // Test MarkItemFound + if !collection.MarkItemFound(12345) { + t.Error("Should successfully mark item as found") + } + + // Verify item is now marked as found + if collection.CollectionItems[0].Found != ItemFound { + t.Error("Item should be marked as found") + } + + if !collection.SaveNeeded { + t.Error("Collection should be marked as needing save") + } + + // Test that marking the same item again fails + if collection.MarkItemFound(12345) { + t.Error("Should not mark already found item again") + } +} + +func TestCollectionProgress(t *testing.T) { + db, _ := database.NewSQLite("file::memory:?mode=memory&cache=shared") + defer db.Close() + + collection := NewWithData(100, "Test", "Heritage", 20, db) + + // Add collection items + for i := 0; i < 4; i++ { + collection.CollectionItems = append(collection.CollectionItems, CollectionItem{ + ItemID: int32(12345 + i), + Index: int8(i), + Found: ItemNotFound, + }) + } + + // Initially 0% progress + if progress := collection.GetProgress(); progress != 0.0 { + t.Errorf("Expected 0%% progress, got %.1f%%", progress) + } + + // Not ready to turn in + if collection.GetIsReadyToTurnIn() { + t.Error("Collection should not be ready to turn in") + } + + // Mark some items found + collection.MarkItemFound(12345) // 25% + collection.MarkItemFound(12346) // 50% + + if progress := collection.GetProgress(); progress != 50.0 { + t.Errorf("Expected 50%% progress, got %.1f%%", progress) + } + + // Still not ready + if collection.GetIsReadyToTurnIn() { + t.Error("Collection should not be ready to turn in at 50%") + } + + // Mark remaining items + collection.MarkItemFound(12347) // 75% + collection.MarkItemFound(12348) // 100% + + if progress := collection.GetProgress(); progress != 100.0 { + t.Errorf("Expected 100%% progress, got %.1f%%", progress) + } + + // Now ready to turn in + if !collection.GetIsReadyToTurnIn() { + t.Error("Collection should be ready to turn in at 100%") + } +} + +func TestCollectionRewards(t *testing.T) { + db, _ := database.NewSQLite("file::memory:?mode=memory&cache=shared") + defer db.Close() + + collection := NewWithData(100, "Test", "Heritage", 20, db) + + // Set coin and XP rewards + collection.RewardCoin = 1000 + collection.RewardXP = 500 + + // Add item rewards + collection.RewardItems = append(collection.RewardItems, CollectionRewardItem{ + ItemID: 50001, + Quantity: 1, + }) + + collection.SelectableRewardItems = append(collection.SelectableRewardItems, CollectionRewardItem{ + ItemID: 50002, + Quantity: 1, + }) + + collection.SelectableRewardItems = append(collection.SelectableRewardItems, CollectionRewardItem{ + ItemID: 50003, + Quantity: 1, + }) + + if collection.RewardCoin != 1000 { + t.Errorf("Expected 1000 coin reward, got %d", collection.RewardCoin) + } + + if collection.RewardXP != 500 { + t.Errorf("Expected 500 XP reward, got %d", collection.RewardXP) + } + + if len(collection.RewardItems) != 1 { + t.Errorf("Expected 1 reward item, got %d", len(collection.RewardItems)) + } + + if len(collection.SelectableRewardItems) != 2 { + t.Errorf("Expected 2 selectable reward items, got %d", len(collection.SelectableRewardItems)) + } +} + +func TestCollectionClone(t *testing.T) { + db, _ := database.NewSQLite("file::memory:?mode=memory&cache=shared") + defer db.Close() + + original := NewWithData(500, "Original Collection", "Heritage", 30, db) + original.RewardCoin = 2000 + original.RewardXP = 1000 + + // Add some items + original.CollectionItems = append(original.CollectionItems, CollectionItem{ + ItemID: 12345, + Index: 0, + Found: ItemFound, + }) + + original.RewardItems = append(original.RewardItems, CollectionRewardItem{ + ItemID: 50001, + Quantity: 2, + }) + + clone := original.Clone() + + if clone == nil { + t.Fatal("Clone returned nil") + } + + if clone == original { + t.Error("Clone returned same pointer as original") + } + + // Test that all fields are copied + 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.RewardCoin != original.RewardCoin { + t.Errorf("Clone RewardCoin = %v, want %v", clone.RewardCoin, original.RewardCoin) + } + + if len(clone.CollectionItems) != len(original.CollectionItems) { + t.Errorf("Clone items length = %v, want %v", len(clone.CollectionItems), len(original.CollectionItems)) + } + + if len(clone.RewardItems) != len(original.RewardItems) { + t.Errorf("Clone reward items length = %v, want %v", len(clone.RewardItems), len(original.RewardItems)) + } + + if !clone.IsNew() { + t.Error("Clone should always be marked as new") + } + + // Verify modification independence + clone.Name = "Modified Clone" + if original.GetName() == "Modified Clone" { + t.Error("Modifying clone affected original") + } + + // Verify slice independence + if len(original.CollectionItems) > 0 && len(clone.CollectionItems) > 0 { + clone.CollectionItems[0].Found = ItemNotFound + if original.CollectionItems[0].Found == ItemNotFound { + t.Error("Modifying clone items affected original") + } + } +} + +func TestCollectionCompletion(t *testing.T) { + db, _ := database.NewSQLite("file::memory:?mode=memory&cache=shared") + defer db.Close() + + collection := NewWithData(100, "Test", "Heritage", 20, db) + + // Add items + collection.CollectionItems = append(collection.CollectionItems, CollectionItem{ + ItemID: 12345, + Index: 0, + Found: ItemNotFound, + }) + + // Not ready when incomplete + if collection.GetIsReadyToTurnIn() { + t.Error("Incomplete collection should not be ready to turn in") + } + + // Mark as completed + collection.Completed = true + + // Completed collections are never ready to turn in + if collection.GetIsReadyToTurnIn() { + t.Error("Completed collection should not be ready to turn in") + } + + // Mark item found and set not completed + collection.Completed = false + collection.MarkItemFound(12345) + + // Now should be ready + if !collection.GetIsReadyToTurnIn() { + t.Error("Collection with all items found should be ready to turn in") + } +} \ No newline at end of file diff --git a/internal/collections/collections.go b/internal/collections/collections.go deleted file mode 100644 index 82b2eae..0000000 --- a/internal/collections/collections.go +++ /dev/null @@ -1,499 +0,0 @@ -package collections - -import ( - "fmt" - "strconv" - "strings" - "time" -) - -// NewCollection creates a new collection instance -func NewCollection() *Collection { - return &Collection{ - collectionItems: make([]CollectionItem, 0), - rewardItems: make([]CollectionRewardItem, 0), - selectableRewardItems: make([]CollectionRewardItem, 0), - lastModified: time.Now(), - } -} - -// NewCollectionFromData creates a collection from another collection (copy constructor) -func NewCollectionFromData(source *Collection) *Collection { - if source == nil { - return nil - } - - source.mu.RLock() - defer source.mu.RUnlock() - - collection := &Collection{ - id: source.id, - name: source.name, - category: source.category, - level: source.level, - rewardCoin: source.rewardCoin, - rewardXP: source.rewardXP, - completed: source.completed, - saveNeeded: source.saveNeeded, - collectionItems: make([]CollectionItem, len(source.collectionItems)), - rewardItems: make([]CollectionRewardItem, len(source.rewardItems)), - selectableRewardItems: make([]CollectionRewardItem, len(source.selectableRewardItems)), - lastModified: time.Now(), - } - - // Deep copy collection items - copy(collection.collectionItems, source.collectionItems) - - // Deep copy reward items - copy(collection.rewardItems, source.rewardItems) - - // Deep copy selectable reward items - copy(collection.selectableRewardItems, source.selectableRewardItems) - - return collection -} - -// SetID sets the collection ID -func (c *Collection) SetID(id int32) { - c.mu.Lock() - defer c.mu.Unlock() - c.id = id -} - -// SetName sets the collection name -func (c *Collection) SetName(name string) { - c.mu.Lock() - defer c.mu.Unlock() - if len(name) > MaxCollectionNameLength { - name = name[:MaxCollectionNameLength] - } - c.name = name -} - -// SetCategory sets the collection category -func (c *Collection) SetCategory(category string) { - c.mu.Lock() - defer c.mu.Unlock() - if len(category) > MaxCollectionCategoryLength { - category = category[:MaxCollectionCategoryLength] - } - c.category = category -} - -// SetLevel sets the collection level -func (c *Collection) SetLevel(level int8) { - c.mu.Lock() - defer c.mu.Unlock() - c.level = level -} - -// SetCompleted sets the collection completion status -func (c *Collection) SetCompleted(completed bool) { - c.mu.Lock() - defer c.mu.Unlock() - c.completed = completed - c.lastModified = time.Now() -} - -// SetSaveNeeded sets whether the collection needs to be saved -func (c *Collection) SetSaveNeeded(saveNeeded bool) { - c.mu.Lock() - defer c.mu.Unlock() - c.saveNeeded = saveNeeded -} - -// SetRewardCoin sets the coin reward amount -func (c *Collection) SetRewardCoin(coin int64) { - c.mu.Lock() - defer c.mu.Unlock() - c.rewardCoin = coin -} - -// SetRewardXP sets the XP reward amount -func (c *Collection) SetRewardXP(xp int64) { - c.mu.Lock() - defer c.mu.Unlock() - c.rewardXP = xp -} - -// AddCollectionItem adds a required item to the collection -func (c *Collection) AddCollectionItem(item CollectionItem) { - c.mu.Lock() - defer c.mu.Unlock() - c.collectionItems = append(c.collectionItems, item) -} - -// AddRewardItem adds a reward item to the collection -func (c *Collection) AddRewardItem(item CollectionRewardItem) { - c.mu.Lock() - defer c.mu.Unlock() - c.rewardItems = append(c.rewardItems, item) -} - -// AddSelectableRewardItem adds a selectable reward item to the collection -func (c *Collection) AddSelectableRewardItem(item CollectionRewardItem) { - c.mu.Lock() - defer c.mu.Unlock() - c.selectableRewardItems = append(c.selectableRewardItems, item) -} - -// GetID returns the collection ID -func (c *Collection) GetID() int32 { - c.mu.RLock() - defer c.mu.RUnlock() - return c.id -} - -// GetName returns the collection name -func (c *Collection) GetName() string { - c.mu.RLock() - defer c.mu.RUnlock() - return c.name -} - -// GetCategory returns the collection category -func (c *Collection) GetCategory() string { - c.mu.RLock() - defer c.mu.RUnlock() - return c.category -} - -// GetLevel returns the collection level -func (c *Collection) GetLevel() int8 { - c.mu.RLock() - defer c.mu.RUnlock() - return c.level -} - -// GetCompleted returns whether the collection is completed -func (c *Collection) GetCompleted() bool { - c.mu.RLock() - defer c.mu.RUnlock() - return c.completed -} - -// GetSaveNeeded returns whether the collection needs to be saved -func (c *Collection) GetSaveNeeded() bool { - c.mu.RLock() - defer c.mu.RUnlock() - return c.saveNeeded -} - -// GetRewardCoin returns the coin reward amount -func (c *Collection) GetRewardCoin() int64 { - c.mu.RLock() - defer c.mu.RUnlock() - return c.rewardCoin -} - -// GetRewardXP returns the XP reward amount -func (c *Collection) GetRewardXP() int64 { - c.mu.RLock() - defer c.mu.RUnlock() - return c.rewardXP -} - -// GetCollectionItems returns a copy of the collection items -func (c *Collection) GetCollectionItems() []CollectionItem { - c.mu.RLock() - defer c.mu.RUnlock() - items := make([]CollectionItem, len(c.collectionItems)) - copy(items, c.collectionItems) - return items -} - -// GetRewardItems returns a copy of the reward items -func (c *Collection) GetRewardItems() []CollectionRewardItem { - c.mu.RLock() - defer c.mu.RUnlock() - items := make([]CollectionRewardItem, len(c.rewardItems)) - copy(items, c.rewardItems) - return items -} - -// GetSelectableRewardItems returns a copy of the selectable reward items -func (c *Collection) GetSelectableRewardItems() []CollectionRewardItem { - c.mu.RLock() - defer c.mu.RUnlock() - items := make([]CollectionRewardItem, len(c.selectableRewardItems)) - copy(items, c.selectableRewardItems) - return items -} - -// NeedsItem checks if the collection needs a specific item -func (c *Collection) NeedsItem(itemID int32) bool { - c.mu.RLock() - defer c.mu.RUnlock() - - if c.completed { - return false - } - - for _, item := range c.collectionItems { - if item.ItemID == itemID { - return item.Found == ItemNotFound - } - } - - return false -} - -// GetCollectionItemByItemID returns the collection item for a specific item ID -func (c *Collection) GetCollectionItemByItemID(itemID int32) *CollectionItem { - c.mu.RLock() - defer c.mu.RUnlock() - - for i := range c.collectionItems { - if c.collectionItems[i].ItemID == itemID { - return &c.collectionItems[i] - } - } - - return nil -} - -// GetIsReadyToTurnIn checks if all required items have been found -func (c *Collection) GetIsReadyToTurnIn() bool { - c.mu.RLock() - defer c.mu.RUnlock() - - if c.completed { - return false - } - - for _, item := range c.collectionItems { - if item.Found == ItemNotFound { - return false - } - } - - return true -} - -// MarkItemFound marks an item as found in the collection -func (c *Collection) MarkItemFound(itemID int32) bool { - c.mu.Lock() - defer c.mu.Unlock() - - if c.completed { - return false - } - - for i := range c.collectionItems { - if c.collectionItems[i].ItemID == itemID && c.collectionItems[i].Found == ItemNotFound { - c.collectionItems[i].Found = ItemFound - c.saveNeeded = true - c.lastModified = time.Now() - return true - } - } - - return false -} - -// GetProgress returns the completion progress as a percentage -func (c *Collection) GetProgress() float64 { - c.mu.RLock() - defer c.mu.RUnlock() - - if len(c.collectionItems) == 0 { - return 0.0 - } - - foundCount := 0 - for _, item := range c.collectionItems { - if item.Found == ItemFound { - foundCount++ - } - } - - return float64(foundCount) / float64(len(c.collectionItems)) * 100.0 -} - -// GetFoundItemsCount returns the number of found items -func (c *Collection) GetFoundItemsCount() int { - c.mu.RLock() - defer c.mu.RUnlock() - - count := 0 - for _, item := range c.collectionItems { - if item.Found == ItemFound { - count++ - } - } - return count -} - -// GetTotalItemsCount returns the total number of required items -func (c *Collection) GetTotalItemsCount() int { - c.mu.RLock() - defer c.mu.RUnlock() - return len(c.collectionItems) -} - -// GetCollectionInfo returns detailed collection information -func (c *Collection) GetCollectionInfo() CollectionInfo { - c.mu.RLock() - defer c.mu.RUnlock() - - return CollectionInfo{ - ID: c.id, - Name: c.name, - Category: c.category, - Level: c.level, - Completed: c.completed, - ReadyToTurnIn: c.getIsReadyToTurnInNoLock(), - ItemsFound: c.getFoundItemsCountNoLock(), - ItemsTotal: len(c.collectionItems), - RewardCoin: c.rewardCoin, - RewardXP: c.rewardXP, - RewardItems: append([]CollectionRewardItem(nil), c.rewardItems...), - SelectableRewards: append([]CollectionRewardItem(nil), c.selectableRewardItems...), - RequiredItems: append([]CollectionItem(nil), c.collectionItems...), - } -} - -// GetCollectionProgress returns detailed progress information -func (c *Collection) GetCollectionProgress() CollectionProgress { - c.mu.RLock() - defer c.mu.RUnlock() - - var foundItems, neededItems []CollectionItem - for _, item := range c.collectionItems { - if item.Found == ItemFound { - foundItems = append(foundItems, item) - } else { - neededItems = append(neededItems, item) - } - } - - return CollectionProgress{ - CollectionID: c.id, - Name: c.name, - Category: c.category, - Level: c.level, - Completed: c.completed, - ReadyToTurnIn: c.getIsReadyToTurnInNoLock(), - Progress: c.getProgressNoLock(), - ItemsFound: foundItems, - ItemsNeeded: neededItems, - LastUpdated: c.lastModified, - } -} - -// LoadFromRewardData loads reward data into the collection -func (c *Collection) LoadFromRewardData(rewards []CollectionRewardData) error { - c.mu.Lock() - defer c.mu.Unlock() - - for _, reward := range rewards { - switch strings.ToLower(reward.RewardType) { - case strings.ToLower(RewardTypeItem): - itemID, err := strconv.ParseInt(reward.RewardValue, 10, 32) - if err != nil { - return fmt.Errorf("invalid item ID in reward: %s", reward.RewardValue) - } - c.rewardItems = append(c.rewardItems, CollectionRewardItem{ - ItemID: int32(itemID), - Quantity: reward.Quantity, - }) - - case strings.ToLower(RewardTypeSelectable): - itemID, err := strconv.ParseInt(reward.RewardValue, 10, 32) - if err != nil { - return fmt.Errorf("invalid item ID in selectable reward: %s", reward.RewardValue) - } - c.selectableRewardItems = append(c.selectableRewardItems, CollectionRewardItem{ - ItemID: int32(itemID), - Quantity: reward.Quantity, - }) - - case strings.ToLower(RewardTypeCoin): - coin, err := strconv.ParseInt(reward.RewardValue, 10, 64) - if err != nil { - return fmt.Errorf("invalid coin amount in reward: %s", reward.RewardValue) - } - c.rewardCoin = coin - - case strings.ToLower(RewardTypeXP): - xp, err := strconv.ParseInt(reward.RewardValue, 10, 64) - if err != nil { - return fmt.Errorf("invalid XP amount in reward: %s", reward.RewardValue) - } - c.rewardXP = xp - - default: - return fmt.Errorf("unknown reward type: %s", reward.RewardType) - } - } - - return nil -} - -// Validate checks if the collection data is valid -func (c *Collection) Validate() error { - c.mu.RLock() - defer c.mu.RUnlock() - - if c.id <= 0 { - return fmt.Errorf("collection ID must be positive") - } - - if strings.TrimSpace(c.name) == "" { - return fmt.Errorf("collection name cannot be empty") - } - - if len(c.collectionItems) == 0 { - return fmt.Errorf("collection must have at least one required item") - } - - // Check for duplicate item IDs - itemIDs := make(map[int32]bool) - for _, item := range c.collectionItems { - if itemIDs[item.ItemID] { - return fmt.Errorf("duplicate item ID in collection: %d", item.ItemID) - } - itemIDs[item.ItemID] = true - - if item.ItemID <= 0 { - return fmt.Errorf("collection item ID must be positive: %d", item.ItemID) - } - } - - return nil -} - -// Helper methods (no lock versions for internal use) - -func (c *Collection) getIsReadyToTurnInNoLock() bool { - if c.completed { - return false - } - - for _, item := range c.collectionItems { - if item.Found == ItemNotFound { - return false - } - } - - return true -} - -func (c *Collection) getFoundItemsCountNoLock() int { - count := 0 - for _, item := range c.collectionItems { - if item.Found == ItemFound { - count++ - } - } - return count -} - -func (c *Collection) getProgressNoLock() float64 { - if len(c.collectionItems) == 0 { - return 0.0 - } - - foundCount := c.getFoundItemsCountNoLock() - return float64(foundCount) / float64(len(c.collectionItems)) * 100.0 -} diff --git a/internal/collections/collections_test.go b/internal/collections/collections_test.go deleted file mode 100644 index dcfb21d..0000000 --- a/internal/collections/collections_test.go +++ /dev/null @@ -1,1187 +0,0 @@ -package collections - -import ( - "context" - "fmt" - "sync" - "testing" -) - -// Mock implementations for testing - -// MockCollectionDatabase implements CollectionDatabase interface for testing -type MockCollectionDatabase struct { - mu sync.RWMutex - collections []CollectionData - collectionItems map[int32][]CollectionItem - collectionRewards map[int32][]CollectionRewardData - playerCollections map[int32][]PlayerCollectionData - playerCollectionItems map[string][]int32 // key: "charID-collectionID" - failLoad bool - failSave bool -} - -func NewMockCollectionDatabase() *MockCollectionDatabase { - return &MockCollectionDatabase{ - collections: make([]CollectionData, 0), - collectionItems: make(map[int32][]CollectionItem), - collectionRewards: make(map[int32][]CollectionRewardData), - playerCollections: make(map[int32][]PlayerCollectionData), - playerCollectionItems: make(map[string][]int32), - } -} - -func (m *MockCollectionDatabase) LoadCollections(ctx context.Context) ([]CollectionData, error) { - m.mu.RLock() - defer m.mu.RUnlock() - - if m.failLoad { - return nil, fmt.Errorf("mock load error") - } - - return m.collections, nil -} - -func (m *MockCollectionDatabase) LoadCollectionItems(ctx context.Context, collectionID int32) ([]CollectionItem, error) { - m.mu.RLock() - defer m.mu.RUnlock() - - if m.failLoad { - return nil, fmt.Errorf("mock load error") - } - - items, exists := m.collectionItems[collectionID] - if !exists { - return []CollectionItem{}, nil - } - return items, nil -} - -func (m *MockCollectionDatabase) LoadCollectionRewards(ctx context.Context, collectionID int32) ([]CollectionRewardData, error) { - m.mu.RLock() - defer m.mu.RUnlock() - - if m.failLoad { - return nil, fmt.Errorf("mock load error") - } - - rewards, exists := m.collectionRewards[collectionID] - if !exists { - return []CollectionRewardData{}, nil - } - return rewards, nil -} - -func (m *MockCollectionDatabase) LoadPlayerCollections(ctx context.Context, characterID int32) ([]PlayerCollectionData, error) { - m.mu.RLock() - defer m.mu.RUnlock() - - if m.failLoad { - return nil, fmt.Errorf("mock load error") - } - - collections, exists := m.playerCollections[characterID] - if !exists { - return []PlayerCollectionData{}, nil - } - return collections, nil -} - -func (m *MockCollectionDatabase) LoadPlayerCollectionItems(ctx context.Context, characterID, collectionID int32) ([]int32, error) { - m.mu.RLock() - defer m.mu.RUnlock() - - if m.failLoad { - return nil, fmt.Errorf("mock load error") - } - - key := fmt.Sprintf("%d-%d", characterID, collectionID) - items, exists := m.playerCollectionItems[key] - if !exists { - return []int32{}, nil - } - return items, nil -} - -func (m *MockCollectionDatabase) SavePlayerCollection(ctx context.Context, characterID, collectionID int32, completed bool) error { - m.mu.Lock() - defer m.mu.Unlock() - - if m.failSave { - return fmt.Errorf("mock save error") - } - - // Update or add player collection - found := false - collections := m.playerCollections[characterID] - for i := range collections { - if collections[i].CollectionID == collectionID { - collections[i].Completed = completed - found = true - break - } - } - - if !found { - m.playerCollections[characterID] = append(collections, PlayerCollectionData{ - CharacterID: characterID, - CollectionID: collectionID, - Completed: completed, - }) - } - - return nil -} - -func (m *MockCollectionDatabase) SavePlayerCollectionItem(ctx context.Context, characterID, collectionID, itemID int32) error { - m.mu.Lock() - defer m.mu.Unlock() - - if m.failSave { - return fmt.Errorf("mock save error") - } - - key := fmt.Sprintf("%d-%d", characterID, collectionID) - items := m.playerCollectionItems[key] - - // Check if already exists - for _, existingItem := range items { - if existingItem == itemID { - return nil - } - } - - m.playerCollectionItems[key] = append(items, itemID) - return nil -} - -func (m *MockCollectionDatabase) SavePlayerCollections(ctx context.Context, characterID int32, collections []*Collection) error { - if m.failSave { - return fmt.Errorf("mock save error") - } - - for _, collection := range collections { - if collection.GetSaveNeeded() { - err := m.SavePlayerCollection(ctx, characterID, collection.GetID(), collection.GetCompleted()) - if err != nil { - return err - } - - // Save items - items := collection.GetCollectionItems() - for _, item := range items { - if item.Found == ItemFound { - err := m.SavePlayerCollectionItem(ctx, characterID, collection.GetID(), item.ItemID) - if err != nil { - return err - } - } - } - } - } - - return nil -} - -// MockItemLookup implements ItemLookup interface for testing -type MockItemLookup struct { - items map[int32]ItemInfo -} - -func NewMockItemLookup() *MockItemLookup { - return &MockItemLookup{ - items: make(map[int32]ItemInfo), - } -} - -func (m *MockItemLookup) GetItem(itemID int32) (ItemInfo, error) { - if item, exists := m.items[itemID]; exists { - return item, nil - } - return ItemInfo{}, fmt.Errorf("item not found") -} - -func (m *MockItemLookup) ItemExists(itemID int32) bool { - _, exists := m.items[itemID] - return exists -} - -func (m *MockItemLookup) GetItemName(itemID int32) string { - if item, exists := m.items[itemID]; exists { - return item.Name - } - return "Unknown Item" -} - -// MockPlayerManager implements PlayerManager interface for testing -type MockPlayerManager struct { - players map[int32]PlayerInfo -} - -func NewMockPlayerManager() *MockPlayerManager { - return &MockPlayerManager{ - players: make(map[int32]PlayerInfo), - } -} - -func (m *MockPlayerManager) GetPlayerInfo(characterID int32) (PlayerInfo, error) { - if player, exists := m.players[characterID]; exists { - return player, nil - } - return PlayerInfo{}, fmt.Errorf("player not found") -} - -func (m *MockPlayerManager) IsPlayerOnline(characterID int32) bool { - if player, exists := m.players[characterID]; exists { - return player.IsOnline - } - return false -} - -func (m *MockPlayerManager) GetPlayerLevel(characterID int32) int8 { - if player, exists := m.players[characterID]; exists { - return player.Level - } - return 0 -} - -// MockClientManager implements ClientManager interface for testing -type MockClientManager struct { - mu sync.Mutex - sentUpdates []int32 - sentCompletions []int32 - sentLists []int32 - sentProgress []int32 - failSend bool -} - -func NewMockClientManager() *MockClientManager { - return &MockClientManager{ - sentUpdates: make([]int32, 0), - sentCompletions: make([]int32, 0), - sentLists: make([]int32, 0), - sentProgress: make([]int32, 0), - } -} - -func (m *MockClientManager) SendCollectionUpdate(characterID int32, collection *Collection) error { - m.mu.Lock() - defer m.mu.Unlock() - - if m.failSend { - return fmt.Errorf("mock send error") - } - - m.sentUpdates = append(m.sentUpdates, characterID) - return nil -} - -func (m *MockClientManager) SendCollectionComplete(characterID int32, collection *Collection) error { - m.mu.Lock() - defer m.mu.Unlock() - - if m.failSend { - return fmt.Errorf("mock send error") - } - - m.sentCompletions = append(m.sentCompletions, characterID) - return nil -} - -func (m *MockClientManager) SendCollectionList(characterID int32, collections []CollectionInfo) error { - m.mu.Lock() - defer m.mu.Unlock() - - if m.failSend { - return fmt.Errorf("mock send error") - } - - m.sentLists = append(m.sentLists, characterID) - return nil -} - -func (m *MockClientManager) SendCollectionProgress(characterID int32, progress []CollectionProgress) error { - m.mu.Lock() - defer m.mu.Unlock() - - if m.failSend { - return fmt.Errorf("mock send error") - } - - m.sentProgress = append(m.sentProgress, characterID) - return nil -} - -// MockRewardProvider implements RewardProvider interface for testing -type MockRewardProvider struct { - mu sync.Mutex - givenItems []struct { - CharacterID int32 - ItemID int32 - Quantity int8 - } - givenCoin []struct { - CharacterID int32 - Amount int64 - } - givenXP []struct { - CharacterID int32 - Amount int64 - } - failGive bool - failValidate bool -} - -func NewMockRewardProvider() *MockRewardProvider { - return &MockRewardProvider{} -} - -func (m *MockRewardProvider) GiveItem(characterID int32, itemID int32, quantity int8) error { - m.mu.Lock() - defer m.mu.Unlock() - - if m.failGive { - return fmt.Errorf("mock give error") - } - - m.givenItems = append(m.givenItems, struct { - CharacterID int32 - ItemID int32 - Quantity int8 - }{characterID, itemID, quantity}) - return nil -} - -func (m *MockRewardProvider) GiveCoin(characterID int32, amount int64) error { - m.mu.Lock() - defer m.mu.Unlock() - - if m.failGive { - return fmt.Errorf("mock give error") - } - - m.givenCoin = append(m.givenCoin, struct { - CharacterID int32 - Amount int64 - }{characterID, amount}) - return nil -} - -func (m *MockRewardProvider) GiveXP(characterID int32, amount int64) error { - m.mu.Lock() - defer m.mu.Unlock() - - if m.failGive { - return fmt.Errorf("mock give error") - } - - m.givenXP = append(m.givenXP, struct { - CharacterID int32 - Amount int64 - }{characterID, amount}) - return nil -} - -func (m *MockRewardProvider) ValidateRewards(characterID int32, rewards []CollectionRewardItem, coin, xp int64) error { - if m.failValidate { - return fmt.Errorf("mock validate error") - } - return nil -} - -// Tests for Collection - -func TestNewCollection(t *testing.T) { - c := NewCollection() - c.SetID(1) - c.SetName("Test Collection") - c.SetCategory("Test Category") - c.SetLevel(10) - - if c.GetID() != 1 { - t.Errorf("Expected ID 1, got %d", c.GetID()) - } - if c.GetName() != "Test Collection" { - t.Errorf("Expected name 'Test Collection', got %s", c.GetName()) - } - if c.GetCategory() != "Test Category" { - t.Errorf("Expected category 'Test Category', got %s", c.GetCategory()) - } - if c.GetLevel() != 10 { - t.Errorf("Expected level 10, got %d", c.GetLevel()) - } - if c.GetCompleted() { - t.Error("Expected collection to be incomplete") - } - if c.GetSaveNeeded() { - t.Error("Expected saveNeeded to be false") - } -} - -func TestCollectionCopy(t *testing.T) { - c1 := NewCollection() - c1.SetID(1) - c1.SetName("Original") - c1.SetCategory("Category") - c1.SetLevel(5) - c1.SetRewardCoin(1000) - c1.SetRewardXP(5000) - c1.AddCollectionItem(CollectionItem{ItemID: 100, Index: 0, Found: ItemNotFound}) - - c2 := NewCollectionFromData(c1) - - // Verify copy has same values - if c2.GetID() != c1.GetID() { - t.Error("Copy has different ID") - } - if c2.GetName() != c1.GetName() { - t.Error("Copy has different name") - } - if c2.GetRewardCoin() != c1.GetRewardCoin() { - t.Error("Copy has different reward coin") - } - - // Verify deep copy (modifying copy doesn't affect original) - c2.SetRewardCoin(2000) - if c1.GetRewardCoin() == c2.GetRewardCoin() { - t.Error("Modifying copy affected original") - } -} - -func TestCollectionItems(t *testing.T) { - c := NewCollection() - c.SetID(1) - c.SetName("Test") - c.SetCategory("Category") - c.SetLevel(1) - - // Add items - c.AddCollectionItem(CollectionItem{ItemID: 100, Index: 0, Found: ItemNotFound}) - c.AddCollectionItem(CollectionItem{ItemID: 101, Index: 1, Found: ItemNotFound}) - c.AddCollectionItem(CollectionItem{ItemID: 102, Index: 2, Found: ItemNotFound}) - - items := c.GetCollectionItems() - if len(items) != 3 { - t.Errorf("Expected 3 items, got %d", len(items)) - } - - // Test finding item - found := c.MarkItemFound(101) - if !found { - t.Error("Expected to find item 101") - } - if !c.GetSaveNeeded() { - t.Error("Expected saveNeeded to be true after finding item") - } - - // Test finding non-existent item - found = c.MarkItemFound(999) - if found { - t.Error("Should not find non-existent item") - } - - // Check if item is needed - if !c.NeedsItem(100) { - t.Error("Expected NeedsItem to return true for unfound item") - } - if c.NeedsItem(101) { - t.Error("Expected NeedsItem to return false for found item") - } - - // Check ready status - if c.GetIsReadyToTurnIn() { - t.Error("Collection should not be ready with unfound items") - } - - // Find remaining items - c.MarkItemFound(100) - c.MarkItemFound(102) - - if !c.GetIsReadyToTurnIn() { - t.Error("Collection should be ready when all items found") - } -} - -func TestCollectionRewards(t *testing.T) { - c := NewCollection() - c.SetID(1) - c.SetName("Test") - c.SetCategory("Category") - c.SetLevel(1) - - // Set rewards - c.SetRewardCoin(1000) - c.SetRewardXP(5000) - - // Add reward items - c.AddRewardItem(CollectionRewardItem{ItemID: 200, Quantity: 1}) - c.AddRewardItem(CollectionRewardItem{ItemID: 201, Quantity: 5}) - - // Add selectable rewards - c.AddSelectableRewardItem(CollectionRewardItem{ItemID: 300, Quantity: 1}) - c.AddSelectableRewardItem(CollectionRewardItem{ItemID: 301, Quantity: 1}) - - if c.GetRewardCoin() != 1000 { - t.Errorf("Expected reward coin 1000, got %d", c.GetRewardCoin()) - } - if c.GetRewardXP() != 5000 { - t.Errorf("Expected reward XP 5000, got %d", c.GetRewardXP()) - } - - rewards := c.GetRewardItems() - if len(rewards) != 2 { - t.Errorf("Expected 2 reward items, got %d", len(rewards)) - } - - selectable := c.GetSelectableRewardItems() - if len(selectable) != 2 { - t.Errorf("Expected 2 selectable rewards, got %d", len(selectable)) - } -} - -func TestCollectionCompletion(t *testing.T) { - c := NewCollection() - c.SetID(1) - c.SetName("Test") - c.SetCategory("Category") - c.SetLevel(1) - c.AddCollectionItem(CollectionItem{ItemID: 100, Index: 0, Found: ItemNotFound}) - - // Check not ready to complete without all items - if c.GetIsReadyToTurnIn() { - t.Error("Should not be ready to complete without all items") - } - - // Find item - c.MarkItemFound(100) - if !c.GetIsReadyToTurnIn() { - t.Error("Should be ready to complete when all items found") - } - - // Mark as completed - c.SetCompleted(true) - if !c.GetCompleted() { - t.Error("Expected collection to be completed") - } -} - -func TestCollectionProgress(t *testing.T) { - c := NewCollection() - c.SetID(1) - c.SetName("Test") - c.SetCategory("Category") - c.SetLevel(1) - c.AddCollectionItem(CollectionItem{ItemID: 100, Index: 0, Found: ItemNotFound}) - c.AddCollectionItem(CollectionItem{ItemID: 101, Index: 1, Found: ItemNotFound}) - c.AddCollectionItem(CollectionItem{ItemID: 102, Index: 2, Found: ItemNotFound}) - c.AddCollectionItem(CollectionItem{ItemID: 103, Index: 3, Found: ItemNotFound}) - - // Initial progress - progress := c.GetProgress() - if progress != 0.0 { - t.Errorf("Expected 0%% progress, got %.2f%%", progress) - } - - // Find one item - c.MarkItemFound(100) - progress = c.GetProgress() - if progress != 25.0 { - t.Errorf("Expected 25%% progress, got %.2f%%", progress) - } - - // Find more items - c.MarkItemFound(101) - c.MarkItemFound(102) - progress = c.GetProgress() - if progress != 75.0 { - t.Errorf("Expected 75%% progress, got %.2f%%", progress) - } - - // Complete collection - c.MarkItemFound(103) - c.SetCompleted(true) - progress = c.GetProgress() - if progress != 100.0 { - t.Errorf("Expected 100%% progress, got %.2f%%", progress) - } -} - -// Tests for MasterCollectionList - -func TestNewMasterCollectionList(t *testing.T) { - db := NewMockCollectionDatabase() - ml := NewMasterCollectionList(db) - - if ml == nil { - t.Fatal("Failed to create MasterCollectionList") - } -} - -func TestMasterCollectionListLoad(t *testing.T) { - db := NewMockCollectionDatabase() - - // Add test data - db.collections = []CollectionData{ - {ID: 1, Name: "Collection 1", Category: "Category A", Level: 10}, - {ID: 2, Name: "Collection 2", Category: "Category B", Level: 20}, - } - - db.collectionItems[1] = []CollectionItem{ - {ItemID: 100, Index: 0, Found: ItemNotFound}, - {ItemID: 101, Index: 1, Found: ItemNotFound}, - } - - db.collectionItems[2] = []CollectionItem{ - {ItemID: 200, Index: 0, Found: ItemNotFound}, - } - - db.collectionRewards[1] = []CollectionRewardData{ - {CollectionID: 1, RewardType: RewardTypeCoin, RewardValue: "1000", Quantity: 1}, - {CollectionID: 1, RewardType: RewardTypeXP, RewardValue: "5000", Quantity: 1}, - } - - // Set up item lookup - itemLookup := NewMockItemLookup() - itemLookup.items[100] = ItemInfo{ID: 100, Name: "Test Item 1"} - itemLookup.items[101] = ItemInfo{ID: 101, Name: "Test Item 2"} - itemLookup.items[200] = ItemInfo{ID: 200, Name: "Test Item 3"} - - ml := NewMasterCollectionList(db) - err := ml.Initialize(context.Background(), itemLookup) - if err != nil { - t.Fatalf("Failed to load collections: %v", err) - } - - // Verify collections loaded - collection := ml.GetCollection(1) - if collection == nil { - t.Fatal("Collection 1 not found") - } - if collection.GetName() != "Collection 1" { - t.Errorf("Expected name 'Collection 1', got %s", collection.GetName()) - } - - // Verify items loaded - items := collection.GetCollectionItems() - if len(items) != 2 { - t.Errorf("Expected 2 items, got %d", len(items)) - } - - // Verify rewards loaded - if collection.GetRewardCoin() != 1000 { - t.Errorf("Expected reward coin 1000, got %d", collection.GetRewardCoin()) - } -} - -func TestMasterCollectionListSearch(t *testing.T) { - db := NewMockCollectionDatabase() - - db.collections = []CollectionData{ - {ID: 1, Name: "Ancient Artifacts", Category: "Archaeology", Level: 10}, - {ID: 2, Name: "Ancient Weapons", Category: "Combat", Level: 20}, - {ID: 3, Name: "Modern Art", Category: "Art", Level: 15}, - } - - // Add required items for each collection - db.collectionItems[1] = []CollectionItem{ - {ItemID: 100, Index: 0, Found: ItemNotFound}, - } - db.collectionItems[2] = []CollectionItem{ - {ItemID: 200, Index: 0, Found: ItemNotFound}, - } - db.collectionItems[3] = []CollectionItem{ - {ItemID: 300, Index: 0, Found: ItemNotFound}, - } - - // Set up item lookup - itemLookup := NewMockItemLookup() - itemLookup.items[100] = ItemInfo{ID: 100, Name: "Test Item 1"} - itemLookup.items[200] = ItemInfo{ID: 200, Name: "Test Item 2"} - itemLookup.items[300] = ItemInfo{ID: 300, Name: "Test Item 3"} - - ml := NewMasterCollectionList(db) - ml.Initialize(context.Background(), itemLookup) - - // Search by name - results := ml.FindCollectionsByName("Ancient") - if len(results) != 2 { - t.Errorf("Expected 2 results for 'Ancient', got %d", len(results)) - } - - // Search by category - results = ml.GetCollectionsByCategory("Art") - if len(results) != 1 { - t.Errorf("Expected 1 result for category 'Art', got %d", len(results)) - } - - // Search by level range - results = ml.GetCollectionsByLevel(15, 25) - if len(results) != 2 { - t.Errorf("Expected 2 results for level range 15-25, got %d", len(results)) - } -} - -// Tests for PlayerCollectionList - -func TestNewPlayerCollectionList(t *testing.T) { - db := NewMockCollectionDatabase() - pl := NewPlayerCollectionList(1001, db) - - if pl == nil { - t.Fatal("Failed to create PlayerCollectionList") - } - if pl.GetCharacterID() != 1001 { - t.Errorf("Expected character ID 1001, got %d", pl.GetCharacterID()) - } -} - -func TestPlayerCollectionListLoad(t *testing.T) { - db := NewMockCollectionDatabase() - masterList := NewMasterCollectionList(db) - - // Set up master collections - db.collections = []CollectionData{ - {ID: 1, Name: "Collection 1", Category: "Category A", Level: 10}, - {ID: 2, Name: "Collection 2", Category: "Category B", Level: 20}, - } - db.collectionItems[1] = []CollectionItem{ - {ItemID: 100, Index: 0, Found: ItemNotFound}, - {ItemID: 101, Index: 1, Found: ItemNotFound}, - } - db.collectionItems[2] = []CollectionItem{ - {ItemID: 200, Index: 0, Found: ItemNotFound}, - } - - // Set up item lookup - itemLookup := NewMockItemLookup() - itemLookup.items[100] = ItemInfo{ID: 100, Name: "Test Item 1"} - itemLookup.items[101] = ItemInfo{ID: 101, Name: "Test Item 2"} - itemLookup.items[200] = ItemInfo{ID: 200, Name: "Test Item 3"} - - masterList.Initialize(context.Background(), itemLookup) - - // Set up player data - db.playerCollections[1001] = []PlayerCollectionData{ - {CharacterID: 1001, CollectionID: 1, Completed: false}, - {CharacterID: 1001, CollectionID: 2, Completed: true}, - } - db.playerCollectionItems["1001-1"] = []int32{100} // Found item 100 in collection 1 - - pl := NewPlayerCollectionList(1001, db) - err := pl.Initialize(context.Background(), masterList) - if err != nil { - t.Fatalf("Failed to load player collections: %v", err) - } - - // Check loaded collections - collection := pl.GetCollection(1) - if collection == nil { - t.Fatal("Collection 1 not found") - } - if collection.GetCompleted() { - t.Error("Collection 1 should not be completed") - } - - // Check found items - if collection.NeedsItem(100) { - t.Error("Item 100 should be found") - } - if !collection.NeedsItem(101) { - t.Error("Item 101 should not be found") - } - - // Check completed collection - collection2 := pl.GetCollection(2) - if collection2 == nil { - t.Fatal("Collection 2 not found") - } - if !collection2.GetCompleted() { - t.Error("Collection 2 should be completed") - } -} - -func TestPlayerCollectionListAddItem(t *testing.T) { - db := NewMockCollectionDatabase() - masterList := NewMasterCollectionList(db) - - // Set up collections - db.collections = []CollectionData{ - {ID: 1, Name: "Collection 1", Category: "Category A", Level: 10}, - } - db.collectionItems[1] = []CollectionItem{ - {ItemID: 100, Index: 0, Found: ItemNotFound}, - {ItemID: 101, Index: 1, Found: ItemNotFound}, - } - - // Set up item lookup - itemLookup := NewMockItemLookup() - itemLookup.items[100] = ItemInfo{ID: 100, Name: "Test Item 1"} - itemLookup.items[101] = ItemInfo{ID: 101, Name: "Test Item 2"} - - masterList.Initialize(context.Background(), itemLookup) - - pl := NewPlayerCollectionList(1001, db) - - // Try to add collection item directly - this will depend on the actual API - // For now, just test basic functionality - if pl.GetCharacterID() != 1001 { - t.Errorf("Expected character ID 1001, got %d", pl.GetCharacterID()) - } - - // Load collections first - err := pl.Initialize(context.Background(), masterList) - if err != nil { - t.Fatalf("Failed to load player collections: %v", err) - } -} - -// Tests for CollectionManager - -func TestNewCollectionManager(t *testing.T) { - db := NewMockCollectionDatabase() - itemLookup := NewMockItemLookup() - - cm := NewCollectionManager(db, itemLookup) - if cm == nil { - t.Fatal("Failed to create CollectionManager") - } -} - -func TestCollectionManagerInitialize(t *testing.T) { - db := NewMockCollectionDatabase() - itemLookup := NewMockItemLookup() - - // Add test data - db.collections = []CollectionData{ - {ID: 1, Name: "Collection 1", Category: "Category A", Level: 10}, - } - db.collectionItems[1] = []CollectionItem{ - {ItemID: 100, Index: 0, Found: ItemNotFound}, - } - - // Set up item lookup - itemLookup.items[100] = ItemInfo{ID: 100, Name: "Test Item 1"} - - cm := NewCollectionManager(db, itemLookup) - err := cm.Initialize(context.Background()) - if err != nil { - t.Fatalf("Failed to initialize: %v", err) - } - - // Verify master list loaded - stats := cm.GetSystemStatistics() - if stats.TotalCollections != 1 { - t.Errorf("Expected 1 collection, got %d", stats.TotalCollections) - } -} - -func TestCollectionManagerPlayerOperations(t *testing.T) { - db := NewMockCollectionDatabase() - itemLookup := NewMockItemLookup() - - // Add test data - db.collections = []CollectionData{ - {ID: 1, Name: "Collection 1", Category: "Category A", Level: 10}, - } - db.collectionItems[1] = []CollectionItem{ - {ItemID: 100, Index: 0, Found: ItemNotFound}, - {ItemID: 101, Index: 1, Found: ItemNotFound}, - } - - // Set up item lookup - itemLookup.items[100] = ItemInfo{ID: 100, Name: "Test Item 1"} - itemLookup.items[101] = ItemInfo{ID: 101, Name: "Test Item 2"} - - cm := NewCollectionManager(db, itemLookup) - err := cm.Initialize(context.Background()) - if err != nil { - t.Fatalf("Failed to initialize: %v", err) - } - - // Verify statistics - stats := cm.GetSystemStatistics() - if stats.TotalCollections != 1 { - t.Errorf("Expected 1 collection, got %d", stats.TotalCollections) - } - - // Get collection copy - collection := cm.GetCollectionCopy(1) - if collection == nil { - t.Fatal("Failed to get collection copy") - } - if collection.GetName() != "Collection 1" { - t.Errorf("Expected name 'Collection 1', got %s", collection.GetName()) - } -} - -// Note: Database integration tests were removed to eliminate dependency on internal/database wrapper. -// Database functionality is tested through unit tests with mocks above. - -// Tests for EntityCollectionAdapter - -// Mock entity for testing -type mockEntity struct { - id int32 -} - -func (m *mockEntity) GetID() int32 { - return m.id -} - -func TestEntityCollectionAdapter(t *testing.T) { - entity := &mockEntity{id: 1001} - - // Mock player manager - pm := NewMockPlayerManager() - pm.players[1001] = PlayerInfo{ - CharacterID: 1001, - Level: 50, - } - - adapter := &EntityCollectionAdapter{ - entity: entity, - playerManager: pm, - } - - if adapter.GetCharacterID() != 1001 { - t.Errorf("Expected character ID 1001, got %d", adapter.GetCharacterID()) - } - - if adapter.GetLevel() != 50 { - t.Errorf("Expected level 50, got %d", adapter.GetLevel()) - } - - // Test HasItem (should return false as placeholder) - if adapter.HasItem(100) { - t.Error("HasItem should return false") - } -} - -// Concurrency tests - -func TestCollectionConcurrency(t *testing.T) { - c := NewCollection() - c.SetID(1) - c.SetName("Test") - c.SetCategory("Category") - c.SetLevel(1) - - // Add items - for i := 0; i < 100; i++ { - c.AddCollectionItem(CollectionItem{ItemID: int32(i), Index: int8(i), Found: ItemNotFound}) - } - - // Concurrent reads and writes - var wg sync.WaitGroup - errors := make(chan error, 100) - - // Writers - for i := 0; i < 50; i++ { - wg.Add(1) - go func(itemID int32) { - defer wg.Done() - c.MarkItemFound(itemID) - }(int32(i)) - } - - // Readers - for i := 0; i < 50; i++ { - wg.Add(1) - go func() { - defer wg.Done() - _ = c.GetProgress() - _ = c.GetCollectionItems() - _ = c.GetIsReadyToTurnIn() - }() - } - - wg.Wait() - close(errors) - - // Check for errors - for err := range errors { - if err != nil { - t.Errorf("Concurrent operation error: %v", err) - } - } -} - -func TestMasterCollectionListConcurrency(t *testing.T) { - db := NewMockCollectionDatabase() - - // Add test data - for i := 1; i <= 100; i++ { - db.collections = append(db.collections, CollectionData{ - ID: int32(i), - Name: fmt.Sprintf("Collection %d", i), - Category: fmt.Sprintf("Category %d", i%10), - Level: int8(i % 50), - }) - - // Add at least one item per collection - db.collectionItems[int32(i)] = []CollectionItem{ - {ItemID: int32(i * 100), Index: 0, Found: ItemNotFound}, - } - } - - // Set up item lookup for all items - itemLookup := NewMockItemLookup() - for i := 1; i <= 100; i++ { - itemLookup.items[int32(i*100)] = ItemInfo{ID: int32(i * 100), Name: fmt.Sprintf("Item %d", i)} - } - - ml := NewMasterCollectionList(db) - ml.Initialize(context.Background(), itemLookup) - - var wg sync.WaitGroup - - // Concurrent reads - for i := 0; i < 100; i++ { - wg.Add(1) - go func(id int32) { - defer wg.Done() - _ = ml.GetCollection(id) - _ = ml.FindCollectionsByName("Collection 1") - _ = ml.GetCollectionsByCategory("Category 1") - _ = ml.GetAllCollections() - }(int32(i + 1)) - } - - wg.Wait() -} - -// Benchmarks - -func BenchmarkCollectionMarkItemFound(b *testing.B) { - c := NewCollection() - c.SetID(1) - c.SetName("Test") - c.SetCategory("Category") - c.SetLevel(1) - - // Add items - for i := 0; i < 100; i++ { - c.AddCollectionItem(CollectionItem{ItemID: int32(i), Index: int8(i), Found: ItemNotFound}) - } - - b.ResetTimer() - for i := 0; i < b.N; i++ { - c.MarkItemFound(int32(i % 100)) - } -} - -func BenchmarkMasterCollectionListSearch(b *testing.B) { - db := NewMockCollectionDatabase() - - // Add test data with diverse names - for i := 1; i <= 1000; i++ { - db.collections = append(db.collections, CollectionData{ - ID: int32(i), - Name: fmt.Sprintf("Collection %d", i), - Category: fmt.Sprintf("Category %d", i%10), - Level: int8(i % 50), - }) - - // Add at least one item per collection - db.collectionItems[int32(i)] = []CollectionItem{ - {ItemID: int32(i * 100), Index: 0, Found: ItemNotFound}, - } - } - - // Set up item lookup for all items - itemLookup := NewMockItemLookup() - for i := 1; i <= 1000; i++ { - itemLookup.items[int32(i*100)] = ItemInfo{ID: int32(i * 100), Name: fmt.Sprintf("Item %d", i)} - } - - ml := NewMasterCollectionList(db) - ml.Initialize(context.Background(), itemLookup) - - // Test with various search patterns - searches := []string{ - "Collection 500", // Exact match - 1 result - "Collection 5", // Prefix match - ~111 results (5, 50-59, 150-159, etc.) - "NonExistent", // No matches - 0 results - } - - b.ResetTimer() - for i := 0; i < b.N; i++ { - search := searches[i%len(searches)] - _ = ml.FindCollectionsByName(search) - } -} - -func BenchmarkPlayerCollectionListLoad(b *testing.B) { - db := NewMockCollectionDatabase() - masterList := NewMasterCollectionList(db) - - // Set up collections - for i := 1; i <= 100; i++ { - db.collections = append(db.collections, CollectionData{ - ID: int32(i), - Name: fmt.Sprintf("Collection %d", i), - Category: "Category", - Level: 10, - }) - - items := make([]CollectionItem, 10) - for j := 0; j < 10; j++ { - items[j] = CollectionItem{ - ItemID: int32(i*100 + j), - Index: int8(j), - Found: ItemNotFound, - } - } - db.collectionItems[int32(i)] = items - } - - // Set up item lookup for all items - itemLookup := NewMockItemLookup() - for i := 1; i <= 100; i++ { - for j := 0; j < 10; j++ { - itemID := int32(i*100 + j) - itemLookup.items[itemID] = ItemInfo{ID: itemID, Name: fmt.Sprintf("Item %d", itemID)} - } - } - - masterList.Initialize(context.Background(), itemLookup) - - b.ResetTimer() - for i := 0; i < b.N; i++ { - pl := NewPlayerCollectionList(1001, db) - pl.Initialize(context.Background(), masterList) - } -} - -// Test constants -func TestConstants(t *testing.T) { - // Test reward types - if RewardTypeItem != "Item" { - t.Errorf("Expected RewardTypeItem to be 'Item', got %s", RewardTypeItem) - } - if RewardTypeSelectable != "Selectable" { - t.Errorf("Expected RewardTypeSelectable to be 'Selectable', got %s", RewardTypeSelectable) - } - if RewardTypeCoin != "Coin" { - t.Errorf("Expected RewardTypeCoin to be 'Coin', got %s", RewardTypeCoin) - } - if RewardTypeXP != "XP" { - t.Errorf("Expected RewardTypeXP to be 'XP', got %s", RewardTypeXP) - } - - // Test item states - if ItemNotFound != 0 { - t.Errorf("Expected ItemNotFound to be 0, got %d", ItemNotFound) - } - if ItemFound != 1 { - t.Errorf("Expected ItemFound to be 1, got %d", ItemFound) - } - - // Test collection states - if CollectionIncomplete != false { - t.Error("Expected CollectionIncomplete to be false") - } - if CollectionCompleted != true { - t.Error("Expected CollectionCompleted to be true") - } -} \ No newline at end of file diff --git a/internal/collections/constants.go b/internal/collections/constants.go index d94e5ca..b4ba050 100644 --- a/internal/collections/constants.go +++ b/internal/collections/constants.go @@ -1,36 +1,13 @@ package collections -// Collection reward types -const ( - RewardTypeItem = "Item" - RewardTypeSelectable = "Selectable" - RewardTypeCoin = "Coin" - RewardTypeXP = "XP" -) - // Collection item states const ( ItemNotFound = 0 ItemFound = 1 ) -// Collection states -const ( - CollectionIncomplete = false - CollectionCompleted = true -) - // String length limits const ( MaxCollectionNameLength = 512 MaxCollectionCategoryLength = 512 ) - -// Database table names -const ( - TableCollections = "collections" - TableCollectionDetails = "collection_details" - TableCollectionRewards = "collection_rewards" - TableCharacterCollections = "character_collections" - TableCharacterCollectionItems = "character_collection_items" -) diff --git a/internal/collections/database.go b/internal/collections/database.go deleted file mode 100644 index ae8dc26..0000000 --- a/internal/collections/database.go +++ /dev/null @@ -1,585 +0,0 @@ -package collections - -import ( - "context" - "fmt" - - "zombiezen.com/go/sqlite" - "zombiezen.com/go/sqlite/sqlitex" -) - -// DatabaseCollectionManager implements CollectionDatabase interface using sqlitex.Pool -type DatabaseCollectionManager struct { - pool *sqlitex.Pool -} - -// NewDatabaseCollectionManager creates a new database collection manager -func NewDatabaseCollectionManager(pool *sqlitex.Pool) *DatabaseCollectionManager { - return &DatabaseCollectionManager{ - pool: pool, - } -} - -// LoadCollections retrieves all collections from database -func (dcm *DatabaseCollectionManager) LoadCollections(ctx context.Context) ([]CollectionData, error) { - conn, err := dcm.pool.Take(context.Background()) - if err != nil { - return nil, fmt.Errorf("failed to get connection: %w", err) - } - defer dcm.pool.Put(conn) - - query := "SELECT `id`, `collection_name`, `collection_category`, `level` FROM `collections`" - - var collections []CollectionData - err = sqlitex.Execute(conn, query, &sqlitex.ExecOptions{ - ResultFunc: func(stmt *sqlite.Stmt) error { - var collection CollectionData - collection.ID = int32(stmt.ColumnInt64(0)) - collection.Name = stmt.ColumnText(1) - collection.Category = stmt.ColumnText(2) - collection.Level = int8(stmt.ColumnInt64(3)) - collections = append(collections, collection) - return nil - }, - }) - - if err != nil { - return nil, fmt.Errorf("failed to query collections: %w", err) - } - - return collections, nil -} - -// LoadCollectionItems retrieves items for a specific collection -func (dcm *DatabaseCollectionManager) LoadCollectionItems(ctx context.Context, collectionID int32) ([]CollectionItem, error) { - conn, err := dcm.pool.Take(context.Background()) - if err != nil { - return nil, fmt.Errorf("failed to get connection: %w", err) - } - defer dcm.pool.Put(conn) - - query := `SELECT item_id, item_index - FROM collection_details - WHERE collection_id = ? - ORDER BY item_index ASC` - - var items []CollectionItem - err = sqlitex.Execute(conn, query, &sqlitex.ExecOptions{ - Args: []any{collectionID}, - ResultFunc: func(stmt *sqlite.Stmt) error { - var item CollectionItem - item.ItemID = int32(stmt.ColumnInt64(0)) - item.Index = int8(stmt.ColumnInt64(1)) - // Items start as not found - item.Found = ItemNotFound - items = append(items, item) - return nil - }, - }) - - if err != nil { - return nil, fmt.Errorf("failed to query collection items for collection %d: %w", collectionID, err) - } - - return items, nil -} - -// LoadCollectionRewards retrieves rewards for a specific collection -func (dcm *DatabaseCollectionManager) LoadCollectionRewards(ctx context.Context, collectionID int32) ([]CollectionRewardData, error) { - conn, err := dcm.pool.Take(context.Background()) - if err != nil { - return nil, fmt.Errorf("failed to get connection: %w", err) - } - defer dcm.pool.Put(conn) - - query := `SELECT collection_id, reward_type, reward_value, reward_quantity - FROM collection_rewards - WHERE collection_id = ?` - - var rewards []CollectionRewardData - err = sqlitex.Execute(conn, query, &sqlitex.ExecOptions{ - Args: []any{collectionID}, - ResultFunc: func(stmt *sqlite.Stmt) error { - var reward CollectionRewardData - reward.CollectionID = int32(stmt.ColumnInt64(0)) - reward.RewardType = stmt.ColumnText(1) - reward.RewardValue = stmt.ColumnText(2) - reward.Quantity = int8(stmt.ColumnInt64(3)) - rewards = append(rewards, reward) - return nil - }, - }) - - if err != nil { - return nil, fmt.Errorf("failed to query collection rewards for collection %d: %w", collectionID, err) - } - - return rewards, nil -} - -// LoadPlayerCollections retrieves player's collection progress -func (dcm *DatabaseCollectionManager) LoadPlayerCollections(ctx context.Context, characterID int32) ([]PlayerCollectionData, error) { - conn, err := dcm.pool.Take(context.Background()) - if err != nil { - return nil, fmt.Errorf("failed to get connection: %w", err) - } - defer dcm.pool.Put(conn) - - query := `SELECT char_id, collection_id, completed - FROM character_collections - WHERE char_id = ?` - - var collections []PlayerCollectionData - err = sqlitex.Execute(conn, query, &sqlitex.ExecOptions{ - Args: []any{characterID}, - ResultFunc: func(stmt *sqlite.Stmt) error { - var collection PlayerCollectionData - collection.CharacterID = int32(stmt.ColumnInt64(0)) - collection.CollectionID = int32(stmt.ColumnInt64(1)) - collection.Completed = stmt.ColumnInt64(2) != 0 - collections = append(collections, collection) - return nil - }, - }) - - if err != nil { - return nil, fmt.Errorf("failed to query player collections for character %d: %w", characterID, err) - } - - return collections, nil -} - -// LoadPlayerCollectionItems retrieves player's found collection items -func (dcm *DatabaseCollectionManager) LoadPlayerCollectionItems(ctx context.Context, characterID, collectionID int32) ([]int32, error) { - conn, err := dcm.pool.Take(context.Background()) - if err != nil { - return nil, fmt.Errorf("failed to get connection: %w", err) - } - defer dcm.pool.Put(conn) - - query := `SELECT collection_item_id - FROM character_collection_items - WHERE char_id = ? AND collection_id = ?` - - var itemIDs []int32 - err = sqlitex.Execute(conn, query, &sqlitex.ExecOptions{ - Args: []any{characterID, collectionID}, - ResultFunc: func(stmt *sqlite.Stmt) error { - itemID := int32(stmt.ColumnInt64(0)) - itemIDs = append(itemIDs, itemID) - return nil - }, - }) - - if err != nil { - return nil, fmt.Errorf("failed to query player collection items for character %d, collection %d: %w", characterID, collectionID, err) - } - - return itemIDs, nil -} - -// SavePlayerCollection saves player collection completion status -func (dcm *DatabaseCollectionManager) SavePlayerCollection(ctx context.Context, characterID, collectionID int32, completed bool) error { - conn, err := dcm.pool.Take(context.Background()) - if err != nil { - return fmt.Errorf("failed to get connection: %w", err) - } - defer dcm.pool.Put(conn) - - completedInt := 0 - if completed { - completedInt = 1 - } - - query := `INSERT INTO character_collections (char_id, collection_id, completed) - VALUES (?, ?, ?) - ON CONFLICT(char_id, collection_id) - DO UPDATE SET completed = ?` - - err = sqlitex.Execute(conn, query, &sqlitex.ExecOptions{ - Args: []any{characterID, collectionID, completedInt, completedInt}, - }) - if err != nil { - return fmt.Errorf("failed to save player collection for character %d, collection %d: %w", characterID, collectionID, err) - } - - return nil -} - -// SavePlayerCollectionItem saves a found collection item -func (dcm *DatabaseCollectionManager) SavePlayerCollectionItem(ctx context.Context, characterID, collectionID, itemID int32) error { - conn, err := dcm.pool.Take(context.Background()) - if err != nil { - return fmt.Errorf("failed to get connection: %w", err) - } - defer dcm.pool.Put(conn) - - query := `INSERT OR IGNORE INTO character_collection_items (char_id, collection_id, collection_item_id) - VALUES (?, ?, ?)` - - err = sqlitex.Execute(conn, query, &sqlitex.ExecOptions{ - Args: []any{characterID, collectionID, itemID}, - }) - if err != nil { - return fmt.Errorf("failed to save player collection item for character %d, collection %d, item %d: %w", characterID, collectionID, itemID, err) - } - - return nil -} - -// SavePlayerCollections saves all modified player collections -func (dcm *DatabaseCollectionManager) SavePlayerCollections(ctx context.Context, characterID int32, collections []*Collection) error { - if len(collections) == 0 { - return nil - } - - conn, err := dcm.pool.Take(context.Background()) - if err != nil { - return fmt.Errorf("failed to get connection: %w", err) - } - defer dcm.pool.Put(conn) - - // Use a transaction for atomic updates - err = sqlitex.Execute(conn, "BEGIN", nil) - if err != nil { - return fmt.Errorf("failed to begin transaction: %w", err) - } - defer sqlitex.Execute(conn, "ROLLBACK", nil) - - for _, collection := range collections { - if !collection.GetSaveNeeded() { - continue - } - - // Save collection completion status - if err := dcm.savePlayerCollectionInTx(conn, characterID, collection); err != nil { - return fmt.Errorf("failed to save collection %d: %w", collection.GetID(), err) - } - - // Save found items - if err := dcm.savePlayerCollectionItemsInTx(conn, characterID, collection); err != nil { - return fmt.Errorf("failed to save collection items for collection %d: %w", collection.GetID(), err) - } - } - - return sqlitex.Execute(conn, "COMMIT", nil) -} - -// savePlayerCollectionInTx saves a single collection within a transaction -func (dcm *DatabaseCollectionManager) savePlayerCollectionInTx(conn *sqlite.Conn, characterID int32, collection *Collection) error { - completedInt := 0 - if collection.GetCompleted() { - completedInt = 1 - } - - query := `INSERT INTO character_collections (char_id, collection_id, completed) - VALUES (?, ?, ?) - ON CONFLICT(char_id, collection_id) - DO UPDATE SET completed = ?` - - return sqlitex.Execute(conn, query, &sqlitex.ExecOptions{ - Args: []any{characterID, collection.GetID(), completedInt, completedInt}, - }) -} - -// savePlayerCollectionItemsInTx saves collection items within a transaction -func (dcm *DatabaseCollectionManager) savePlayerCollectionItemsInTx(conn *sqlite.Conn, characterID int32, collection *Collection) error { - items := collection.GetCollectionItems() - - for _, item := range items { - if item.Found == ItemFound { - query := `INSERT OR IGNORE INTO character_collection_items (char_id, collection_id, collection_item_id) - VALUES (?, ?, ?)` - - err := sqlitex.Execute(conn, query, &sqlitex.ExecOptions{ - Args: []any{characterID, collection.GetID(), item.ItemID}, - }) - if err != nil { - return fmt.Errorf("failed to save item %d: %w", item.ItemID, err) - } - } - } - - return nil -} - -// EnsureCollectionTables creates the collection tables if they don't exist -func (dcm *DatabaseCollectionManager) EnsureCollectionTables(ctx context.Context) error { - conn, err := dcm.pool.Take(context.Background()) - if err != nil { - return fmt.Errorf("failed to get connection: %w", err) - } - defer dcm.pool.Put(conn) - - queries := []string{ - `CREATE TABLE IF NOT EXISTS collections ( - id INTEGER PRIMARY KEY, - collection_name TEXT NOT NULL, - collection_category TEXT NOT NULL DEFAULT '', - level INTEGER NOT NULL DEFAULT 0, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP - )`, - `CREATE TABLE IF NOT EXISTS collection_details ( - collection_id INTEGER NOT NULL, - item_id INTEGER NOT NULL, - item_index INTEGER NOT NULL DEFAULT 0, - PRIMARY KEY (collection_id, item_id), - FOREIGN KEY (collection_id) REFERENCES collections(id) ON DELETE CASCADE - )`, - `CREATE TABLE IF NOT EXISTS collection_rewards ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - collection_id INTEGER NOT NULL, - reward_type TEXT NOT NULL, - reward_value TEXT NOT NULL, - reward_quantity INTEGER NOT NULL DEFAULT 1, - FOREIGN KEY (collection_id) REFERENCES collections(id) ON DELETE CASCADE - )`, - `CREATE TABLE IF NOT EXISTS character_collections ( - char_id INTEGER NOT NULL, - collection_id INTEGER NOT NULL, - completed INTEGER NOT NULL DEFAULT 0, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (char_id, collection_id), - FOREIGN KEY (collection_id) REFERENCES collections(id) ON DELETE CASCADE - )`, - `CREATE TABLE IF NOT EXISTS character_collection_items ( - char_id INTEGER NOT NULL, - collection_id INTEGER NOT NULL, - collection_item_id INTEGER NOT NULL, - found_at DATETIME DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (char_id, collection_id, collection_item_id), - FOREIGN KEY (char_id, collection_id) REFERENCES character_collections(char_id, collection_id) ON DELETE CASCADE - )`, - } - - for i, query := range queries { - err := sqlitex.Execute(conn, query, nil) - if err != nil { - return fmt.Errorf("failed to create collection table %d: %w", i+1, err) - } - } - - // Create indexes for better performance - indexes := []string{ - `CREATE INDEX IF NOT EXISTS idx_collection_details_collection_id ON collection_details(collection_id)`, - `CREATE INDEX IF NOT EXISTS idx_collection_rewards_collection_id ON collection_rewards(collection_id)`, - `CREATE INDEX IF NOT EXISTS idx_character_collections_char_id ON character_collections(char_id)`, - `CREATE INDEX IF NOT EXISTS idx_character_collection_items_char_id ON character_collection_items(char_id)`, - `CREATE INDEX IF NOT EXISTS idx_character_collection_items_collection_id ON character_collection_items(collection_id)`, - `CREATE INDEX IF NOT EXISTS idx_collections_category ON collections(collection_category)`, - `CREATE INDEX IF NOT EXISTS idx_collections_level ON collections(level)`, - } - - for i, query := range indexes { - err := sqlitex.Execute(conn, query, nil) - if err != nil { - return fmt.Errorf("failed to create collection index %d: %w", i+1, err) - } - } - - return nil -} - -// GetCollectionCount returns the total number of collections in the database -func (dcm *DatabaseCollectionManager) GetCollectionCount(ctx context.Context) (int, error) { - conn, err := dcm.pool.Take(context.Background()) - if err != nil { - return 0, fmt.Errorf("failed to get connection: %w", err) - } - defer dcm.pool.Put(conn) - - query := "SELECT COUNT(*) FROM collections" - - var count int - err = sqlitex.Execute(conn, query, &sqlitex.ExecOptions{ - ResultFunc: func(stmt *sqlite.Stmt) error { - count = int(stmt.ColumnInt64(0)) - return nil - }, - }) - if err != nil { - return 0, fmt.Errorf("failed to get collection count: %w", err) - } - - return count, nil -} - -// GetPlayerCollectionCount returns the number of collections a player has -func (dcm *DatabaseCollectionManager) GetPlayerCollectionCount(ctx context.Context, characterID int32) (int, error) { - conn, err := dcm.pool.Take(context.Background()) - if err != nil { - return 0, fmt.Errorf("failed to get connection: %w", err) - } - defer dcm.pool.Put(conn) - - query := "SELECT COUNT(*) FROM character_collections WHERE char_id = ?" - - var count int - err = sqlitex.Execute(conn, query, &sqlitex.ExecOptions{ - Args: []any{characterID}, - ResultFunc: func(stmt *sqlite.Stmt) error { - count = int(stmt.ColumnInt64(0)) - return nil - }, - }) - if err != nil { - return 0, fmt.Errorf("failed to get player collection count for character %d: %w", characterID, err) - } - - return count, nil -} - -// GetCompletedCollectionCount returns the number of completed collections for a player -func (dcm *DatabaseCollectionManager) GetCompletedCollectionCount(ctx context.Context, characterID int32) (int, error) { - conn, err := dcm.pool.Take(context.Background()) - if err != nil { - return 0, fmt.Errorf("failed to get connection: %w", err) - } - defer dcm.pool.Put(conn) - - query := "SELECT COUNT(*) FROM character_collections WHERE char_id = ? AND completed = 1" - - var count int - err = sqlitex.Execute(conn, query, &sqlitex.ExecOptions{ - Args: []any{characterID}, - ResultFunc: func(stmt *sqlite.Stmt) error { - count = int(stmt.ColumnInt64(0)) - return nil - }, - }) - if err != nil { - return 0, fmt.Errorf("failed to get completed collection count for character %d: %w", characterID, err) - } - - return count, nil -} - -// DeletePlayerCollection removes a player's collection progress -func (dcm *DatabaseCollectionManager) DeletePlayerCollection(ctx context.Context, characterID, collectionID int32) error { - conn, err := dcm.pool.Take(context.Background()) - if err != nil { - return fmt.Errorf("failed to get connection: %w", err) - } - defer dcm.pool.Put(conn) - - // Use a transaction to ensure both tables are updated atomically - err = sqlitex.Execute(conn, "BEGIN", nil) - if err != nil { - return fmt.Errorf("failed to begin transaction: %w", err) - } - defer sqlitex.Execute(conn, "ROLLBACK", nil) - - // Delete collection items first due to foreign key constraint - err = sqlitex.Execute(conn, "DELETE FROM character_collection_items WHERE char_id = ? AND collection_id = ?", &sqlitex.ExecOptions{ - Args: []any{characterID, collectionID}, - }) - if err != nil { - return fmt.Errorf("failed to delete player collection items: %w", err) - } - - // Delete collection - err = sqlitex.Execute(conn, "DELETE FROM character_collections WHERE char_id = ? AND collection_id = ?", &sqlitex.ExecOptions{ - Args: []any{characterID, collectionID}, - }) - if err != nil { - return fmt.Errorf("failed to delete player collection: %w", err) - } - - return sqlitex.Execute(conn, "COMMIT", nil) -} - -// GetCollectionStatistics returns database-level collection statistics -func (dcm *DatabaseCollectionManager) GetCollectionStatistics(ctx context.Context) (CollectionStatistics, error) { - conn, err := dcm.pool.Take(context.Background()) - if err != nil { - return CollectionStatistics{}, fmt.Errorf("failed to get connection: %w", err) - } - defer dcm.pool.Put(conn) - - var stats CollectionStatistics - - // Total collections - err = sqlitex.Execute(conn, "SELECT COUNT(*) FROM collections", &sqlitex.ExecOptions{ - ResultFunc: func(stmt *sqlite.Stmt) error { - stats.TotalCollections = int(stmt.ColumnInt64(0)) - return nil - }, - }) - if err != nil { - return stats, fmt.Errorf("failed to get total collections: %w", err) - } - - // Total collection items - err = sqlitex.Execute(conn, "SELECT COUNT(*) FROM collection_details", &sqlitex.ExecOptions{ - ResultFunc: func(stmt *sqlite.Stmt) error { - stats.TotalItems = int(stmt.ColumnInt64(0)) - return nil - }, - }) - if err != nil { - return stats, fmt.Errorf("failed to get total items: %w", err) - } - - // Players with collections - err = sqlitex.Execute(conn, "SELECT COUNT(DISTINCT char_id) FROM character_collections", &sqlitex.ExecOptions{ - ResultFunc: func(stmt *sqlite.Stmt) error { - stats.PlayersWithCollections = int(stmt.ColumnInt64(0)) - return nil - }, - }) - if err != nil { - return stats, fmt.Errorf("failed to get players with collections: %w", err) - } - - // Completed collections across all players - err = sqlitex.Execute(conn, "SELECT COUNT(*) FROM character_collections WHERE completed = 1", &sqlitex.ExecOptions{ - ResultFunc: func(stmt *sqlite.Stmt) error { - stats.CompletedCollections = int(stmt.ColumnInt64(0)) - return nil - }, - }) - if err != nil { - return stats, fmt.Errorf("failed to get completed collections: %w", err) - } - - // Active collections (incomplete with at least one item found) across all players - query := `SELECT COUNT(DISTINCT cc.char_id || '-' || cc.collection_id) - FROM character_collections cc - JOIN character_collection_items cci ON cc.char_id = cci.char_id AND cc.collection_id = cci.collection_id - WHERE cc.completed = 0` - err = sqlitex.Execute(conn, query, &sqlitex.ExecOptions{ - ResultFunc: func(stmt *sqlite.Stmt) error { - stats.ActiveCollections = int(stmt.ColumnInt64(0)) - return nil - }, - }) - if err != nil { - return stats, fmt.Errorf("failed to get active collections: %w", err) - } - - // Found items across all players - err = sqlitex.Execute(conn, "SELECT COUNT(*) FROM character_collection_items", &sqlitex.ExecOptions{ - ResultFunc: func(stmt *sqlite.Stmt) error { - stats.FoundItems = int(stmt.ColumnInt64(0)) - return nil - }, - }) - if err != nil { - return stats, fmt.Errorf("failed to get found items: %w", err) - } - - // Total rewards - err = sqlitex.Execute(conn, "SELECT COUNT(*) FROM collection_rewards", &sqlitex.ExecOptions{ - ResultFunc: func(stmt *sqlite.Stmt) error { - stats.TotalRewards = int(stmt.ColumnInt64(0)) - return nil - }, - }) - if err != nil { - return stats, fmt.Errorf("failed to get total rewards: %w", err) - } - - return stats, nil -} \ No newline at end of file diff --git a/internal/collections/doc.go b/internal/collections/doc.go new file mode 100644 index 0000000..03c8aab --- /dev/null +++ b/internal/collections/doc.go @@ -0,0 +1,70 @@ +// Package collections provides collection quest management for the EverQuest II server emulator. +// +// Collections are special quests where players gather specific items scattered throughout +// the world. When all items are found, the collection can be turned in for rewards. +// The system tracks both master collections available to all players and individual +// player progress on those collections. +// +// Basic Usage: +// +// db, _ := database.NewSQLite("collections.db") +// +// // Create new collection +// collection := collections.New(db) +// collection.ID = 1001 +// collection.Name = "Antonian Cameos" +// collection.Category = "Heritage" +// collection.Level = 20 +// collection.Save() +// +// // Load existing collection +// loaded, _ := collections.Load(db, 1001) +// loaded.Delete() +// +// // Add collection items +// collection.CollectionItems = append(collection.CollectionItems, collections.CollectionItem{ +// ItemID: 12345, +// Index: 0, +// Found: collections.ItemNotFound, +// }) +// +// Master List Management: +// +// masterList := collections.NewMasterList() +// masterList.LoadAllCollections(db) +// masterList.AddCollection(collection) +// +// // Find collections +// found := masterList.GetCollection(1001) +// heritage := masterList.FindCollectionsByCategory("Heritage") +// needsItem := masterList.GetCollectionsNeedingItem(12345) +// +// Player Collection Management: +// +// playerList := collections.NewPlayerList(characterID, db) +// playerList.LoadPlayerCollections(masterList) +// +// // Check if player needs an item +// if playerList.NeedsItem(12345) { +// // Award the item to collections that need it +// } +// +// // Find collections ready to turn in +// readyCollections := playerList.GetCollectionsToHandIn() +// +// Collection Features: +// +// // Check collection progress +// progress := collection.GetProgress() // Returns percentage 0-100 +// ready := collection.GetIsReadyToTurnIn() +// +// // Mark items as found +// found := collection.MarkItemFound(itemID) +// +// // Clone collections for players +// playerCollection := masterCollection.Clone() +// +// The package supports both master collections (defined by game data) and player-specific +// collection instances with individual progress tracking. Collections can have multiple +// reward types including items, coin, and experience points. +package collections \ No newline at end of file diff --git a/internal/collections/interfaces.go b/internal/collections/interfaces.go deleted file mode 100644 index a31e916..0000000 --- a/internal/collections/interfaces.go +++ /dev/null @@ -1,176 +0,0 @@ -package collections - -import "context" - -// CollectionDatabase defines database operations for collections -type CollectionDatabase interface { - // LoadCollections retrieves all collections from database - LoadCollections(ctx context.Context) ([]CollectionData, error) - - // LoadCollectionItems retrieves items for a specific collection - LoadCollectionItems(ctx context.Context, collectionID int32) ([]CollectionItem, error) - - // LoadCollectionRewards retrieves rewards for a specific collection - LoadCollectionRewards(ctx context.Context, collectionID int32) ([]CollectionRewardData, error) - - // LoadPlayerCollections retrieves player's collection progress - LoadPlayerCollections(ctx context.Context, characterID int32) ([]PlayerCollectionData, error) - - // LoadPlayerCollectionItems retrieves player's found collection items - LoadPlayerCollectionItems(ctx context.Context, characterID, collectionID int32) ([]int32, error) - - // SavePlayerCollection saves player collection completion status - SavePlayerCollection(ctx context.Context, characterID, collectionID int32, completed bool) error - - // SavePlayerCollectionItem saves a found collection item - SavePlayerCollectionItem(ctx context.Context, characterID, collectionID, itemID int32) error - - // SavePlayerCollections saves all modified player collections - SavePlayerCollections(ctx context.Context, characterID int32, collections []*Collection) error -} - -// ItemLookup provides item information for collections -type ItemLookup interface { - // GetItem retrieves an item by ID - GetItem(itemID int32) (ItemInfo, error) - - // ItemExists checks if an item exists - ItemExists(itemID int32) bool - - // GetItemName returns the name of an item - GetItemName(itemID int32) string -} - -// PlayerManager provides player information for collections -type PlayerManager interface { - // GetPlayerInfo retrieves basic player information - GetPlayerInfo(characterID int32) (PlayerInfo, error) - - // IsPlayerOnline checks if a player is currently online - IsPlayerOnline(characterID int32) bool - - // GetPlayerLevel returns player's current level - GetPlayerLevel(characterID int32) int8 -} - -// ClientManager handles client communication for collections -type ClientManager interface { - // SendCollectionUpdate notifies client of collection changes - SendCollectionUpdate(characterID int32, collection *Collection) error - - // SendCollectionComplete notifies client of collection completion - SendCollectionComplete(characterID int32, collection *Collection) error - - // SendCollectionList sends available collections to client - SendCollectionList(characterID int32, collections []CollectionInfo) error - - // SendCollectionProgress sends collection progress to client - SendCollectionProgress(characterID int32, progress []CollectionProgress) error -} - -// ItemInfo contains item information needed for collections -type ItemInfo struct { - ID int32 `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - Icon int32 `json:"icon"` - Level int8 `json:"level"` - Rarity int8 `json:"rarity"` -} - -// PlayerInfo contains basic player information -type PlayerInfo struct { - CharacterID int32 `json:"character_id"` - CharacterName string `json:"character_name"` - Level int8 `json:"level"` - Race int32 `json:"race"` - Class int32 `json:"class"` - IsOnline bool `json:"is_online"` -} - -// CollectionAware interface for entities that can participate in collections -type CollectionAware interface { - GetCharacterID() int32 - GetLevel() int8 - HasItem(itemID int32) bool - GetCollectionList() *PlayerCollectionList -} - -// EntityCollectionAdapter adapts entities to work with collection system -type EntityCollectionAdapter struct { - entity interface { - GetID() int32 - // Add other entity methods as needed - } - playerManager PlayerManager -} - -// GetCharacterID returns the character ID from the adapted entity -func (a *EntityCollectionAdapter) GetCharacterID() int32 { - return a.entity.GetID() -} - -// GetLevel returns the character level from player manager -func (a *EntityCollectionAdapter) GetLevel() int8 { - if info, err := a.playerManager.GetPlayerInfo(a.entity.GetID()); err == nil { - return info.Level - } - return 0 -} - -// HasItem checks if the character has a specific item (placeholder) -func (a *EntityCollectionAdapter) HasItem(itemID int32) bool { - // TODO: Implement item checking through entity system - return false -} - -// GetCollectionList placeholder for getting player collection list -func (a *EntityCollectionAdapter) GetCollectionList() *PlayerCollectionList { - // TODO: Implement collection list retrieval - return nil -} - -// RewardProvider handles collection reward distribution -type RewardProvider interface { - // GiveItem gives an item to a player - GiveItem(characterID int32, itemID int32, quantity int8) error - - // GiveCoin gives coins to a player - GiveCoin(characterID int32, amount int64) error - - // GiveXP gives experience points to a player - GiveXP(characterID int32, amount int64) error - - // ValidateRewards checks if rewards can be given - ValidateRewards(characterID int32, rewards []CollectionRewardItem, coin, xp int64) error -} - -// CollectionEventHandler handles collection-related events -type CollectionEventHandler interface { - // OnCollectionStarted called when player starts a collection - OnCollectionStarted(characterID, collectionID int32) - - // OnItemFound called when player finds a collection item - OnItemFound(characterID, collectionID, itemID int32) - - // OnCollectionCompleted called when player completes a collection - OnCollectionCompleted(characterID, collectionID int32) - - // OnRewardClaimed called when player claims collection rewards - OnRewardClaimed(characterID, collectionID int32, rewards []CollectionRewardItem, coin, xp int64) -} - -// LogHandler provides logging functionality -type LogHandler interface { - // LogDebug logs debug messages - LogDebug(category, message string, args ...any) - - // LogInfo logs informational messages - LogInfo(category, message string, args ...any) - - // LogError logs error messages - LogError(category, message string, args ...any) - - // LogWarning logs warning messages - LogWarning(category, message string, args ...any) -} diff --git a/internal/collections/manager.go b/internal/collections/manager.go deleted file mode 100644 index d0515f8..0000000 --- a/internal/collections/manager.go +++ /dev/null @@ -1,380 +0,0 @@ -package collections - -import ( - "context" - "fmt" - "sync" -) - -// NewCollectionManager creates a new collection manager instance -func NewCollectionManager(database CollectionDatabase, itemLookup ItemLookup) *CollectionManager { - return &CollectionManager{ - masterList: NewMasterCollectionList(database), - database: database, - itemLookup: itemLookup, - } -} - -// Initialize initializes the collection system by loading all collections -func (cm *CollectionManager) Initialize(ctx context.Context) error { - return cm.masterList.Initialize(ctx, cm.itemLookup) -} - -// GetMasterList returns the master collection list -func (cm *CollectionManager) GetMasterList() *MasterCollectionList { - return cm.masterList -} - -// CreatePlayerCollectionList creates a new player collection list -func (cm *CollectionManager) CreatePlayerCollectionList(characterID int32) *PlayerCollectionList { - return NewPlayerCollectionList(characterID, cm.database) -} - -// GetCollection returns a collection by ID from the master list -func (cm *CollectionManager) GetCollection(collectionID int32) *Collection { - return cm.masterList.GetCollection(collectionID) -} - -// GetCollectionCopy returns a copy of a collection by ID -func (cm *CollectionManager) GetCollectionCopy(collectionID int32) *Collection { - return cm.masterList.GetCollectionCopy(collectionID) -} - -// ProcessItemFound processes when a player finds an item across all collections -func (cm *CollectionManager) ProcessItemFound(playerList *PlayerCollectionList, itemID int32) ([]*Collection, error) { - if playerList == nil { - return nil, fmt.Errorf("player collection list is nil") - } - - return playerList.ProcessItemFound(itemID, cm.masterList) -} - -// CompleteCollection processes collection completion for a player -func (cm *CollectionManager) CompleteCollection(playerList *PlayerCollectionList, collectionID int32, rewardProvider RewardProvider) error { - if playerList == nil { - return fmt.Errorf("player collection list is nil") - } - - collection := playerList.GetCollection(collectionID) - if collection == nil { - return fmt.Errorf("collection %d not found in player list", collectionID) - } - - if !collection.GetIsReadyToTurnIn() { - return fmt.Errorf("collection %d is not ready to complete", collectionID) - } - - // Give rewards if provider is available - if rewardProvider != nil { - characterID := playerList.GetCharacterID() - - // Give coin reward - if coin := collection.GetRewardCoin(); coin > 0 { - if err := rewardProvider.GiveCoin(characterID, coin); err != nil { - return fmt.Errorf("failed to give coin reward: %w", err) - } - } - - // Give XP reward - if xp := collection.GetRewardXP(); xp > 0 { - if err := rewardProvider.GiveXP(characterID, xp); err != nil { - return fmt.Errorf("failed to give XP reward: %w", err) - } - } - - // Give item rewards - for _, reward := range collection.GetRewardItems() { - if err := rewardProvider.GiveItem(characterID, reward.ItemID, reward.Quantity); err != nil { - return fmt.Errorf("failed to give item reward %d: %w", reward.ItemID, err) - } - } - } - - // Mark collection as completed - return playerList.CompleteCollection(collectionID) -} - -// GetAvailableCollections returns collections available to a player based on level -func (cm *CollectionManager) GetAvailableCollections(playerLevel int8) []*Collection { - return cm.masterList.GetCollectionsByLevel(0, playerLevel) -} - -// GetCollectionsByCategory returns collections in a specific category -func (cm *CollectionManager) GetCollectionsByCategory(category string) []*Collection { - return cm.masterList.GetCollectionsByCategory(category) -} - -// GetAllCategories returns all collection categories -func (cm *CollectionManager) GetAllCategories() []string { - return cm.masterList.GetCategories() -} - -// SearchCollections searches for collections by name -func (cm *CollectionManager) SearchCollections(searchTerm string) []*Collection { - return cm.masterList.FindCollectionsByName(searchTerm) -} - -// ValidateSystemIntegrity validates the integrity of the collection system -func (cm *CollectionManager) ValidateSystemIntegrity() []error { - return cm.masterList.ValidateIntegrity(cm.itemLookup) -} - -// GetSystemStatistics returns overall collection system statistics -func (cm *CollectionManager) GetSystemStatistics() CollectionStatistics { - return cm.masterList.GetStatistics() -} - -// CollectionService provides high-level collection system services -type CollectionService struct { - manager *CollectionManager - playerLists map[int32]*PlayerCollectionList - mu sync.RWMutex - clientManager ClientManager - eventHandler CollectionEventHandler - logger LogHandler -} - -// NewCollectionService creates a new collection service -func NewCollectionService(database CollectionDatabase, itemLookup ItemLookup, clientManager ClientManager) *CollectionService { - return &CollectionService{ - manager: NewCollectionManager(database, itemLookup), - playerLists: make(map[int32]*PlayerCollectionList), - clientManager: clientManager, - } -} - -// SetEventHandler sets the collection event handler -func (cs *CollectionService) SetEventHandler(handler CollectionEventHandler) { - cs.eventHandler = handler -} - -// SetLogger sets the logger for the service -func (cs *CollectionService) SetLogger(logger LogHandler) { - cs.logger = logger -} - -// Initialize initializes the collection service -func (cs *CollectionService) Initialize(ctx context.Context) error { - return cs.manager.Initialize(ctx) -} - -// LoadPlayerCollections loads collections for a specific player -func (cs *CollectionService) LoadPlayerCollections(ctx context.Context, characterID int32) error { - cs.mu.Lock() - defer cs.mu.Unlock() - - playerList := cs.manager.CreatePlayerCollectionList(characterID) - if err := playerList.Initialize(ctx, cs.manager.GetMasterList()); err != nil { - return fmt.Errorf("failed to initialize player collections: %w", err) - } - - cs.playerLists[characterID] = playerList - - if cs.logger != nil { - cs.logger.LogDebug("collections", "Loaded %d collections for character %d", - playerList.Size(), characterID) - } - - return nil -} - -// UnloadPlayerCollections unloads collections for a player (when they log out) -func (cs *CollectionService) UnloadPlayerCollections(ctx context.Context, characterID int32) error { - cs.mu.Lock() - defer cs.mu.Unlock() - - playerList, exists := cs.playerLists[characterID] - if !exists { - return nil // Already unloaded - } - - // Save any pending changes - if err := playerList.SaveCollections(ctx); err != nil { - if cs.logger != nil { - cs.logger.LogError("collections", "Failed to save collections for character %d: %v", characterID, err) - } - return fmt.Errorf("failed to save collections: %w", err) - } - - delete(cs.playerLists, characterID) - - if cs.logger != nil { - cs.logger.LogDebug("collections", "Unloaded collections for character %d", characterID) - } - - return nil -} - -// ProcessItemFound processes when a player finds an item -func (cs *CollectionService) ProcessItemFound(characterID, itemID int32) error { - cs.mu.RLock() - playerList, exists := cs.playerLists[characterID] - cs.mu.RUnlock() - - if !exists { - return fmt.Errorf("player collections not loaded for character %d", characterID) - } - - updatedCollections, err := cs.manager.ProcessItemFound(playerList, itemID) - if err != nil { - return fmt.Errorf("failed to process found item: %w", err) - } - - // Notify client and event handler of updates - for _, collection := range updatedCollections { - if cs.clientManager != nil { - cs.clientManager.SendCollectionUpdate(characterID, collection) - } - - if cs.eventHandler != nil { - cs.eventHandler.OnItemFound(characterID, collection.GetID(), itemID) - } - - if cs.logger != nil { - cs.logger.LogDebug("collections", "Character %d found item %d for collection %d (%s)", - characterID, itemID, collection.GetID(), collection.GetName()) - } - } - - return nil -} - -// CompleteCollection processes collection completion -func (cs *CollectionService) CompleteCollection(characterID, collectionID int32, rewardProvider RewardProvider) error { - cs.mu.RLock() - playerList, exists := cs.playerLists[characterID] - cs.mu.RUnlock() - - if !exists { - return fmt.Errorf("player collections not loaded for character %d", characterID) - } - - collection := playerList.GetCollection(collectionID) - if collection == nil { - return fmt.Errorf("collection %d not found for character %d", collectionID, characterID) - } - - // Complete the collection - if err := cs.manager.CompleteCollection(playerList, collectionID, rewardProvider); err != nil { - return fmt.Errorf("failed to complete collection: %w", err) - } - - // Notify client and event handler - if cs.clientManager != nil { - cs.clientManager.SendCollectionComplete(characterID, collection) - } - - if cs.eventHandler != nil { - rewards := collection.GetRewardItems() - selectableRewards := collection.GetSelectableRewardItems() - allRewards := append(rewards, selectableRewards...) - cs.eventHandler.OnCollectionCompleted(characterID, collectionID) - cs.eventHandler.OnRewardClaimed(characterID, collectionID, allRewards, - collection.GetRewardCoin(), collection.GetRewardXP()) - } - - if cs.logger != nil { - cs.logger.LogInfo("collections", "Character %d completed collection %d (%s)", - characterID, collectionID, collection.GetName()) - } - - return nil -} - -// GetPlayerCollections returns all collections for a player -func (cs *CollectionService) GetPlayerCollections(characterID int32) ([]*Collection, error) { - cs.mu.RLock() - playerList, exists := cs.playerLists[characterID] - cs.mu.RUnlock() - - if !exists { - return nil, fmt.Errorf("player collections not loaded for character %d", characterID) - } - - return playerList.GetAllCollections(), nil -} - -// GetPlayerCollectionProgress returns detailed progress for all player collections -func (cs *CollectionService) GetPlayerCollectionProgress(characterID int32) ([]CollectionProgress, error) { - cs.mu.RLock() - playerList, exists := cs.playerLists[characterID] - cs.mu.RUnlock() - - if !exists { - return nil, fmt.Errorf("player collections not loaded for character %d", characterID) - } - - return playerList.GetCollectionProgress(), nil -} - -// SendCollectionList sends available collections to a player -func (cs *CollectionService) SendCollectionList(characterID int32, playerLevel int8) error { - if cs.clientManager == nil { - return fmt.Errorf("client manager not available") - } - - collections := cs.manager.GetAvailableCollections(playerLevel) - collectionInfos := make([]CollectionInfo, len(collections)) - - for i, collection := range collections { - collectionInfos[i] = collection.GetCollectionInfo() - } - - return cs.clientManager.SendCollectionList(characterID, collectionInfos) -} - -// SaveAllPlayerCollections saves all loaded player collections -func (cs *CollectionService) SaveAllPlayerCollections(ctx context.Context) error { - cs.mu.RLock() - playerLists := make(map[int32]*PlayerCollectionList) - for characterID, playerList := range cs.playerLists { - playerLists[characterID] = playerList - } - cs.mu.RUnlock() - - var saveErrors []error - for characterID, playerList := range playerLists { - if err := playerList.SaveCollections(ctx); err != nil { - saveErrors = append(saveErrors, fmt.Errorf("character %d: %w", characterID, err)) - if cs.logger != nil { - cs.logger.LogError("collections", "Failed to save collections for character %d: %v", characterID, err) - } - } - } - - if len(saveErrors) > 0 { - return fmt.Errorf("failed to save some player collections: %v", saveErrors) - } - - return nil -} - -// GetLoadedPlayerCount returns the number of players with loaded collections -func (cs *CollectionService) GetLoadedPlayerCount() int { - cs.mu.RLock() - defer cs.mu.RUnlock() - return len(cs.playerLists) -} - -// IsPlayerLoaded checks if a player's collections are loaded -func (cs *CollectionService) IsPlayerLoaded(characterID int32) bool { - cs.mu.RLock() - defer cs.mu.RUnlock() - _, exists := cs.playerLists[characterID] - return exists -} - -// GetMasterCollection returns a collection from the master list -func (cs *CollectionService) GetMasterCollection(collectionID int32) *Collection { - return cs.manager.GetCollection(collectionID) -} - -// GetAllCategories returns all collection categories -func (cs *CollectionService) GetAllCategories() []string { - return cs.manager.GetAllCategories() -} - -// SearchCollections searches for collections by name -func (cs *CollectionService) SearchCollections(searchTerm string) []*Collection { - return cs.manager.SearchCollections(searchTerm) -} diff --git a/internal/collections/master.go b/internal/collections/master.go new file mode 100644 index 0000000..e324419 --- /dev/null +++ b/internal/collections/master.go @@ -0,0 +1,327 @@ +package collections + +import ( + "fmt" + + "eq2emu/internal/common" + "eq2emu/internal/database" +) + +// MasterList manages a collection of collections using the generic MasterList base +type MasterList struct { + *common.MasterList[int32, *Collection] +} + +// NewMasterList creates a new collection master list +func NewMasterList() *MasterList { + return &MasterList{ + MasterList: common.NewMasterList[int32, *Collection](), + } +} + +// AddCollection adds a collection to the master list +func (ml *MasterList) AddCollection(collection *Collection) bool { + return ml.Add(collection) +} + +// GetCollection retrieves a collection by ID +func (ml *MasterList) GetCollection(id int32) *Collection { + return ml.Get(id) +} + +// GetCollectionSafe retrieves a collection by ID with existence check +func (ml *MasterList) GetCollectionSafe(id int32) (*Collection, bool) { + return ml.GetSafe(id) +} + +// HasCollection checks if a collection exists by ID +func (ml *MasterList) HasCollection(id int32) bool { + return ml.Exists(id) +} + +// RemoveCollection removes a collection by ID +func (ml *MasterList) RemoveCollection(id int32) bool { + return ml.Remove(id) +} + +// GetAllCollections returns all collections as a map +func (ml *MasterList) GetAllCollections() map[int32]*Collection { + return ml.GetAll() +} + +// GetAllCollectionsList returns all collections as a slice +func (ml *MasterList) GetAllCollectionsList() []*Collection { + return ml.GetAllSlice() +} + +// GetCollectionCount returns the number of collections +func (ml *MasterList) GetCollectionCount() int { + return ml.Size() +} + +// ClearCollections removes all collections from the list +func (ml *MasterList) ClearCollections() { + ml.Clear() +} + +// NeedsItem checks if any collection needs the specified item +func (ml *MasterList) NeedsItem(itemID int32) bool { + for _, collection := range ml.GetAll() { + if collection.NeedsItem(itemID) { + return true + } + } + return false +} + +// FindCollectionsByCategory finds collections in a specific category +func (ml *MasterList) FindCollectionsByCategory(category string) []*Collection { + return ml.Filter(func(collection *Collection) bool { + return collection.GetCategory() == category + }) +} + +// FindCollectionsByLevel finds collections for a specific level range +func (ml *MasterList) FindCollectionsByLevel(minLevel, maxLevel int8) []*Collection { + return ml.Filter(func(collection *Collection) bool { + level := collection.GetLevel() + return level >= minLevel && level <= maxLevel + }) +} + +// GetCollectionsNeedingItem returns all collections that need a specific item +func (ml *MasterList) GetCollectionsNeedingItem(itemID int32) []*Collection { + return ml.Filter(func(collection *Collection) bool { + return collection.NeedsItem(itemID) + }) +} + +// GetCategories returns all unique collection categories +func (ml *MasterList) GetCategories() []string { + categoryMap := make(map[string]bool) + ml.ForEach(func(id int32, collection *Collection) { + categoryMap[collection.GetCategory()] = true + }) + + categories := make([]string, 0, len(categoryMap)) + for category := range categoryMap { + categories = append(categories, category) + } + return categories +} + +// ValidateCollections checks all collections for consistency +func (ml *MasterList) ValidateCollections() []string { + var issues []string + + ml.ForEach(func(id int32, collection *Collection) { + if collection == nil { + issues = append(issues, fmt.Sprintf("Collection ID %d is nil", id)) + return + } + + if collection.GetID() != id { + issues = append(issues, fmt.Sprintf("Collection ID mismatch: map key %d != collection ID %d", id, collection.GetID())) + } + + if len(collection.GetName()) == 0 { + issues = append(issues, fmt.Sprintf("Collection ID %d has empty name", id)) + } + + if len(collection.GetCategory()) == 0 { + issues = append(issues, fmt.Sprintf("Collection ID %d has empty category", id)) + } + + if collection.GetLevel() < 0 { + issues = append(issues, fmt.Sprintf("Collection ID %d has negative level: %d", id, collection.GetLevel())) + } + + if len(collection.CollectionItems) == 0 { + issues = append(issues, fmt.Sprintf("Collection ID %d has no collection items", id)) + } + + // Check for duplicate item indices + indexMap := make(map[int8]bool) + for _, item := range collection.CollectionItems { + if indexMap[item.Index] { + issues = append(issues, fmt.Sprintf("Collection ID %d has duplicate item index: %d", id, item.Index)) + } + indexMap[item.Index] = true + } + }) + + return issues +} + +// IsValid returns true if all collections are valid +func (ml *MasterList) IsValid() bool { + issues := ml.ValidateCollections() + return len(issues) == 0 +} + +// GetStatistics returns statistics about the collection collection +func (ml *MasterList) GetStatistics() map[string]any { + stats := make(map[string]any) + stats["total_collections"] = ml.Size() + + if ml.IsEmpty() { + return stats + } + + // Count by category + categoryCounts := make(map[string]int) + var totalItems, totalRewards int + var minLevel, maxLevel int8 = 127, 0 + var minID, maxID int32 + first := true + + ml.ForEach(func(id int32, collection *Collection) { + categoryCounts[collection.GetCategory()]++ + totalItems += len(collection.CollectionItems) + totalRewards += len(collection.RewardItems) + len(collection.SelectableRewardItems) + + level := collection.GetLevel() + if level < minLevel { + minLevel = level + } + if level > maxLevel { + maxLevel = level + } + + if first { + minID = id + maxID = id + first = false + } else { + if id < minID { + minID = id + } + if id > maxID { + maxID = id + } + } + }) + + stats["collections_by_category"] = categoryCounts + stats["total_collection_items"] = totalItems + stats["total_rewards"] = totalRewards + stats["min_level"] = minLevel + stats["max_level"] = maxLevel + stats["min_id"] = minID + stats["max_id"] = maxID + stats["id_range"] = maxID - minID + stats["average_items_per_collection"] = float64(totalItems) / float64(ml.Size()) + + return stats +} + +// LoadAllCollections loads all collections from the database into the master list +func (ml *MasterList) LoadAllCollections(db *database.Database) error { + if db == nil { + return fmt.Errorf("database connection is nil") + } + + // Clear existing collections + ml.Clear() + + query := `SELECT id, collection_name, collection_category, level FROM collections ORDER BY id` + rows, err := db.Query(query) + if err != nil { + return fmt.Errorf("failed to query collections: %w", err) + } + defer rows.Close() + + count := 0 + for rows.Next() { + collection := &Collection{ + db: db, + isNew: false, + CollectionItems: make([]CollectionItem, 0), + RewardItems: make([]CollectionRewardItem, 0), + SelectableRewardItems: make([]CollectionRewardItem, 0), + } + + err := rows.Scan(&collection.ID, &collection.Name, &collection.Category, &collection.Level) + if err != nil { + return fmt.Errorf("failed to scan collection: %w", err) + } + + // Load collection items for this collection + itemQuery := `SELECT item_id, item_index, found FROM collection_items WHERE collection_id = ? ORDER BY item_index` + itemRows, err := db.Query(itemQuery, collection.ID) + if err != nil { + return fmt.Errorf("failed to load collection items for collection %d: %w", collection.ID, err) + } + + for itemRows.Next() { + var item CollectionItem + if err := itemRows.Scan(&item.ItemID, &item.Index, &item.Found); err != nil { + itemRows.Close() + return fmt.Errorf("failed to scan collection item: %w", err) + } + collection.CollectionItems = append(collection.CollectionItems, item) + } + itemRows.Close() + + // Load rewards for this collection + rewardQuery := `SELECT reward_type, reward_value, reward_quantity FROM collection_rewards WHERE collection_id = ?` + rewardRows, err := db.Query(rewardQuery, collection.ID) + if err != nil { + return fmt.Errorf("failed to load rewards for collection %d: %w", collection.ID, err) + } + + for rewardRows.Next() { + var rewardType, rewardValue string + var quantity int8 + + if err := rewardRows.Scan(&rewardType, &rewardValue, &quantity); err != nil { + rewardRows.Close() + return fmt.Errorf("failed to scan collection reward: %w", err) + } + + switch rewardType { + case "coin": + fmt.Sscanf(rewardValue, "%d", &collection.RewardCoin) + case "xp": + fmt.Sscanf(rewardValue, "%d", &collection.RewardXP) + case "item": + var itemID int32 + fmt.Sscanf(rewardValue, "%d", &itemID) + collection.RewardItems = append(collection.RewardItems, CollectionRewardItem{ + ItemID: itemID, + Quantity: quantity, + }) + case "selectable_item": + var itemID int32 + fmt.Sscanf(rewardValue, "%d", &itemID) + collection.SelectableRewardItems = append(collection.SelectableRewardItems, CollectionRewardItem{ + ItemID: itemID, + Quantity: quantity, + }) + } + } + rewardRows.Close() + + if !ml.AddCollection(collection) { + return fmt.Errorf("failed to add collection %d to master list", collection.ID) + } + + count++ + } + + if err := rows.Err(); err != nil { + return fmt.Errorf("error iterating collection rows: %w", err) + } + + return nil +} + +// LoadAllCollectionsFromDatabase is a convenience function that creates a master list and loads all collections +func LoadAllCollectionsFromDatabase(db *database.Database) (*MasterList, error) { + masterList := NewMasterList() + err := masterList.LoadAllCollections(db) + if err != nil { + return nil, err + } + return masterList, nil +} \ No newline at end of file diff --git a/internal/collections/master_list.go b/internal/collections/master_list.go deleted file mode 100644 index e369315..0000000 --- a/internal/collections/master_list.go +++ /dev/null @@ -1,336 +0,0 @@ -package collections - -import ( - "context" - "fmt" - "sort" - "strings" -) - -// NewMasterCollectionList creates a new master collection list -func NewMasterCollectionList(database CollectionDatabase) *MasterCollectionList { - return &MasterCollectionList{ - collections: make(map[int32]*Collection), - database: database, - } -} - -// Initialize loads all collections from the database -func (mcl *MasterCollectionList) Initialize(ctx context.Context, itemLookup ItemLookup) error { - mcl.mu.Lock() - defer mcl.mu.Unlock() - - // Load collection data - collectionData, err := mcl.database.LoadCollections(ctx) - if err != nil { - return fmt.Errorf("failed to load collections: %w", err) - } - - totalItems := 0 - totalRewards := 0 - - for _, data := range collectionData { - collection := NewCollection() - collection.SetID(data.ID) - collection.SetName(data.Name) - collection.SetCategory(data.Category) - collection.SetLevel(data.Level) - - // Load collection items - items, err := mcl.database.LoadCollectionItems(ctx, data.ID) - if err != nil { - return fmt.Errorf("failed to load items for collection %d: %w", data.ID, err) - } - - for _, item := range items { - // Validate item exists - if itemLookup != nil && !itemLookup.ItemExists(item.ItemID) { - continue // Skip non-existent items - } - collection.AddCollectionItem(item) - totalItems++ - } - - // Load collection rewards - rewards, err := mcl.database.LoadCollectionRewards(ctx, data.ID) - if err != nil { - return fmt.Errorf("failed to load rewards for collection %d: %w", data.ID, err) - } - - if err := collection.LoadFromRewardData(rewards); err != nil { - return fmt.Errorf("failed to load reward data for collection %d: %w", data.ID, err) - } - totalRewards += len(rewards) - - // Validate collection before adding - if err := collection.Validate(); err != nil { - return fmt.Errorf("invalid collection %d (%s): %w", data.ID, data.Name, err) - } - - if !mcl.addCollectionNoLock(collection) { - return fmt.Errorf("duplicate collection ID: %d", data.ID) - } - } - - return nil -} - -// AddCollection adds a collection to the master list -func (mcl *MasterCollectionList) AddCollection(collection *Collection) bool { - mcl.mu.Lock() - defer mcl.mu.Unlock() - return mcl.addCollectionNoLock(collection) -} - -// addCollectionNoLock adds a collection without acquiring the lock -func (mcl *MasterCollectionList) addCollectionNoLock(collection *Collection) bool { - if collection == nil { - return false - } - - id := collection.GetID() - if _, exists := mcl.collections[id]; exists { - return false - } - - mcl.collections[id] = collection - return true -} - -// GetCollection retrieves a collection by ID -func (mcl *MasterCollectionList) GetCollection(collectionID int32) *Collection { - mcl.mu.RLock() - defer mcl.mu.RUnlock() - return mcl.collections[collectionID] -} - -// GetCollectionCopy retrieves a copy of a collection by ID -func (mcl *MasterCollectionList) GetCollectionCopy(collectionID int32) *Collection { - mcl.mu.RLock() - defer mcl.mu.RUnlock() - - if collection, exists := mcl.collections[collectionID]; exists { - return NewCollectionFromData(collection) - } - return nil -} - -// ClearCollections removes all collections -func (mcl *MasterCollectionList) ClearCollections() { - mcl.mu.Lock() - defer mcl.mu.Unlock() - mcl.collections = make(map[int32]*Collection) -} - -// Size returns the number of collections -func (mcl *MasterCollectionList) Size() int { - mcl.mu.RLock() - defer mcl.mu.RUnlock() - return len(mcl.collections) -} - -// NeedsItem checks if any collection needs a specific item -func (mcl *MasterCollectionList) NeedsItem(itemID int32) bool { - mcl.mu.RLock() - defer mcl.mu.RUnlock() - - for _, collection := range mcl.collections { - if collection.NeedsItem(itemID) { - return true - } - } - - return false -} - -// GetCollectionsByCategory returns collections in a specific category -func (mcl *MasterCollectionList) GetCollectionsByCategory(category string) []*Collection { - mcl.mu.RLock() - defer mcl.mu.RUnlock() - - var result []*Collection - for _, collection := range mcl.collections { - if collection.GetCategory() == category { - result = append(result, collection) - } - } - - return result -} - -// GetCollectionsByLevel returns collections for a specific level range -func (mcl *MasterCollectionList) GetCollectionsByLevel(minLevel, maxLevel int8) []*Collection { - mcl.mu.RLock() - defer mcl.mu.RUnlock() - - var result []*Collection - for _, collection := range mcl.collections { - level := collection.GetLevel() - if level >= minLevel && level <= maxLevel { - result = append(result, collection) - } - } - - return result -} - -// GetAllCollections returns all collections -func (mcl *MasterCollectionList) GetAllCollections() []*Collection { - mcl.mu.RLock() - defer mcl.mu.RUnlock() - - result := make([]*Collection, 0, len(mcl.collections)) - for _, collection := range mcl.collections { - result = append(result, collection) - } - - return result -} - -// GetCollectionIDs returns all collection IDs -func (mcl *MasterCollectionList) GetCollectionIDs() []int32 { - mcl.mu.RLock() - defer mcl.mu.RUnlock() - - ids := make([]int32, 0, len(mcl.collections)) - for id := range mcl.collections { - ids = append(ids, id) - } - - sort.Slice(ids, func(i, j int) bool { - return ids[i] < ids[j] - }) - - return ids -} - -// GetCategories returns all unique categories -func (mcl *MasterCollectionList) GetCategories() []string { - mcl.mu.RLock() - defer mcl.mu.RUnlock() - - categoryMap := make(map[string]bool) - for _, collection := range mcl.collections { - category := collection.GetCategory() - if category != "" { - categoryMap[category] = true - } - } - - categories := make([]string, 0, len(categoryMap)) - for category := range categoryMap { - categories = append(categories, category) - } - - sort.Strings(categories) - return categories -} - -// GetCollectionsRequiringItem returns collections that need a specific item -func (mcl *MasterCollectionList) GetCollectionsRequiringItem(itemID int32) []*Collection { - mcl.mu.RLock() - defer mcl.mu.RUnlock() - - var result []*Collection - for _, collection := range mcl.collections { - if collection.NeedsItem(itemID) { - result = append(result, collection) - } - } - - return result -} - -// GetStatistics returns master collection list statistics -func (mcl *MasterCollectionList) GetStatistics() CollectionStatistics { - mcl.mu.RLock() - defer mcl.mu.RUnlock() - - stats := CollectionStatistics{ - TotalCollections: len(mcl.collections), - } - - for _, collection := range mcl.collections { - if collection.GetCompleted() { - stats.CompletedCollections++ - } - if collection.GetTotalItemsCount() > 0 { - stats.ActiveCollections++ - } - stats.TotalItems += collection.GetTotalItemsCount() - stats.FoundItems += collection.GetFoundItemsCount() - stats.TotalRewards += len(collection.GetRewardItems()) + len(collection.GetSelectableRewardItems()) - if collection.GetRewardCoin() > 0 { - stats.TotalRewards++ - } - if collection.GetRewardXP() > 0 { - stats.TotalRewards++ - } - } - - return stats -} - -// ValidateIntegrity checks the integrity of all collections -func (mcl *MasterCollectionList) ValidateIntegrity(itemLookup ItemLookup) []error { - mcl.mu.RLock() - defer mcl.mu.RUnlock() - - var errors []error - - for _, collection := range mcl.collections { - if err := collection.Validate(); err != nil { - errors = append(errors, fmt.Errorf("collection %d (%s): %w", - collection.GetID(), collection.GetName(), err)) - } - - // Check if all required items exist - if itemLookup != nil { - for _, item := range collection.GetCollectionItems() { - if !itemLookup.ItemExists(item.ItemID) { - errors = append(errors, fmt.Errorf("collection %d (%s) references non-existent item %d", - collection.GetID(), collection.GetName(), item.ItemID)) - } - } - - // Check reward items - for _, item := range collection.GetRewardItems() { - if !itemLookup.ItemExists(item.ItemID) { - errors = append(errors, fmt.Errorf("collection %d (%s) has non-existent reward item %d", - collection.GetID(), collection.GetName(), item.ItemID)) - } - } - - for _, item := range collection.GetSelectableRewardItems() { - if !itemLookup.ItemExists(item.ItemID) { - errors = append(errors, fmt.Errorf("collection %d (%s) has non-existent selectable reward item %d", - collection.GetID(), collection.GetName(), item.ItemID)) - } - } - } - } - - return errors -} - -// FindCollectionsByName searches for collections by name (case-insensitive) -func (mcl *MasterCollectionList) FindCollectionsByName(searchTerm string) []*Collection { - mcl.mu.RLock() - defer mcl.mu.RUnlock() - - var result []*Collection - searchLower := strings.ToLower(searchTerm) - - for _, collection := range mcl.collections { - if strings.Contains(strings.ToLower(collection.GetName()), searchLower) { - result = append(result, collection) - } - } - - // Sort by name - sort.Slice(result, func(i, j int) bool { - return result[i].GetName() < result[j].GetName() - }) - - return result -} diff --git a/internal/collections/master_test.go b/internal/collections/master_test.go new file mode 100644 index 0000000..7cc116b --- /dev/null +++ b/internal/collections/master_test.go @@ -0,0 +1,365 @@ +package collections + +import ( + "testing" + + "eq2emu/internal/database" +) + +func TestNewMasterList(t *testing.T) { + masterList := NewMasterList() + + if masterList == nil { + t.Fatal("NewMasterList returned nil") + } + + if masterList.GetCollectionCount() != 0 { + t.Errorf("Expected count 0, got %d", masterList.GetCollectionCount()) + } +} + +func TestMasterListBasicOperations(t *testing.T) { + db, _ := database.NewSQLite("file::memory:?mode=memory&cache=shared") + defer db.Close() + + masterList := NewMasterList() + + // Create test collections + collection1 := NewWithData(1001, "Heritage Collection", "Heritage", 20, db) + collection2 := NewWithData(1002, "Treasured Collection", "Treasured", 30, db) + + // Test adding + if !masterList.AddCollection(collection1) { + t.Error("Should successfully add collection1") + } + + if !masterList.AddCollection(collection2) { + t.Error("Should successfully add collection2") + } + + // Test duplicate add (should fail) + if masterList.AddCollection(collection1) { + t.Error("Should not add duplicate collection") + } + + if masterList.GetCollectionCount() != 2 { + t.Errorf("Expected count 2, got %d", masterList.GetCollectionCount()) + } + + // Test retrieving + retrieved := masterList.GetCollection(1001) + if retrieved == nil { + t.Error("Should retrieve added collection") + } + + if retrieved.GetName() != "Heritage Collection" { + t.Errorf("Expected name 'Heritage Collection', got '%s'", retrieved.GetName()) + } + + // Test safe retrieval + retrieved, exists := masterList.GetCollectionSafe(1001) + if !exists || retrieved == nil { + t.Error("GetCollectionSafe should return collection and true") + } + + _, exists = masterList.GetCollectionSafe(9999) + if exists { + t.Error("GetCollectionSafe should return false for non-existent ID") + } + + // Test HasCollection + if !masterList.HasCollection(1001) { + t.Error("HasCollection should return true for existing ID") + } + + if masterList.HasCollection(9999) { + t.Error("HasCollection should return false for non-existent ID") + } + + // Test removing + if !masterList.RemoveCollection(1001) { + t.Error("Should successfully remove collection") + } + + if masterList.GetCollectionCount() != 1 { + t.Errorf("Expected count 1, got %d", masterList.GetCollectionCount()) + } + + if masterList.HasCollection(1001) { + t.Error("Collection should be removed") + } + + // Test clear + masterList.ClearCollections() + if masterList.GetCollectionCount() != 0 { + t.Errorf("Expected count 0 after clear, got %d", masterList.GetCollectionCount()) + } +} + +func TestMasterListItemNeeds(t *testing.T) { + db, _ := database.NewSQLite("file::memory:?mode=memory&cache=shared") + defer db.Close() + + masterList := NewMasterList() + + // Create collections with items + collection1 := NewWithData(1001, "Heritage Collection", "Heritage", 20, db) + collection1.CollectionItems = append(collection1.CollectionItems, CollectionItem{ + ItemID: 12345, + Index: 0, + Found: ItemNotFound, + }) + collection1.CollectionItems = append(collection1.CollectionItems, CollectionItem{ + ItemID: 12346, + Index: 1, + Found: ItemFound, // Already found + }) + + collection2 := NewWithData(1002, "Treasured Collection", "Treasured", 30, db) + collection2.CollectionItems = append(collection2.CollectionItems, CollectionItem{ + ItemID: 12347, + Index: 0, + Found: ItemNotFound, + }) + + masterList.AddCollection(collection1) + masterList.AddCollection(collection2) + + // Test NeedsItem + if !masterList.NeedsItem(12345) { + t.Error("MasterList should need item 12345") + } + + if masterList.NeedsItem(12346) { + t.Error("MasterList should not need item 12346 (already found)") + } + + if !masterList.NeedsItem(12347) { + t.Error("MasterList should need item 12347") + } + + if masterList.NeedsItem(99999) { + t.Error("MasterList should not need item 99999") + } + + // Test GetCollectionsNeedingItem + needingItem := masterList.GetCollectionsNeedingItem(12345) + if len(needingItem) != 1 { + t.Errorf("Expected 1 collection needing item 12345, got %d", len(needingItem)) + } + + needingNone := masterList.GetCollectionsNeedingItem(99999) + if len(needingNone) != 0 { + t.Errorf("Expected 0 collections needing item 99999, got %d", len(needingNone)) + } +} + +func TestMasterListFiltering(t *testing.T) { + db, _ := database.NewSQLite("file::memory:?mode=memory&cache=shared") + defer db.Close() + + masterList := NewMasterList() + + // Add test collections + collections := []*Collection{ + NewWithData(1, "Heritage 1", "Heritage", 10, db), + NewWithData(2, "Heritage 2", "Heritage", 20, db), + NewWithData(3, "Treasured 1", "Treasured", 15, db), + NewWithData(4, "Treasured 2", "Treasured", 25, db), + NewWithData(5, "Legendary 1", "Legendary", 30, db), + } + + for _, collection := range collections { + masterList.AddCollection(collection) + } + + // Test FindCollectionsByCategory + heritageCollections := masterList.FindCollectionsByCategory("Heritage") + if len(heritageCollections) != 2 { + t.Errorf("FindCollectionsByCategory('Heritage') returned %v results, want 2", len(heritageCollections)) + } + + treasuredCollections := masterList.FindCollectionsByCategory("Treasured") + if len(treasuredCollections) != 2 { + t.Errorf("FindCollectionsByCategory('Treasured') returned %v results, want 2", len(treasuredCollections)) + } + + // Test FindCollectionsByLevel + lowLevel := masterList.FindCollectionsByLevel(10, 15) + if len(lowLevel) != 2 { + t.Errorf("FindCollectionsByLevel(10, 15) returned %v results, want 2", len(lowLevel)) + } + + midLevel := masterList.FindCollectionsByLevel(20, 25) + if len(midLevel) != 2 { + t.Errorf("FindCollectionsByLevel(20, 25) returned %v results, want 2", len(midLevel)) + } + + highLevel := masterList.FindCollectionsByLevel(30, 40) + if len(highLevel) != 1 { + t.Errorf("FindCollectionsByLevel(30, 40) returned %v results, want 1", len(highLevel)) + } +} + +func TestMasterListCategories(t *testing.T) { + db, _ := database.NewSQLite("file::memory:?mode=memory&cache=shared") + defer db.Close() + + masterList := NewMasterList() + + // Add collections with different categories + masterList.AddCollection(NewWithData(1, "Test1", "Heritage", 10, db)) + masterList.AddCollection(NewWithData(2, "Test2", "Heritage", 20, db)) + masterList.AddCollection(NewWithData(3, "Test3", "Treasured", 15, db)) + masterList.AddCollection(NewWithData(4, "Test4", "Legendary", 30, db)) + + categories := masterList.GetCategories() + + expectedCategories := []string{"Heritage", "Treasured", "Legendary"} + if len(categories) != len(expectedCategories) { + t.Errorf("Expected %d categories, got %d", len(expectedCategories), len(categories)) + } + + // Check that all expected categories are present + categoryMap := make(map[string]bool) + for _, category := range categories { + categoryMap[category] = true + } + + for _, expected := range expectedCategories { + if !categoryMap[expected] { + t.Errorf("Expected category '%s' not found", expected) + } + } +} + +func TestMasterListGetAll(t *testing.T) { + db, _ := database.NewSQLite("file::memory:?mode=memory&cache=shared") + defer db.Close() + + masterList := NewMasterList() + + // Add test collections + for i := int32(1); i <= 3; i++ { + collection := NewWithData(i*100, "Test", "Heritage", 20, db) + masterList.AddCollection(collection) + } + + // Test GetAllCollections (map) + allMap := masterList.GetAllCollections() + if len(allMap) != 3 { + t.Errorf("GetAllCollections() returned %v items, want 3", len(allMap)) + } + + // Verify it's a copy by modifying returned map + delete(allMap, 100) + if masterList.GetCollectionCount() != 3 { + t.Error("Modifying returned map affected internal state") + } + + // Test GetAllCollectionsList (slice) + allList := masterList.GetAllCollectionsList() + if len(allList) != 3 { + t.Errorf("GetAllCollectionsList() returned %v items, want 3", len(allList)) + } +} + +func TestMasterListValidation(t *testing.T) { + db, _ := database.NewSQLite("file::memory:?mode=memory&cache=shared") + defer db.Close() + + masterList := NewMasterList() + + // Add valid collection + collection1 := NewWithData(100, "Valid Collection", "Heritage", 20, db) + collection1.CollectionItems = append(collection1.CollectionItems, CollectionItem{ + ItemID: 12345, + Index: 0, + Found: ItemNotFound, + }) + masterList.AddCollection(collection1) + + issues := masterList.ValidateCollections() + if len(issues) != 0 { + t.Errorf("ValidateCollections() returned issues for valid data: %v", issues) + } + + if !masterList.IsValid() { + t.Error("IsValid() should return true for valid data") + } + + // Add invalid collection (empty name) + collection2 := NewWithData(200, "", "Heritage", 20, db) + masterList.AddCollection(collection2) + + issues = masterList.ValidateCollections() + if len(issues) == 0 { + t.Error("ValidateCollections() should return issues for invalid data") + } + + if masterList.IsValid() { + t.Error("IsValid() should return false for invalid data") + } +} + +func TestMasterListStatistics(t *testing.T) { + db, _ := database.NewSQLite("file::memory:?mode=memory&cache=shared") + defer db.Close() + + masterList := NewMasterList() + + // Add collections with different categories and levels + collection1 := NewWithData(10, "Heritage1", "Heritage", 10, db) + collection1.CollectionItems = append(collection1.CollectionItems, CollectionItem{ItemID: 1, Index: 0, Found: 0}) + collection1.CollectionItems = append(collection1.CollectionItems, CollectionItem{ItemID: 2, Index: 1, Found: 0}) + collection1.RewardItems = append(collection1.RewardItems, CollectionRewardItem{ItemID: 1001, Quantity: 1}) + + collection2 := NewWithData(20, "Heritage2", "Heritage", 20, db) + collection2.CollectionItems = append(collection2.CollectionItems, CollectionItem{ItemID: 3, Index: 0, Found: 0}) + collection2.RewardItems = append(collection2.RewardItems, CollectionRewardItem{ItemID: 1002, Quantity: 1}) + collection2.SelectableRewardItems = append(collection2.SelectableRewardItems, CollectionRewardItem{ItemID: 1003, Quantity: 1}) + + collection3 := NewWithData(30, "Treasured1", "Treasured", 30, db) + collection3.CollectionItems = append(collection3.CollectionItems, CollectionItem{ItemID: 4, Index: 0, Found: 0}) + + masterList.AddCollection(collection1) + masterList.AddCollection(collection2) + masterList.AddCollection(collection3) + + stats := masterList.GetStatistics() + + if total, ok := stats["total_collections"].(int); !ok || total != 3 { + t.Errorf("total_collections = %v, want 3", stats["total_collections"]) + } + + if totalItems, ok := stats["total_collection_items"].(int); !ok || totalItems != 4 { + t.Errorf("total_collection_items = %v, want 4", stats["total_collection_items"]) + } + + if totalRewards, ok := stats["total_rewards"].(int); !ok || totalRewards != 3 { + t.Errorf("total_rewards = %v, want 3", stats["total_rewards"]) + } + + if minLevel, ok := stats["min_level"].(int8); !ok || minLevel != 10 { + t.Errorf("min_level = %v, want 10", stats["min_level"]) + } + + if maxLevel, ok := stats["max_level"].(int8); !ok || maxLevel != 30 { + t.Errorf("max_level = %v, want 30", stats["max_level"]) + } + + if categoryCounts, ok := stats["collections_by_category"].(map[string]int); ok { + if categoryCounts["Heritage"] != 2 { + t.Errorf("Heritage collections = %v, want 2", categoryCounts["Heritage"]) + } + if categoryCounts["Treasured"] != 1 { + t.Errorf("Treasured collections = %v, want 1", categoryCounts["Treasured"]) + } + } else { + t.Error("collections_by_category not found in statistics") + } + + if avgItems, ok := stats["average_items_per_collection"].(float64); !ok || avgItems != float64(4)/3 { + t.Errorf("average_items_per_collection = %v, want %v", avgItems, float64(4)/3) + } +} \ No newline at end of file diff --git a/internal/collections/player.go b/internal/collections/player.go new file mode 100644 index 0000000..702e9f1 --- /dev/null +++ b/internal/collections/player.go @@ -0,0 +1,281 @@ +package collections + +import ( + "fmt" + + "eq2emu/internal/database" +) + +// PlayerList manages collections for a specific player +type PlayerList struct { + CharacterID int32 + collections map[int32]*Collection + db *database.Database +} + +// NewPlayerList creates a new player collection list +func NewPlayerList(characterID int32, db *database.Database) *PlayerList { + return &PlayerList{ + CharacterID: characterID, + collections: make(map[int32]*Collection), + db: db, + } +} + +// AddCollection adds a collection to the player's list +func (pl *PlayerList) AddCollection(collection *Collection) bool { + if collection == nil { + return false + } + + if _, exists := pl.collections[collection.GetID()]; exists { + return false // Already exists + } + + pl.collections[collection.GetID()] = collection + return true +} + +// GetCollection retrieves a collection by ID +func (pl *PlayerList) GetCollection(id int32) *Collection { + return pl.collections[id] +} + +// RemoveCollection removes a collection from the player's list +func (pl *PlayerList) RemoveCollection(id int32) bool { + if _, exists := pl.collections[id]; exists { + delete(pl.collections, id) + return true + } + return false +} + +// ClearCollections removes all collections from the player's list +func (pl *PlayerList) ClearCollections() { + pl.collections = make(map[int32]*Collection) +} + +// Size returns the number of collections the player has +func (pl *PlayerList) Size() int { + return len(pl.collections) +} + +// GetCollections returns all player collections +func (pl *PlayerList) GetCollections() map[int32]*Collection { + return pl.collections +} + +// NeedsItem checks if any of the player's collections need the specified item +func (pl *PlayerList) NeedsItem(itemID int32) bool { + for _, collection := range pl.collections { + if collection.NeedsItem(itemID) { + return true + } + } + return false +} + +// HasCollectionsToHandIn checks if the player has any collections ready to turn in +func (pl *PlayerList) HasCollectionsToHandIn() bool { + for _, collection := range pl.collections { + if collection.GetIsReadyToTurnIn() { + return true + } + } + return false +} + +// GetCollectionsToHandIn returns all collections ready to turn in +func (pl *PlayerList) GetCollectionsToHandIn() []*Collection { + var readyCollections []*Collection + for _, collection := range pl.collections { + if collection.GetIsReadyToTurnIn() { + readyCollections = append(readyCollections, collection) + } + } + return readyCollections +} + +// GetCompletedCollections returns all completed collections +func (pl *PlayerList) GetCompletedCollections() []*Collection { + var completedCollections []*Collection + for _, collection := range pl.collections { + if collection.Completed { + completedCollections = append(completedCollections, collection) + } + } + return completedCollections +} + +// GetActiveCollections returns all non-completed collections +func (pl *PlayerList) GetActiveCollections() []*Collection { + var activeCollections []*Collection + for _, collection := range pl.collections { + if !collection.Completed { + activeCollections = append(activeCollections, collection) + } + } + return activeCollections +} + +// LoadPlayerCollections loads all collections for the player from database +func (pl *PlayerList) LoadPlayerCollections(masterList *MasterList) error { + if pl.db == nil { + return fmt.Errorf("no database connection available") + } + + // Clear existing collections + pl.ClearCollections() + + // Load player's collection progress + query := `SELECT collection_id, completed FROM character_collections WHERE char_id = ?` + rows, err := pl.db.Query(query, pl.CharacterID) + if err != nil { + return fmt.Errorf("failed to load player collections: %w", err) + } + defer rows.Close() + + for rows.Next() { + var collectionID int32 + var completed bool + + if err := rows.Scan(&collectionID, &completed); err != nil { + return fmt.Errorf("failed to scan player collection: %w", err) + } + + // Get the master collection + masterCollection := masterList.GetCollection(collectionID) + if masterCollection == nil { + continue // Skip if collection doesn't exist in master list + } + + // Create a copy for the player + playerCollection := masterCollection.Clone() + playerCollection.Completed = completed + + // Load player's found items + itemQuery := `SELECT collection_item_id FROM character_collection_items WHERE char_id = ? AND collection_id = ?` + itemRows, err := pl.db.Query(itemQuery, pl.CharacterID, collectionID) + if err != nil { + return fmt.Errorf("failed to load player collection items: %w", err) + } + + for itemRows.Next() { + var itemID int32 + if err := itemRows.Scan(&itemID); err != nil { + itemRows.Close() + return fmt.Errorf("failed to scan player collection item: %w", err) + } + + // Mark the item as found + if collectionItem := playerCollection.GetCollectionItemByItemID(itemID); collectionItem != nil { + collectionItem.Found = 1 + } + } + itemRows.Close() + + pl.AddCollection(playerCollection) + } + + return nil +} + +// SavePlayerCollection saves a player's collection progress +func (pl *PlayerList) SavePlayerCollection(collectionID int32) error { + if pl.db == nil { + return fmt.Errorf("no database connection available") + } + + collection := pl.GetCollection(collectionID) + if collection == nil { + return fmt.Errorf("collection %d not found for player %d", collectionID, pl.CharacterID) + } + + // Update or insert player collection record + _, err := pl.db.Exec(` + INSERT INTO character_collections (char_id, collection_id, completed) + VALUES (?, ?, ?) + ON CONFLICT(char_id, collection_id) + DO UPDATE SET completed = ?`, + pl.CharacterID, collectionID, collection.Completed, collection.Completed) + if err != nil { + return fmt.Errorf("failed to save player collection: %w", err) + } + + // Delete existing found items and re-insert + _, err = pl.db.Exec(`DELETE FROM character_collection_items WHERE char_id = ? AND collection_id = ?`, + pl.CharacterID, collectionID) + if err != nil { + return fmt.Errorf("failed to delete old collection items: %w", err) + } + + // Insert found items + for _, item := range collection.CollectionItems { + if item.Found != 0 { + _, err = pl.db.Exec(` + INSERT INTO character_collection_items (char_id, collection_id, collection_item_id) + VALUES (?, ?, ?)`, + pl.CharacterID, collectionID, item.ItemID) + if err != nil { + return fmt.Errorf("failed to save collection item: %w", err) + } + } + } + + collection.SaveNeeded = false + return nil +} + +// SaveAllCollections saves all player collections that need saving +func (pl *PlayerList) SaveAllCollections() error { + for collectionID, collection := range pl.collections { + if collection.SaveNeeded { + if err := pl.SavePlayerCollection(collectionID); err != nil { + return err + } + } + } + return nil +} + +// GetStatistics returns statistics about the player's collections +func (pl *PlayerList) GetStatistics() map[string]any { + stats := make(map[string]any) + stats["total_collections"] = len(pl.collections) + + completed := 0 + readyToTurnIn := 0 + totalItemsFound := 0 + totalItemsNeeded := 0 + + for _, collection := range pl.collections { + if collection.Completed { + completed++ + } + if collection.GetIsReadyToTurnIn() { + readyToTurnIn++ + } + + for _, item := range collection.CollectionItems { + if item.Found != 0 { + totalItemsFound++ + } else { + totalItemsNeeded++ + } + } + } + + stats["completed_collections"] = completed + stats["ready_to_turn_in"] = readyToTurnIn + stats["active_collections"] = len(pl.collections) - completed + stats["total_items_found"] = totalItemsFound + stats["total_items_needed"] = totalItemsNeeded + + if totalItemsFound+totalItemsNeeded > 0 { + stats["overall_progress"] = float64(totalItemsFound) / float64(totalItemsFound+totalItemsNeeded) * 100.0 + } else { + stats["overall_progress"] = 0.0 + } + + return stats +} \ No newline at end of file diff --git a/internal/collections/player_list.go b/internal/collections/player_list.go deleted file mode 100644 index 7c31b20..0000000 --- a/internal/collections/player_list.go +++ /dev/null @@ -1,397 +0,0 @@ -package collections - -import ( - "context" - "fmt" - "sort" -) - -// NewPlayerCollectionList creates a new player collection list -func NewPlayerCollectionList(characterID int32, database CollectionDatabase) *PlayerCollectionList { - return &PlayerCollectionList{ - characterID: characterID, - collections: make(map[int32]*Collection), - database: database, - } -} - -// Initialize loads player's collection progress from database -func (pcl *PlayerCollectionList) Initialize(ctx context.Context, masterList *MasterCollectionList) error { - pcl.mu.Lock() - defer pcl.mu.Unlock() - - // Load player collection data - playerCollections, err := pcl.database.LoadPlayerCollections(ctx, pcl.characterID) - if err != nil { - return fmt.Errorf("failed to load player collections: %w", err) - } - - for _, playerCollection := range playerCollections { - // Get the master collection template - masterCollection := masterList.GetCollection(playerCollection.CollectionID) - if masterCollection == nil { - continue // Skip collections that no longer exist - } - - // Create a copy for the player - collection := NewCollectionFromData(masterCollection) - if collection == nil { - continue - } - - collection.SetCompleted(playerCollection.Completed) - - // Load player's found items - foundItems, err := pcl.database.LoadPlayerCollectionItems(ctx, pcl.characterID, playerCollection.CollectionID) - if err != nil { - return fmt.Errorf("failed to load player collection items for collection %d: %w", playerCollection.CollectionID, err) - } - - // Mark found items - for _, itemID := range foundItems { - collection.MarkItemFound(itemID) - } - - // Reset save needed flag after loading - collection.SetSaveNeeded(false) - - pcl.collections[playerCollection.CollectionID] = collection - } - - return nil -} - -// AddCollection adds a collection to the player's list -func (pcl *PlayerCollectionList) AddCollection(collection *Collection) bool { - pcl.mu.Lock() - defer pcl.mu.Unlock() - - if collection == nil { - return false - } - - id := collection.GetID() - if _, exists := pcl.collections[id]; exists { - return false - } - - pcl.collections[id] = collection - return true -} - -// GetCollection retrieves a collection by ID -func (pcl *PlayerCollectionList) GetCollection(collectionID int32) *Collection { - pcl.mu.RLock() - defer pcl.mu.RUnlock() - return pcl.collections[collectionID] -} - -// ClearCollections removes all collections -func (pcl *PlayerCollectionList) ClearCollections() { - pcl.mu.Lock() - defer pcl.mu.Unlock() - pcl.collections = make(map[int32]*Collection) -} - -// Size returns the number of collections -func (pcl *PlayerCollectionList) Size() int { - pcl.mu.RLock() - defer pcl.mu.RUnlock() - return len(pcl.collections) -} - -// NeedsItem checks if any player collection or potential collection needs an item -func (pcl *PlayerCollectionList) NeedsItem(itemID int32, masterList *MasterCollectionList) bool { - pcl.mu.RLock() - defer pcl.mu.RUnlock() - - // Check player's active collections first - for _, collection := range pcl.collections { - if collection.NeedsItem(itemID) { - return true - } - } - - // Check if any master collection the player doesn't have needs this item - if masterList != nil { - for _, masterCollection := range masterList.GetAllCollections() { - if masterCollection.NeedsItem(itemID) { - // Player doesn't have this collection yet - if _, hasCollection := pcl.collections[masterCollection.GetID()]; !hasCollection { - return true - } - } - } - } - - return false -} - -// HasCollectionsToHandIn checks if any collections are ready to turn in -func (pcl *PlayerCollectionList) HasCollectionsToHandIn() bool { - pcl.mu.RLock() - defer pcl.mu.RUnlock() - - for _, collection := range pcl.collections { - if collection.GetIsReadyToTurnIn() { - return true - } - } - - return false -} - -// GetCollectionsReadyToTurnIn returns collections that are ready to complete -func (pcl *PlayerCollectionList) GetCollectionsReadyToTurnIn() []*Collection { - pcl.mu.RLock() - defer pcl.mu.RUnlock() - - var result []*Collection - for _, collection := range pcl.collections { - if collection.GetIsReadyToTurnIn() { - result = append(result, collection) - } - } - - return result -} - -// GetCompletedCollections returns all completed collections -func (pcl *PlayerCollectionList) GetCompletedCollections() []*Collection { - pcl.mu.RLock() - defer pcl.mu.RUnlock() - - var result []*Collection - for _, collection := range pcl.collections { - if collection.GetCompleted() { - result = append(result, collection) - } - } - - return result -} - -// GetActiveCollections returns all active (incomplete) collections -func (pcl *PlayerCollectionList) GetActiveCollections() []*Collection { - pcl.mu.RLock() - defer pcl.mu.RUnlock() - - var result []*Collection - for _, collection := range pcl.collections { - if !collection.GetCompleted() { - result = append(result, collection) - } - } - - return result -} - -// GetAllCollections returns all player collections -func (pcl *PlayerCollectionList) GetAllCollections() []*Collection { - pcl.mu.RLock() - defer pcl.mu.RUnlock() - - result := make([]*Collection, 0, len(pcl.collections)) - for _, collection := range pcl.collections { - result = append(result, collection) - } - - return result -} - -// GetCollectionsByCategory returns collections in a specific category -func (pcl *PlayerCollectionList) GetCollectionsByCategory(category string) []*Collection { - pcl.mu.RLock() - defer pcl.mu.RUnlock() - - var result []*Collection - for _, collection := range pcl.collections { - if collection.GetCategory() == category { - result = append(result, collection) - } - } - - return result -} - -// ProcessItemFound processes when a player finds an item that may belong to collections -func (pcl *PlayerCollectionList) ProcessItemFound(itemID int32, masterList *MasterCollectionList) ([]*Collection, error) { - pcl.mu.Lock() - defer pcl.mu.Unlock() - - var updatedCollections []*Collection - - // Check existing player collections - for _, collection := range pcl.collections { - if collection.NeedsItem(itemID) { - if collection.MarkItemFound(itemID) { - updatedCollections = append(updatedCollections, collection) - } - } - } - - // Check if player should start new collections - if masterList != nil { - for _, masterCollection := range masterList.GetAllCollections() { - // Skip if player already has this collection - if _, hasCollection := pcl.collections[masterCollection.GetID()]; hasCollection { - continue - } - - // Check if master collection needs this item - if masterCollection.NeedsItem(itemID) { - // Create new collection for player - newCollection := NewCollectionFromData(masterCollection) - if newCollection != nil { - newCollection.MarkItemFound(itemID) - pcl.collections[masterCollection.GetID()] = newCollection - updatedCollections = append(updatedCollections, newCollection) - } - } - } - } - - return updatedCollections, nil -} - -// CompleteCollection marks a collection as completed -func (pcl *PlayerCollectionList) CompleteCollection(collectionID int32) error { - pcl.mu.Lock() - defer pcl.mu.Unlock() - - collection, exists := pcl.collections[collectionID] - if !exists { - return fmt.Errorf("collection %d not found", collectionID) - } - - if collection.GetCompleted() { - return fmt.Errorf("collection %d is already completed", collectionID) - } - - if !collection.GetIsReadyToTurnIn() { - return fmt.Errorf("collection %d is not ready to complete", collectionID) - } - - collection.SetCompleted(true) - collection.SetSaveNeeded(true) - - return nil -} - -// GetCollectionsNeedingSave returns collections that need to be saved -func (pcl *PlayerCollectionList) GetCollectionsNeedingSave() []*Collection { - pcl.mu.RLock() - defer pcl.mu.RUnlock() - - var result []*Collection - for _, collection := range pcl.collections { - if collection.GetSaveNeeded() { - result = append(result, collection) - } - } - - return result -} - -// SaveCollections saves all collections that need saving -func (pcl *PlayerCollectionList) SaveCollections(ctx context.Context) error { - collectionsToSave := pcl.GetCollectionsNeedingSave() - if len(collectionsToSave) == 0 { - return nil - } - - if err := pcl.database.SavePlayerCollections(ctx, pcl.characterID, collectionsToSave); err != nil { - return fmt.Errorf("failed to save player collections: %w", err) - } - - // Mark collections as saved - for _, collection := range collectionsToSave { - collection.SetSaveNeeded(false) - } - - return nil -} - -// GetStatistics returns player collection statistics -func (pcl *PlayerCollectionList) GetStatistics() CollectionStatistics { - pcl.mu.RLock() - defer pcl.mu.RUnlock() - - stats := CollectionStatistics{ - TotalCollections: len(pcl.collections), - PlayersWithCollections: 1, // This player - } - - for _, collection := range pcl.collections { - if collection.GetCompleted() { - stats.CompletedCollections++ - } - if !collection.GetCompleted() && collection.GetFoundItemsCount() > 0 { - stats.ActiveCollections++ - } - stats.TotalItems += collection.GetTotalItemsCount() - stats.FoundItems += collection.GetFoundItemsCount() - stats.TotalRewards += len(collection.GetRewardItems()) + len(collection.GetSelectableRewardItems()) - if collection.GetRewardCoin() > 0 { - stats.TotalRewards++ - } - if collection.GetRewardXP() > 0 { - stats.TotalRewards++ - } - } - - return stats -} - -// GetCollectionProgress returns detailed progress for all collections -func (pcl *PlayerCollectionList) GetCollectionProgress() []CollectionProgress { - pcl.mu.RLock() - defer pcl.mu.RUnlock() - - progress := make([]CollectionProgress, 0, len(pcl.collections)) - for _, collection := range pcl.collections { - progress = append(progress, collection.GetCollectionProgress()) - } - - // Sort by name - sort.Slice(progress, func(i, j int) bool { - return progress[i].Name < progress[j].Name - }) - - return progress -} - -// GetCharacterID returns the character ID for this collection list -func (pcl *PlayerCollectionList) GetCharacterID() int32 { - return pcl.characterID -} - -// RemoveCollection removes a collection from the player's list -func (pcl *PlayerCollectionList) RemoveCollection(collectionID int32) bool { - pcl.mu.Lock() - defer pcl.mu.Unlock() - - if _, exists := pcl.collections[collectionID]; exists { - delete(pcl.collections, collectionID) - return true - } - - return false -} - -// GetCollectionIDs returns all collection IDs the player has -func (pcl *PlayerCollectionList) GetCollectionIDs() []int32 { - pcl.mu.RLock() - defer pcl.mu.RUnlock() - - ids := make([]int32, 0, len(pcl.collections)) - for id := range pcl.collections { - ids = append(ids, id) - } - - sort.Slice(ids, func(i, j int) bool { - return ids[i] < ids[j] - }) - - return ids -} diff --git a/internal/collections/types.go b/internal/collections/types.go index 85c6128..75b668a 100644 --- a/internal/collections/types.go +++ b/internal/collections/types.go @@ -1,104 +1,10 @@ package collections import ( - "sync" "time" ) -// CollectionItem represents an item required for a collection -type CollectionItem struct { - ItemID int32 `json:"item_id" db:"item_id"` - Index int8 `json:"index" db:"item_index"` - Found int8 `json:"found" db:"found"` -} - -// CollectionRewardItem represents a reward item for completing a collection -type CollectionRewardItem struct { - ItemID int32 `json:"item_id" db:"item_id"` - Quantity int8 `json:"quantity" db:"quantity"` -} - -// Collection represents a collection that players can complete -type Collection struct { - mu sync.RWMutex - id int32 - name string - category string - level int8 - rewardCoin int64 - rewardXP int64 - completed bool - saveNeeded bool - collectionItems []CollectionItem - rewardItems []CollectionRewardItem - selectableRewardItems []CollectionRewardItem - lastModified time.Time -} - -// CollectionData represents collection data for database operations -type CollectionData struct { - ID int32 `json:"id" db:"id"` - Name string `json:"collection_name" db:"collection_name"` - Category string `json:"collection_category" db:"collection_category"` - Level int8 `json:"level" db:"level"` -} - -// CollectionRewardData represents reward data from database -type CollectionRewardData struct { - CollectionID int32 `json:"collection_id" db:"collection_id"` - RewardType string `json:"reward_type" db:"reward_type"` - RewardValue string `json:"reward_value" db:"reward_value"` - Quantity int8 `json:"reward_quantity" db:"reward_quantity"` -} - -// PlayerCollectionData represents player collection progress -type PlayerCollectionData struct { - CharacterID int32 `json:"char_id" db:"char_id"` - CollectionID int32 `json:"collection_id" db:"collection_id"` - Completed bool `json:"completed" db:"completed"` -} - -// PlayerCollectionItemData represents player found collection items -type PlayerCollectionItemData struct { - CharacterID int32 `json:"char_id" db:"char_id"` - CollectionID int32 `json:"collection_id" db:"collection_id"` - CollectionItemID int32 `json:"collection_item_id" db:"collection_item_id"` -} - -// MasterCollectionList manages all available collections in the game -type MasterCollectionList struct { - mu sync.RWMutex - collections map[int32]*Collection - database CollectionDatabase -} - -// PlayerCollectionList manages collections for a specific player -type PlayerCollectionList struct { - mu sync.RWMutex - characterID int32 - collections map[int32]*Collection - database CollectionDatabase -} - -// CollectionManager provides high-level collection management -type CollectionManager struct { - masterList *MasterCollectionList - database CollectionDatabase - itemLookup ItemLookup -} - -// CollectionStatistics provides collection system usage statistics -type CollectionStatistics struct { - TotalCollections int - CompletedCollections int - ActiveCollections int - TotalItems int - FoundItems int - TotalRewards int - PlayersWithCollections int -} - -// CollectionInfo provides basic collection information +// CollectionInfo provides basic collection information for client display type CollectionInfo struct { ID int32 `json:"id"` Name string `json:"name"`