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