eq2go/internal/titles/database.go
2025-08-06 13:19:07 -05:00

303 lines
8.0 KiB
Go

package titles
import (
"fmt"
"time"
"zombiezen.com/go/sqlite"
"zombiezen.com/go/sqlite/sqlitex"
)
// DB wraps a SQLite connection for title operations
type DB struct {
conn *sqlite.Conn
}
// OpenDB opens a database connection
func OpenDB(path string) (*DB, error) {
conn, err := sqlite.OpenConn(path, sqlite.OpenReadWrite|sqlite.OpenCreate|sqlite.OpenWAL)
if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err)
}
// Enable foreign keys
if err := sqlitex.ExecTransient(conn, "PRAGMA foreign_keys = ON;", nil); err != nil {
conn.Close()
return nil, fmt.Errorf("failed to enable foreign keys: %w", err)
}
return &DB{conn: conn}, nil
}
// Close closes the database connection
func (db *DB) Close() error {
if db.conn != nil {
return db.conn.Close()
}
return nil
}
// CreateTables creates the necessary tables for the title system
func (db *DB) CreateTables() error {
// Create titles table
titlesTableSQL := `
CREATE TABLE IF NOT EXISTS titles (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
category TEXT,
position INTEGER NOT NULL DEFAULT 0,
source INTEGER NOT NULL DEFAULT 0,
rarity INTEGER NOT NULL DEFAULT 0,
flags INTEGER NOT NULL DEFAULT 0,
achievement_id INTEGER,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_titles_category ON titles(category);
CREATE INDEX IF NOT EXISTS idx_titles_achievement ON titles(achievement_id);
`
// Create player_titles table
playerTitlesTableSQL := `
CREATE TABLE IF NOT EXISTS player_titles (
player_id INTEGER NOT NULL,
title_id INTEGER NOT NULL,
achievement_id INTEGER,
granted_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expiration_date TIMESTAMP,
is_active INTEGER DEFAULT 0,
PRIMARY KEY (player_id, title_id),
FOREIGN KEY (title_id) REFERENCES titles(id)
);
CREATE INDEX IF NOT EXISTS idx_player_titles_player ON player_titles(player_id);
CREATE INDEX IF NOT EXISTS idx_player_titles_expiration ON player_titles(expiration_date);
`
// Execute table creation
if err := sqlitex.ExecuteScript(db.conn, titlesTableSQL, &sqlitex.ExecOptions{}); err != nil {
return fmt.Errorf("failed to create titles table: %w", err)
}
if err := sqlitex.ExecuteScript(db.conn, playerTitlesTableSQL, &sqlitex.ExecOptions{}); err != nil {
return fmt.Errorf("failed to create player_titles table: %w", err)
}
return nil
}
// LoadMasterTitles loads all titles from the database
func (db *DB) LoadMasterTitles() ([]*Title, error) {
var titles []*Title
query := `SELECT id, name, description, category, position, source, rarity, flags, achievement_id FROM titles`
err := sqlitex.Execute(db.conn, query, &sqlitex.ExecOptions{
ResultFunc: func(stmt *sqlite.Stmt) error {
title := &Title{
ID: int32(stmt.ColumnInt64(0)),
Name: stmt.ColumnText(1),
Description: stmt.ColumnText(2),
Category: stmt.ColumnText(3),
Position: int32(stmt.ColumnInt(4)),
Source: int32(stmt.ColumnInt(5)),
Rarity: int32(stmt.ColumnInt(6)),
Flags: uint32(stmt.ColumnInt64(7)),
}
// Handle nullable achievement_id
if stmt.ColumnType(8) != sqlite.TypeNull {
title.AchievementID = uint32(stmt.ColumnInt64(8))
}
titles = append(titles, title)
return nil
},
})
if err != nil {
return nil, fmt.Errorf("failed to load titles: %w", err)
}
return titles, nil
}
// SaveMasterTitles saves all titles to the database
func (db *DB) SaveMasterTitles(titles []*Title) error {
// Use a transaction for atomic updates
endFn, err := sqlitex.ImmediateTransaction(db.conn)
if err != nil {
return fmt.Errorf("failed to start transaction: %w", err)
}
defer endFn(&err)
// Clear existing titles
if err := sqlitex.Execute(db.conn, "DELETE FROM titles", &sqlitex.ExecOptions{}); err != nil {
return fmt.Errorf("failed to clear titles table: %w", err)
}
// Insert all titles
insertQuery := `
INSERT INTO titles (id, name, description, category, position, source, rarity, flags, achievement_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`
for _, title := range titles {
err := sqlitex.Execute(db.conn, insertQuery, &sqlitex.ExecOptions{
Args: []any{
title.ID,
title.Name,
title.Description,
title.Category,
int(title.Position),
int(title.Source),
int(title.Rarity),
int64(title.Flags),
nullableUint32(title.AchievementID),
},
})
if err != nil {
return fmt.Errorf("failed to insert title %d: %w", title.ID, err)
}
}
return nil
}
// LoadPlayerTitles loads titles for a specific player
func (db *DB) LoadPlayerTitles(playerID int32) ([]*PlayerTitle, error) {
var playerTitles []*PlayerTitle
query := `
SELECT title_id, achievement_id, granted_date, expiration_date, is_active
FROM player_titles
WHERE player_id = ?
`
err := sqlitex.Execute(db.conn, query, &sqlitex.ExecOptions{
Args: []any{playerID},
ResultFunc: func(stmt *sqlite.Stmt) error {
playerTitle := &PlayerTitle{
TitleID: int32(stmt.ColumnInt64(0)),
PlayerID: playerID,
EarnedDate: time.Unix(stmt.ColumnInt64(2), 0),
}
// Handle nullable achievement_id
if stmt.ColumnType(1) != sqlite.TypeNull {
playerTitle.AchievementID = uint32(stmt.ColumnInt64(1))
}
// Handle nullable expiration_date
if stmt.ColumnType(3) != sqlite.TypeNull {
playerTitle.ExpiresAt = time.Unix(stmt.ColumnInt64(3), 0)
}
playerTitles = append(playerTitles, playerTitle)
return nil
},
})
if err != nil {
return nil, fmt.Errorf("failed to load player titles: %w", err)
}
return playerTitles, nil
}
// SavePlayerTitles saves a player's titles to the database
func (db *DB) SavePlayerTitles(playerID int32, titles []*PlayerTitle, activePrefixID, activeSuffixID int32) error {
// Use a transaction for atomic updates
endFn, err := sqlitex.ImmediateTransaction(db.conn)
if err != nil {
return fmt.Errorf("failed to start transaction: %w", err)
}
defer endFn(&err)
// Clear existing titles for this player
deleteQuery := "DELETE FROM player_titles WHERE player_id = ?"
if err := sqlitex.Execute(db.conn, deleteQuery, &sqlitex.ExecOptions{
Args: []any{playerID},
}); err != nil {
return fmt.Errorf("failed to clear player titles: %w", err)
}
// Insert all current titles
insertQuery := `
INSERT INTO player_titles (player_id, title_id, achievement_id, granted_date, expiration_date, is_active)
VALUES (?, ?, ?, ?, ?, ?)
`
for _, playerTitle := range titles {
isActive := 0
if playerTitle.TitleID == activePrefixID || playerTitle.TitleID == activeSuffixID {
isActive = 1
}
err := sqlitex.Execute(db.conn, insertQuery, &sqlitex.ExecOptions{
Args: []any{
playerID,
playerTitle.TitleID,
nullableUint32(playerTitle.AchievementID),
playerTitle.EarnedDate.Unix(),
nullableTime(playerTitle.ExpiresAt),
isActive,
},
})
if err != nil {
return fmt.Errorf("failed to insert player title %d: %w", playerTitle.TitleID, err)
}
}
return nil
}
// GetActivePlayerTitles retrieves the active prefix and suffix titles for a player
func (db *DB) GetActivePlayerTitles(playerID int32) (prefixID, suffixID int32, err error) {
query := `
SELECT pt.title_id, t.position
FROM player_titles pt
JOIN titles t ON pt.title_id = t.id
WHERE pt.player_id = ? AND pt.is_active = 1
`
err = sqlitex.Execute(db.conn, query, &sqlitex.ExecOptions{
Args: []any{playerID},
ResultFunc: func(stmt *sqlite.Stmt) error {
titleID := int32(stmt.ColumnInt64(0))
position := int32(stmt.ColumnInt(1))
if position == TitlePositionPrefix {
prefixID = titleID
} else if position == TitlePositionSuffix {
suffixID = titleID
}
return nil
},
})
if err != nil {
return 0, 0, fmt.Errorf("failed to get active titles: %w", err)
}
return prefixID, suffixID, nil
}
// Helper functions for nullable values
func nullableUint32(val uint32) any {
if val == 0 {
return nil
}
return val
}
func nullableTime(t time.Time) any {
if t.IsZero() {
return nil
}
return t.Unix()
}