eq2go/internal/collections/database.go

585 lines
19 KiB
Go

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
}