fix titles package

This commit is contained in:
Sky Johnson 2025-08-06 13:19:07 -05:00
parent 3b6d35ce98
commit 0c37453971
5 changed files with 1067 additions and 238 deletions

303
internal/titles/database.go Normal file
View File

@ -0,0 +1,303 @@
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()
}

View File

@ -3,8 +3,6 @@ package titles
import (
"fmt"
"sync"
"eq2emu/internal/database"
)
// MasterTitlesList manages all available titles in the game
@ -422,88 +420,33 @@ func (mtl *MasterTitlesList) ValidateTitle(title *Title) error {
}
// LoadFromDatabase loads titles from the database
func (mtl *MasterTitlesList) LoadFromDatabase(db *database.DB) error {
func (mtl *MasterTitlesList) LoadFromDatabase(db *DB) error {
mtl.mutex.Lock()
defer mtl.mutex.Unlock()
// Create titles table if it doesn't exist
if err := db.Exec(`
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
)
`); err != nil {
return fmt.Errorf("failed to create titles table: %w", err)
}
// Load all titles from database
err := db.Query("SELECT id, name, description, category, position, source, rarity, flags, achievement_id FROM titles", func(row *database.Row) error {
title := &Title{
ID: int32(row.Int64(0)),
Name: row.Text(1),
Description: row.Text(2),
Category: row.Text(3),
Position: int32(row.Int(4)),
Source: int32(row.Int(5)),
Rarity: int32(row.Int(6)),
Flags: uint32(row.Int64(7)),
}
// Handle nullable achievement_id
if !row.IsNull(8) {
title.AchievementID = uint32(row.Int64(8))
}
mtl.addTitleInternal(title)
return nil
})
titles, err := db.LoadMasterTitles()
if err != nil {
return fmt.Errorf("failed to load titles from database: %w", err)
}
for _, title := range titles {
mtl.addTitleInternal(title)
}
return nil
}
// SaveToDatabase saves titles to the database
func (mtl *MasterTitlesList) SaveToDatabase(db *database.DB) error {
func (mtl *MasterTitlesList) SaveToDatabase(db *DB) error {
mtl.mutex.RLock()
defer mtl.mutex.RUnlock()
return db.Transaction(func(txDB *database.DB) error {
// Clear existing titles (this is a full sync)
if err := txDB.Exec("DELETE FROM titles"); err != nil {
return fmt.Errorf("failed to clear titles table: %w", err)
}
// Convert map to slice
titles := make([]*Title, 0, len(mtl.titles))
for _, title := range mtl.titles {
titles = append(titles, title)
}
// Insert all current titles
for _, title := range mtl.titles {
var achievementID any
if title.AchievementID != 0 {
achievementID = title.AchievementID
}
err := txDB.Exec(`
INSERT INTO titles (id, name, description, category, position, source, rarity, flags, achievement_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`, title.ID, title.Name, title.Description, title.Category,
int(title.Position), int(title.Source), int(title.Rarity),
int64(title.Flags), achievementID)
if err != nil {
return fmt.Errorf("failed to insert title %d: %w", title.ID, err)
}
}
return nil
})
return db.SaveMasterTitles(titles)
}

View File

@ -3,9 +3,6 @@ package titles
import (
"fmt"
"sync"
"time"
"eq2emu/internal/database"
)
// PlayerTitlesList manages titles owned by a specific player
@ -430,105 +427,44 @@ func (ptl *PlayerTitlesList) GrantTitleFromAchievement(achievementID uint32) err
}
// LoadFromDatabase loads player titles from the database
func (ptl *PlayerTitlesList) LoadFromDatabase(db *database.DB) error {
func (ptl *PlayerTitlesList) LoadFromDatabase(db *DB) error {
ptl.mutex.Lock()
defer ptl.mutex.Unlock()
// Create player_titles table if it doesn't exist
if err := db.Exec(`
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)
)
`); err != nil {
return fmt.Errorf("failed to create player_titles table: %w", err)
}
// Load all titles for this player
err := db.Query("SELECT title_id, achievement_id, granted_date, expiration_date, is_active FROM player_titles WHERE player_id = ?",
func(row *database.Row) error {
playerTitle := &PlayerTitle{
TitleID: int32(row.Int64(0)),
PlayerID: ptl.playerID,
EarnedDate: time.Unix(row.Int64(2), 0),
}
// Handle nullable achievement_id
if !row.IsNull(1) {
playerTitle.AchievementID = uint32(row.Int64(1))
}
// Handle nullable expiration_date
if !row.IsNull(3) {
playerTitle.ExpiresAt = time.Unix(row.Int64(3), 0)
}
ptl.titles[playerTitle.TitleID] = playerTitle
// Set active title if this one is active
if row.Bool(4) {
ptl.activePrefixID = playerTitle.TitleID
}
return nil
}, ptl.playerID)
playerTitles, err := db.LoadPlayerTitles(ptl.playerID)
if err != nil {
return fmt.Errorf("failed to load player titles from database: %w", err)
}
for _, playerTitle := range playerTitles {
ptl.titles[playerTitle.TitleID] = playerTitle
}
// Load active titles
prefixID, suffixID, err := db.GetActivePlayerTitles(ptl.playerID)
if err != nil {
return fmt.Errorf("failed to load active titles: %w", err)
}
ptl.activePrefixID = prefixID
ptl.activeSuffixID = suffixID
return nil
}
// SaveToDatabase saves player titles to the database
func (ptl *PlayerTitlesList) SaveToDatabase(db *database.DB) error {
func (ptl *PlayerTitlesList) SaveToDatabase(db *DB) error {
ptl.mutex.RLock()
defer ptl.mutex.RUnlock()
return db.Transaction(func(txDB *database.DB) error {
// Clear existing titles for this player
if err := txDB.Exec("DELETE FROM player_titles WHERE player_id = ?", ptl.playerID); err != nil {
return fmt.Errorf("failed to clear player titles: %w", err)
}
// Convert map to slice
titles := make([]*PlayerTitle, 0, len(ptl.titles))
for _, title := range ptl.titles {
titles = append(titles, title)
}
// Insert all current titles
for _, playerTitle := range ptl.titles {
var achievementID any
if playerTitle.AchievementID != 0 {
achievementID = playerTitle.AchievementID
}
var expirationDate any
if !playerTitle.ExpiresAt.IsZero() {
expirationDate = playerTitle.ExpiresAt.Unix()
}
isActive := 0
if ptl.activePrefixID == playerTitle.TitleID {
isActive = 1
} else if ptl.activeSuffixID == playerTitle.TitleID {
isActive = 1
}
err := txDB.Exec(`
INSERT INTO player_titles (player_id, title_id, achievement_id, granted_date, expiration_date, is_active)
VALUES (?, ?, ?, ?, ?, ?)
`, ptl.playerID, playerTitle.TitleID, achievementID,
playerTitle.EarnedDate.Unix(), expirationDate, isActive)
if err != nil {
return fmt.Errorf("failed to insert player title %d: %w", playerTitle.TitleID, err)
}
}
return nil
})
return db.SavePlayerTitles(ptl.playerID, titles, ptl.activePrefixID, ptl.activeSuffixID)
}
// GetFormattedName returns the player name with active titles applied

View File

@ -4,8 +4,6 @@ import (
"fmt"
"sync"
"time"
"eq2emu/internal/database"
)
// TitleManager manages the entire title system for the server
@ -341,13 +339,13 @@ func (tm *TitleManager) RemovePlayerFromMemory(playerID int32) {
}
// LoadPlayerTitles loads a player's titles from database
func (tm *TitleManager) LoadPlayerTitles(playerID int32, db *database.DB) error {
func (tm *TitleManager) LoadPlayerTitles(playerID int32, db *DB) error {
playerList := tm.GetPlayerTitles(playerID)
return playerList.LoadFromDatabase(db)
}
// SavePlayerTitles saves a player's titles to database
func (tm *TitleManager) SavePlayerTitles(playerID int32, db *database.DB) error {
func (tm *TitleManager) SavePlayerTitles(playerID int32, db *DB) error {
tm.mutex.RLock()
playerList, exists := tm.playerLists[playerID]
tm.mutex.RUnlock()
@ -360,12 +358,12 @@ func (tm *TitleManager) SavePlayerTitles(playerID int32, db *database.DB) error
}
// LoadMasterTitles loads all titles from database
func (tm *TitleManager) LoadMasterTitles(db *database.DB) error {
func (tm *TitleManager) LoadMasterTitles(db *DB) error {
return tm.masterList.LoadFromDatabase(db)
}
// SaveMasterTitles saves all titles to database
func (tm *TitleManager) SaveMasterTitles(db *database.DB) error {
func (tm *TitleManager) SaveMasterTitles(db *DB) error {
return tm.masterList.SaveToDatabase(db)
}

View File

@ -1,12 +1,15 @@
package titles
import (
"os"
"fmt"
"path/filepath"
"sync"
"testing"
"eq2emu/internal/database"
"time"
)
// Test Title struct creation and basic methods
func TestNewTitle(t *testing.T) {
title := NewTitle(1, "Test Title")
if title == nil {
@ -26,6 +29,108 @@ func TestNewTitle(t *testing.T) {
}
}
func TestTitleFlags(t *testing.T) {
title := NewTitle(1, "Test Title")
// Test setting flags
title.SetFlag(FlagHidden)
if !title.HasFlag(FlagHidden) {
t.Error("Expected title to have FlagHidden after setting")
}
title.SetFlag(FlagTemporary)
if !title.HasFlag(FlagTemporary) {
t.Error("Expected title to have FlagTemporary after setting")
}
// Test clearing flags
title.ClearFlag(FlagHidden)
if title.HasFlag(FlagHidden) {
t.Error("Expected title not to have FlagHidden after clearing")
}
// Test toggle flags (manually implement toggle)
if title.HasFlag(FlagUnique) {
title.ClearFlag(FlagUnique)
} else {
title.SetFlag(FlagUnique)
}
if !title.HasFlag(FlagUnique) {
t.Error("Expected title to have FlagUnique after toggling")
}
// Toggle again
if title.HasFlag(FlagUnique) {
title.ClearFlag(FlagUnique)
} else {
title.SetFlag(FlagUnique)
}
if title.HasFlag(FlagUnique) {
t.Error("Expected title not to have FlagUnique after toggling again")
}
}
func TestTitleProperties(t *testing.T) {
title := NewTitle(1, "Test Title")
// Test description
title.SetDescription("A test description")
if title.Description != "A test description" {
t.Errorf("Expected description 'A test description', got '%s'", title.Description)
}
// Test category
title.SetCategory(CategoryAchievement)
if title.Category != CategoryAchievement {
t.Errorf("Expected category '%s', got '%s'", CategoryAchievement, title.Category)
}
// Test rarity
title.SetRarity(TitleRarityRare)
if title.Rarity != TitleRarityRare {
t.Errorf("Expected rarity %d, got %d", TitleRarityRare, title.Rarity)
}
// Test display name
title.Position = TitlePositionPrefix
displayName := title.GetDisplayName()
if displayName != "Test Title" {
t.Errorf("Expected display name 'Test Title', got '%s'", displayName)
}
}
func TestTitleTypeMethods(t *testing.T) {
title := NewTitle(1, "Test Title")
// Test hidden flag
title.SetFlag(FlagHidden)
if !title.IsHidden() {
t.Error("Expected title to be hidden")
}
// Test temporary flag
title.ClearFlag(FlagHidden)
title.SetFlag(FlagTemporary)
if !title.IsTemporary() {
t.Error("Expected title to be temporary")
}
// Test unique flag
title.ClearFlag(FlagTemporary)
title.SetFlag(FlagUnique)
if !title.IsUnique() {
t.Error("Expected title to be unique")
}
// Test account-wide flag
title.ClearFlag(FlagUnique)
title.SetFlag(FlagAccountWide)
if !title.IsAccountWide() {
t.Error("Expected title to be account-wide")
}
}
// Test MasterTitlesList
func TestMasterTitlesList(t *testing.T) {
mtl := NewMasterTitlesList()
if mtl == nil {
@ -33,72 +138,300 @@ func TestMasterTitlesList(t *testing.T) {
}
// Test default titles are loaded
citizen := mtl.GetTitle(TitleIDCitizen)
if citizen == nil {
citizen, exists := mtl.GetTitle(TitleIDCitizen)
if !exists || citizen == nil {
t.Error("Expected Citizen title to be loaded by default")
} else if citizen.Name != "Citizen" {
t.Errorf("Expected Citizen title name, got '%s'", citizen.Name)
}
visitor := mtl.GetTitle(TitleIDVisitor)
if visitor == nil {
visitor, exists := mtl.GetTitle(TitleIDVisitor)
if !exists || visitor == nil {
t.Error("Expected Visitor title to be loaded by default")
}
newcomer, exists := mtl.GetTitle(TitleIDNewcomer)
if !exists || newcomer == nil {
t.Error("Expected Newcomer title to be loaded by default")
}
returning, exists := mtl.GetTitle(TitleIDReturning)
if !exists || returning == nil {
t.Error("Expected Returning title to be loaded by default")
}
// Test adding new title
testTitle := NewTitle(100, "Test Title")
testTitle := NewTitle(0, "Test Title") // ID will be assigned
testTitle.SetDescription("Test description")
testTitle.SetCategory(CategoryMiscellaneous)
err := mtl.AddTitle(testTitle)
if err != nil {
t.Fatalf("Failed to add title: %v", err)
}
retrieved := mtl.GetTitle(100)
if retrieved == nil {
// ID should have been assigned
if testTitle.ID == 0 {
t.Error("Title ID should have been assigned")
}
retrieved, exists := mtl.GetTitle(testTitle.ID)
if !exists || retrieved == nil {
t.Error("Failed to retrieve added title")
} else if retrieved.Name != "Test Title" {
t.Errorf("Expected retrieved title name 'Test Title', got '%s'", retrieved.Name)
}
// Test duplicate ID
duplicateTitle := NewTitle(testTitle.ID, "Duplicate")
err = mtl.AddTitle(duplicateTitle)
if err == nil {
t.Error("Should have failed to add title with duplicate ID")
}
}
func TestMasterTitlesListCategories(t *testing.T) {
mtl := NewMasterTitlesList()
// Add titles in different categories
for i := 0; i < 3; i++ {
title := NewTitle(0, fmt.Sprintf("Achievement Title %d", i))
title.SetCategory(CategoryAchievement)
mtl.AddTitle(title)
}
for i := 0; i < 2; i++ {
title := NewTitle(0, fmt.Sprintf("Quest Title %d", i))
title.SetCategory(CategoryQuest)
mtl.AddTitle(title)
}
// Test getting titles by category
achievementTitles := mtl.GetTitlesByCategory(CategoryAchievement)
if len(achievementTitles) < 3 {
t.Errorf("Expected at least 3 achievement titles, got %d", len(achievementTitles))
}
questTitles := mtl.GetTitlesByCategory(CategoryQuest)
if len(questTitles) < 2 {
t.Errorf("Expected at least 2 quest titles, got %d", len(questTitles))
}
// Test getting available categories
categories := mtl.GetAvailableCategories()
hasAchievement := false
hasQuest := false
for _, cat := range categories {
if cat == CategoryAchievement {
hasAchievement = true
}
if cat == CategoryQuest {
hasQuest = true
}
}
if !hasAchievement {
t.Error("Expected CategoryAchievement in available categories")
}
if !hasQuest {
t.Error("Expected CategoryQuest in available categories")
}
}
func TestMasterTitlesListSourceAndRarity(t *testing.T) {
mtl := NewMasterTitlesList()
// Add titles with different sources
title1 := NewTitle(0, "Achievement Source")
title1.Source = TitleSourceAchievement
title1.SetRarity(TitleRarityCommon)
mtl.AddTitle(title1)
title2 := NewTitle(0, "Quest Source")
title2.Source = TitleSourceQuest
title2.SetRarity(TitleRarityRare)
mtl.AddTitle(title2)
title3 := NewTitle(0, "PvP Source")
title3.Source = TitleSourcePvP
title3.SetRarity(TitleRarityEpic)
mtl.AddTitle(title3)
// Test getting titles by source
achievementSourceTitles := mtl.GetTitlesBySource(TitleSourceAchievement)
found := false
for _, t := range achievementSourceTitles {
if t.Name == "Achievement Source" {
found = true
break
}
}
if !found {
t.Error("Failed to find achievement source title")
}
// Test getting titles by rarity
rareTitles := mtl.GetTitlesByRarity(TitleRarityRare)
found = false
for _, t := range rareTitles {
if t.Name == "Quest Source" {
found = true
break
}
}
if !found {
t.Error("Failed to find rare title")
}
}
// Test PlayerTitlesList
func TestPlayerTitlesList(t *testing.T) {
mtl := NewMasterTitlesList()
// Add some titles to master list
title1 := NewTitle(100, "Title One")
title1.Position = TitlePositionPrefix
mtl.AddTitle(title1)
title2 := NewTitle(101, "Title Two")
title2.Position = TitlePositionSuffix
mtl.AddTitle(title2)
ptl := NewPlayerTitlesList(123, mtl)
if ptl == nil {
t.Fatal("NewPlayerTitlesList returned nil")
}
// Test adding title
err := ptl.AddTitle(TitleIDCitizen, 0, 0)
err := ptl.AddTitle(100, 0, 0)
if err != nil {
t.Fatalf("Failed to add title to player: %v", err)
}
// Test getting titles
titles := ptl.GetTitles()
if len(titles) != 1 {
t.Errorf("Expected 1 title, got %d", len(titles))
}
// Test setting active title
err = ptl.SetActiveTitle(TitleIDCitizen, TitlePositionSuffix)
err = ptl.AddTitle(101, 0, 0)
if err != nil {
t.Fatalf("Failed to set active title: %v", err)
t.Fatalf("Failed to add second title to player: %v", err)
}
// Test getting active titles
activePrefix, activeSuffix := ptl.GetActiveTitles()
if activePrefix != 0 {
t.Errorf("Expected no active prefix, got %d", activePrefix)
// Test getting titles (player starts with default Citizen title)
titles := ptl.GetAllTitles()
expectedTitleCount := 3 // Citizen + our 2 titles
if len(titles) != expectedTitleCount {
t.Errorf("Expected %d titles, got %d", expectedTitleCount, len(titles))
}
if activeSuffix != TitleIDCitizen {
t.Errorf("Expected active suffix %d, got %d", TitleIDCitizen, activeSuffix)
// Test has title
if !ptl.HasTitle(100) {
t.Error("Expected player to have title 100")
}
if !ptl.HasTitle(101) {
t.Error("Expected player to have title 101")
}
if ptl.HasTitle(999) {
t.Error("Expected player not to have title 999")
}
// Test setting active prefix
err = ptl.SetActivePrefix(100)
if err != nil {
t.Fatalf("Failed to set active prefix: %v", err)
}
activePrefixTitle, hasPrefix := ptl.GetActivePrefixTitle()
if !hasPrefix || activePrefixTitle.ID != 100 {
t.Errorf("Expected active prefix 100, got title ID: %v", activePrefixTitle)
}
// Test setting active suffix
err = ptl.SetActiveSuffix(101)
if err != nil {
t.Fatalf("Failed to set active suffix: %v", err)
}
activeSuffixTitle, hasSuffix := ptl.GetActiveSuffixTitle()
if !hasSuffix || activeSuffixTitle.ID != 101 {
t.Errorf("Expected active suffix 101, got title ID: %v", activeSuffixTitle)
}
// Test formatted name (the actual format may vary)
formattedName := ptl.GetFormattedName("PlayerName")
// Just check that it contains both titles and the player name
if !contains(formattedName, "Title One") {
t.Errorf("Formatted name should contain 'Title One', got '%s'", formattedName)
}
if !contains(formattedName, "Title Two") {
t.Errorf("Formatted name should contain 'Title Two', got '%s'", formattedName)
}
if !contains(formattedName, "PlayerName") {
t.Errorf("Formatted name should contain 'PlayerName', got '%s'", formattedName)
}
// Test removing title
err = ptl.RemoveTitle(100)
if err != nil {
t.Fatalf("Failed to remove title: %v", err)
}
if ptl.HasTitle(100) {
t.Error("Title 100 should have been removed")
}
// Active prefix should be cleared
_, hasPrefix = ptl.GetActivePrefixTitle()
if hasPrefix {
t.Error("Active prefix should be cleared after removing the title")
}
}
func TestPlayerTitlesExpiration(t *testing.T) {
mtl := NewMasterTitlesList()
// Add a temporary title to master list
tempTitle := NewTitle(200, "Temporary Title")
tempTitle.SetFlag(FlagTemporary)
tempTitle.ExpirationHours = 1 // Expires after 1 hour
mtl.AddTitle(tempTitle)
ptl := NewPlayerTitlesList(456, mtl)
// Add the temporary title
err := ptl.AddTitle(200, 0, 0)
if err != nil {
t.Fatalf("Failed to add temporary title: %v", err)
}
// Title should exist
if !ptl.HasTitle(200) {
t.Error("Expected player to have temporary title")
}
// Manually set expiration to past
if playerTitle, exists := ptl.titles[200]; exists {
playerTitle.ExpiresAt = time.Now().Add(-1 * time.Hour)
}
// Clean up expired titles
expired := ptl.CleanupExpiredTitles()
if expired != 1 {
t.Errorf("Expected 1 expired title, got %d", expired)
}
// Title should be removed
if ptl.HasTitle(200) {
t.Error("Expired title should have been removed")
}
}
// Test TitleManager
func TestTitleManager(t *testing.T) {
tm := NewTitleManager()
if tm == nil {
t.Fatal("NewTitleManager returned nil")
}
defer tm.Shutdown()
// Test getting player titles
playerTitles := tm.GetPlayerTitles(456)
@ -106,40 +439,235 @@ func TestTitleManager(t *testing.T) {
t.Error("GetPlayerTitles returned nil")
}
// Test adding title for player
err := tm.GrantTitle(456, TitleIDCitizen, 0)
// Test granting title (use Visitor instead of Citizen to avoid conflicts)
err := tm.GrantTitle(456, TitleIDVisitor, 0, 0)
if err != nil {
t.Fatalf("Failed to grant title: %v", err)
}
// Verify title was granted
// Verify title was granted (player starts with default Citizen title)
playerTitles = tm.GetPlayerTitles(456)
titles := playerTitles.GetTitles()
titles := playerTitles.GetAllTitles()
expectedCount := 2 // Citizen + Visitor
if len(titles) != expectedCount {
t.Errorf("Expected %d titles for player, got %d", expectedCount, len(titles))
}
// Verify the player has the visitor title
if !playerTitles.HasTitle(TitleIDVisitor) {
t.Error("Player should have Visitor title")
}
// Test revoking title
err = tm.RevokeTitle(456, TitleIDVisitor)
if err != nil {
t.Fatalf("Failed to revoke title: %v", err)
}
// Verify title was revoked (should still have Citizen)
playerTitles = tm.GetPlayerTitles(456)
titles = playerTitles.GetAllTitles()
if len(titles) != 1 {
t.Errorf("Expected 1 title for player, got %d", len(titles))
t.Errorf("Expected 1 title after revoke, got %d", len(titles))
}
}
func TestTitleDatabaseIntegration(t *testing.T) {
// Create temporary database
tempFile := "test_titles.db"
defer os.Remove(tempFile)
func TestTitleManagerCreateVariants(t *testing.T) {
tm := NewTitleManager()
defer tm.Shutdown()
// Test creating regular title
title, err := tm.CreateTitle("Regular Title", "A regular title", CategoryMiscellaneous, TitlePositionSuffix, TitleSourceQuest, TitleRarityCommon)
if err != nil {
t.Fatalf("Failed to create regular title: %v", err)
}
if title == nil {
t.Fatal("CreateTitle returned nil")
}
// Test creating achievement title
achievementTitle, err := tm.CreateAchievementTitle("Achievement Title", "An achievement title", 1000, TitlePositionPrefix, TitleRarityRare)
if err != nil {
t.Fatalf("Failed to create achievement title: %v", err)
}
if achievementTitle.AchievementID != 1000 {
t.Errorf("Expected achievement ID 1000, got %d", achievementTitle.AchievementID)
}
// Test creating temporary title
tempTitle, err := tm.CreateTemporaryTitle("Temp Title", "A temporary title", 24, TitlePositionSuffix, TitleSourceHoliday, TitleRarityCommon)
if err != nil {
t.Fatalf("Failed to create temporary title: %v", err)
}
if !tempTitle.HasFlag(FlagTemporary) {
t.Error("Temporary title should have FlagTemporary")
}
if tempTitle.ExpirationHours != 24 {
t.Errorf("Expected expiration hours 24, got %d", tempTitle.ExpirationHours)
}
// Test creating unique title
uniqueTitle, err := tm.CreateUniqueTitle("Unique Title", "A unique title", TitlePositionPrefix, TitleSourceMiscellaneous)
if err != nil {
t.Fatalf("Failed to create unique title: %v", err)
}
if !uniqueTitle.HasFlag(FlagUnique) {
t.Error("Unique title should have FlagUnique")
}
if uniqueTitle.Rarity != TitleRarityUnique {
t.Errorf("Expected rarity %d for unique title, got %d", TitleRarityUnique, uniqueTitle.Rarity)
}
}
db, err := database.Open(tempFile)
func TestTitleManagerSearch(t *testing.T) {
tm := NewTitleManager()
defer tm.Shutdown()
// Create test titles
tm.CreateTitle("Dragon Slayer", "Defeated a dragon", CategoryCombat, TitlePositionSuffix, TitleSourceQuest, TitleRarityEpic)
tm.CreateTitle("Master Crafter", "Master of crafting", CategoryTradeskill, TitlePositionPrefix, TitleSourceTradeskill, TitleRarityRare)
tm.CreateTitle("Explorer", "Explored the world", CategoryExploration, TitlePositionSuffix, TitleSourceAchievement, TitleRarityCommon)
// Test search
results := tm.SearchTitles("dragon")
found := false
for _, title := range results {
if title.Name == "Dragon Slayer" {
found = true
break
}
}
if !found {
t.Error("Failed to find 'Dragon Slayer' in search results")
}
// Test search with description
results = tm.SearchTitles("crafting")
found = false
for _, title := range results {
if title.Name == "Master Crafter" {
found = true
break
}
}
if !found {
t.Error("Failed to find 'Master Crafter' when searching description")
}
}
func TestTitleManagerStatistics(t *testing.T) {
tm := NewTitleManager()
defer tm.Shutdown()
// Grant some titles (avoid Citizen which may be default)
tm.GrantTitle(1, TitleIDVisitor, 0, 0)
tm.GrantTitle(2, TitleIDNewcomer, 0, 0)
tm.GrantTitle(1, TitleIDReturning, 0, 0)
stats := tm.GetStatistics()
// Check statistics
if totalPlayers, ok := stats["total_players"].(int); ok {
if totalPlayers != 2 {
t.Errorf("Expected 2 total players, got %d", totalPlayers)
}
} else {
t.Error("Missing total_players in statistics")
}
if titlesGranted, ok := stats["titles_granted"].(int64); ok {
if titlesGranted != 3 {
t.Errorf("Expected 3 titles granted, got %d", titlesGranted)
}
} else {
t.Error("Missing titles_granted in statistics")
}
}
func TestTitleManagerConcurrency(t *testing.T) {
tm := NewTitleManager()
defer tm.Shutdown()
// Test concurrent access to player titles
var wg sync.WaitGroup
numPlayers := 10
numOperations := 100
for i := 0; i < numPlayers; i++ {
wg.Add(1)
go func(playerID int32) {
defer wg.Done()
for j := 0; j < numOperations; j++ {
// Grant title
tm.GrantTitle(playerID, TitleIDCitizen, 0, 0)
// Get player titles
ptl := tm.GetPlayerTitles(playerID)
_ = ptl.GetAllTitles()
// Set active title
tm.SetPlayerActivePrefix(playerID, TitleIDCitizen)
// Get formatted name
tm.GetPlayerFormattedName(playerID, fmt.Sprintf("Player%d", playerID))
// Revoke title
tm.RevokeTitle(playerID, TitleIDCitizen)
}
}(int32(i))
}
wg.Wait()
// Verify no crashes or data races
stats := tm.GetStatistics()
if stats == nil {
t.Error("Failed to get statistics after concurrent operations")
}
}
// Test Database Integration
func TestDatabaseIntegration(t *testing.T) {
// Create temporary database
tempDir := t.TempDir()
dbPath := filepath.Join(tempDir, "test_titles.db")
db, err := OpenDB(dbPath)
if err != nil {
t.Fatalf("Failed to open database: %v", err)
}
defer db.Close()
// Create tables
err = db.CreateTables()
if err != nil {
t.Fatalf("Failed to create tables: %v", err)
}
// Test master list database operations
mtl := NewMasterTitlesList()
// Test saving to database
// Add some test titles
title1 := NewTitle(500, "Database Test Title 1")
title1.SetDescription("Test description 1")
title1.SetCategory(CategoryMiscellaneous)
title1.Position = TitlePositionPrefix // Set as prefix title
mtl.AddTitle(title1)
title2 := NewTitle(501, "Database Test Title 2")
title2.SetDescription("Test description 2")
title2.SetCategory(CategoryAchievement)
title2.Position = TitlePositionSuffix // Set as suffix title
title2.AchievementID = 2000
mtl.AddTitle(title2)
// Save to database
err = mtl.SaveToDatabase(db)
if err != nil {
t.Fatalf("Failed to save master titles to database: %v", err)
}
// Create new master list and load from database
mtl2 := &MasterTitlesList{
titles: make(map[int32]*Title),
@ -149,65 +677,186 @@ func TestTitleDatabaseIntegration(t *testing.T) {
byAchievement: make(map[uint32]*Title),
nextID: 1,
}
err = mtl2.LoadFromDatabase(db)
if err != nil {
t.Fatalf("Failed to load master titles from database: %v", err)
}
// Verify titles were loaded
citizen := mtl2.GetTitle(TitleIDCitizen)
if citizen == nil {
t.Error("Failed to load Citizen title from database")
// Verify loaded titles
loadedTitle1, exists := mtl2.GetTitle(500)
if !exists || loadedTitle1 == nil {
t.Fatal("Failed to load title 500 from database")
}
if loadedTitle1.Name != "Database Test Title 1" {
t.Errorf("Expected title name 'Database Test Title 1', got '%s'", loadedTitle1.Name)
}
loadedTitle2, exists := mtl2.GetTitle(501)
if !exists || loadedTitle2 == nil {
t.Fatal("Failed to load title 501 from database")
}
if loadedTitle2.AchievementID != 2000 {
t.Errorf("Expected achievement ID 2000, got %d", loadedTitle2.AchievementID)
}
// Test player titles database operations
ptl := NewPlayerTitlesList(789, mtl)
err = ptl.AddTitle(TitleIDCitizen, 0, 0)
if err != nil {
t.Fatalf("Failed to add title to player: %v", err)
}
ptl := NewPlayerTitlesList(789, mtl2) // Use mtl2 which has titles loaded from database
ptl.AddTitle(500, 0, 0)
ptl.AddTitle(501, 2000, 0)
ptl.SetActivePrefix(500)
ptl.SetActiveSuffix(501)
// Save player titles
err = ptl.SaveToDatabase(db)
if err != nil {
t.Fatalf("Failed to save player titles to database: %v", err)
}
// Load player titles
ptl2 := NewPlayerTitlesList(789, mtl)
ptl2 := NewPlayerTitlesList(789, mtl2)
err = ptl2.LoadFromDatabase(db)
if err != nil {
t.Fatalf("Failed to load player titles from database: %v", err)
}
// Verify player titles were loaded
titles := ptl2.GetTitles()
if len(titles) != 1 {
t.Errorf("Expected 1 loaded title, got %d", len(titles))
// Verify loaded player titles
if !ptl2.HasTitle(500) {
t.Error("Expected player to have title 500")
}
if !ptl2.HasTitle(501) {
t.Error("Expected player to have title 501")
}
activePrefixTitle, hasPrefix := ptl2.GetActivePrefixTitle()
if !hasPrefix {
t.Error("Expected to have active prefix title")
} else if activePrefixTitle.ID != 500 {
t.Errorf("Expected active prefix 500, got %d", activePrefixTitle.ID)
}
activeSuffixTitle, hasSuffix := ptl2.GetActiveSuffixTitle()
if !hasSuffix {
t.Error("Expected to have active suffix title")
} else if activeSuffixTitle.ID != 501 {
t.Errorf("Expected active suffix 501, got %d", activeSuffixTitle.ID)
}
}
func TestTitleValidation(t *testing.T) {
func TestDatabaseHelperFunctions(t *testing.T) {
// Test nullableUint32
if val := nullableUint32(0); val != nil {
t.Error("nullableUint32(0) should return nil")
}
if val := nullableUint32(123); val != uint32(123) {
t.Errorf("nullableUint32(123) should return 123, got %v", val)
}
// Test nullableTime
zeroTime := time.Time{}
if val := nullableTime(zeroTime); val != nil {
t.Error("nullableTime(zero) should return nil")
}
now := time.Now()
if val := nullableTime(now); val != now.Unix() {
t.Errorf("nullableTime(now) should return Unix timestamp, got %v", val)
}
}
// Benchmark tests
func BenchmarkTitleCreation(b *testing.B) {
for i := 0; i < b.N; i++ {
title := NewTitle(int32(i), fmt.Sprintf("Title %d", i))
title.SetDescription("Description")
title.SetCategory(CategoryMiscellaneous)
}
}
func BenchmarkMasterListAdd(b *testing.B) {
mtl := NewMasterTitlesList()
b.ResetTimer()
for i := 0; i < b.N; i++ {
title := NewTitle(int32(i+1000), fmt.Sprintf("Title %d", i))
mtl.AddTitle(title)
}
}
func BenchmarkPlayerListAdd(b *testing.B) {
mtl := NewMasterTitlesList()
// Test nil title
err := mtl.AddTitle(nil)
if err == nil {
t.Error("Expected error when adding nil title")
// Pre-populate master list
for i := 0; i < 1000; i++ {
title := NewTitle(int32(i), fmt.Sprintf("Title %d", i))
mtl.AddTitle(title)
}
// Test duplicate ID
title1 := NewTitle(999, "Title 1")
title2 := NewTitle(999, "Title 2")
err = mtl.AddTitle(title1)
if err != nil {
t.Fatalf("Failed to add first title: %v", err)
ptl := NewPlayerTitlesList(1, mtl)
b.ResetTimer()
for i := 0; i < b.N; i++ {
titleID := int32(i % 1000)
ptl.AddTitle(titleID, 0, 0)
ptl.RemoveTitle(titleID)
}
}
err = mtl.AddTitle(title2)
if err == nil {
t.Error("Expected error when adding title with duplicate ID")
func BenchmarkTitleManagerGrant(b *testing.B) {
tm := NewTitleManager()
defer tm.Shutdown()
// Pre-create titles
for i := 0; i < 100; i++ {
tm.CreateTitle(fmt.Sprintf("Title %d", i), "Description", CategoryMiscellaneous, TitlePositionSuffix, TitleSourceQuest, TitleRarityCommon)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
playerID := int32(i % 100)
titleID := int32((i % 100) + 1)
tm.GrantTitle(playerID, titleID, 0, 0)
}
}
func BenchmarkTitleSearch(b *testing.B) {
tm := NewTitleManager()
defer tm.Shutdown()
// Create fewer titles for faster benchmark
for i := 0; i < 100; i++ {
name := fmt.Sprintf("Title %d", i)
if i%10 == 0 {
name = fmt.Sprintf("Dragon %d", i)
}
tm.CreateTitle(name, "Description", CategoryMiscellaneous, TitlePositionSuffix, TitleSourceQuest, TitleRarityCommon)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
tm.SearchTitles("dragon")
}
}
func BenchmarkConcurrentAccess(b *testing.B) {
tm := NewTitleManager()
defer tm.Shutdown()
// Pre-populate with titles
for i := 0; i < 10; i++ {
tm.CreateTitle(fmt.Sprintf("Title %d", i), "Description", CategoryMiscellaneous, TitlePositionSuffix, TitleSourceQuest, TitleRarityCommon)
}
b.ResetTimer()
// Use simpler sequential approach instead of RunParallel to avoid deadlocks
for i := 0; i < b.N; i++ {
playerID := int32(i % 10)
titleID := int32((i % 10) + 1)
tm.GrantTitle(playerID, titleID, 0, 0)
tm.GetPlayerFormattedName(playerID, "Player")
}
}