modernize collections

This commit is contained in:
Sky Johnson 2025-08-07 17:52:49 -05:00
parent a47ad4f737
commit 6df4b00201
16 changed files with 1792 additions and 3993 deletions

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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