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 }