eq2go/internal/titles/player_titles.go

553 lines
15 KiB
Go

package titles
import (
"fmt"
"sync"
"time"
"eq2emu/internal/database"
)
// PlayerTitlesList manages titles owned by a specific player
type PlayerTitlesList struct {
playerID int32 // Character ID
titles map[int32]*PlayerTitle // Owned titles indexed by title ID
activePrefixID int32 // Currently active prefix title ID (0 = none)
activeSuffixID int32 // Currently active suffix title ID (0 = none)
masterList *MasterTitlesList // Reference to master titles list
mutex sync.RWMutex // Thread safety
}
// TitlePacketData represents title data for network packets
type TitlePacketData struct {
PlayerID uint32 `json:"player_id"`
PlayerName string `json:"player_name"`
PrefixTitle string `json:"prefix_title"`
SuffixTitle string `json:"suffix_title"`
SubTitle string `json:"sub_title"`
LastName string `json:"last_name"`
Titles []TitleEntry `json:"titles"`
NumTitles uint16 `json:"num_titles"`
CurrentPrefix int16 `json:"current_prefix"`
CurrentSuffix int16 `json:"current_suffix"`
}
// TitleEntry represents a single title for packet transmission
type TitleEntry struct {
Name string `json:"name"`
IsPrefix bool `json:"is_prefix"`
}
// NewPlayerTitlesList creates a new player titles list
func NewPlayerTitlesList(playerID int32, masterList *MasterTitlesList) *PlayerTitlesList {
ptl := &PlayerTitlesList{
playerID: playerID,
titles: make(map[int32]*PlayerTitle),
activePrefixID: TitleIDNone,
activeSuffixID: TitleIDCitizen, // Default suffix title
masterList: masterList,
}
// Grant default citizen title
ptl.grantDefaultTitle()
return ptl
}
// grantDefaultTitle grants the basic citizen title to new players
func (ptl *PlayerTitlesList) grantDefaultTitle() {
citizenTitle := NewPlayerTitle(TitleIDCitizen, ptl.playerID)
citizenTitle.IsActive = true
citizenTitle.IsPrefix = false // Suffix title
ptl.titles[TitleIDCitizen] = citizenTitle
}
// AddTitle grants a title to the player
func (ptl *PlayerTitlesList) AddTitle(titleID int32, sourceAchievementID, sourceQuestID uint32) error {
ptl.mutex.Lock()
defer ptl.mutex.Unlock()
// Check if player already has this title
if _, exists := ptl.titles[titleID]; exists {
return fmt.Errorf("player %d already has title %d", ptl.playerID, titleID)
}
// Verify title exists in master list
masterTitle, exists := ptl.masterList.GetTitle(titleID)
if !exists {
return fmt.Errorf("title %d does not exist in master list", titleID)
}
// Check if we've hit the maximum title limit
if len(ptl.titles) >= MaxPlayerTitles {
return fmt.Errorf("player %d has reached maximum title limit of %d", ptl.playerID, MaxPlayerTitles)
}
// Check for unique title restrictions
if masterTitle.IsUnique() {
// TODO: Check database to ensure no other player has this unique title
}
// Create player title entry
playerTitle := NewPlayerTitle(titleID, ptl.playerID)
playerTitle.AchievementID = sourceAchievementID
playerTitle.QuestID = sourceQuestID
// Set expiration if it's a temporary title
if masterTitle.ExpirationHours > 0 {
playerTitle.SetExpiration(masterTitle.ExpirationHours)
}
ptl.titles[titleID] = playerTitle
return nil
}
// RemoveTitle removes a title from the player
func (ptl *PlayerTitlesList) RemoveTitle(titleID int32) error {
ptl.mutex.Lock()
defer ptl.mutex.Unlock()
playerTitle, exists := ptl.titles[titleID]
if !exists {
return fmt.Errorf("player %d does not have title %d", ptl.playerID, titleID)
}
// If this title is currently active, deactivate it
if playerTitle.IsActive {
if playerTitle.IsPrefix && ptl.activePrefixID == titleID {
ptl.activePrefixID = TitleIDNone
} else if !playerTitle.IsPrefix && ptl.activeSuffixID == titleID {
ptl.activeSuffixID = TitleIDCitizen // Revert to default
}
}
delete(ptl.titles, titleID)
return nil
}
// HasTitle checks if the player owns a specific title
func (ptl *PlayerTitlesList) HasTitle(titleID int32) bool {
ptl.mutex.RLock()
defer ptl.mutex.RUnlock()
_, exists := ptl.titles[titleID]
return exists
}
// GetTitle retrieves a player's title information
func (ptl *PlayerTitlesList) GetTitle(titleID int32) (*PlayerTitle, bool) {
ptl.mutex.RLock()
defer ptl.mutex.RUnlock()
title, exists := ptl.titles[titleID]
if !exists {
return nil, false
}
return title.Clone(), true
}
// SetActivePrefix sets the active prefix title
func (ptl *PlayerTitlesList) SetActivePrefix(titleID int32) error {
ptl.mutex.Lock()
defer ptl.mutex.Unlock()
// Allow clearing prefix title
if titleID == TitleIDNone {
// Deactivate current prefix if any
if ptl.activePrefixID != TitleIDNone {
if currentTitle, exists := ptl.titles[ptl.activePrefixID]; exists {
currentTitle.IsActive = false
}
}
ptl.activePrefixID = TitleIDNone
return nil
}
// Check if player owns the title
playerTitle, exists := ptl.titles[titleID]
if !exists {
return fmt.Errorf("player %d does not own title %d", ptl.playerID, titleID)
}
// Verify title can be used as prefix
masterTitle, exists := ptl.masterList.GetTitle(titleID)
if !exists {
return fmt.Errorf("title %d not found in master list", titleID)
}
if masterTitle.Position != TitlePositionPrefix {
return fmt.Errorf("title %d cannot be used as prefix", titleID)
}
// Check if title has expired
if playerTitle.IsExpired() {
return fmt.Errorf("title %d has expired", titleID)
}
// Deactivate current prefix
if ptl.activePrefixID != TitleIDNone {
if currentTitle, exists := ptl.titles[ptl.activePrefixID]; exists {
currentTitle.IsActive = false
}
}
// Activate new prefix
playerTitle.Activate(true)
ptl.activePrefixID = titleID
return nil
}
// SetActiveSuffix sets the active suffix title
func (ptl *PlayerTitlesList) SetActiveSuffix(titleID int32) error {
ptl.mutex.Lock()
defer ptl.mutex.Unlock()
// Check if player owns the title
playerTitle, exists := ptl.titles[titleID]
if !exists {
return fmt.Errorf("player %d does not own title %d", ptl.playerID, titleID)
}
// Verify title can be used as suffix
masterTitle, exists := ptl.masterList.GetTitle(titleID)
if !exists {
return fmt.Errorf("title %d not found in master list", titleID)
}
if masterTitle.Position != TitlePositionSuffix {
return fmt.Errorf("title %d cannot be used as suffix", titleID)
}
// Check if title has expired
if playerTitle.IsExpired() {
return fmt.Errorf("title %d has expired", titleID)
}
// Deactivate current suffix
if ptl.activeSuffixID != TitleIDNone {
if currentTitle, exists := ptl.titles[ptl.activeSuffixID]; exists {
currentTitle.IsActive = false
}
}
// Activate new suffix
playerTitle.Activate(false)
ptl.activeSuffixID = titleID
return nil
}
// GetActivePrefixTitle returns the currently active prefix title
func (ptl *PlayerTitlesList) GetActivePrefixTitle() (*Title, bool) {
ptl.mutex.RLock()
defer ptl.mutex.RUnlock()
if ptl.activePrefixID == TitleIDNone {
return nil, false
}
return ptl.masterList.GetTitle(ptl.activePrefixID)
}
// GetActiveSuffixTitle returns the currently active suffix title
func (ptl *PlayerTitlesList) GetActiveSuffixTitle() (*Title, bool) {
ptl.mutex.RLock()
defer ptl.mutex.RUnlock()
if ptl.activeSuffixID == TitleIDNone {
return nil, false
}
return ptl.masterList.GetTitle(ptl.activeSuffixID)
}
// GetAllTitles returns all titles owned by the player
func (ptl *PlayerTitlesList) GetAllTitles() []*PlayerTitle {
ptl.mutex.RLock()
defer ptl.mutex.RUnlock()
result := make([]*PlayerTitle, 0, len(ptl.titles))
for _, title := range ptl.titles {
result = append(result, title.Clone())
}
return result
}
// GetAvailablePrefixTitles returns all titles that can be used as prefix
func (ptl *PlayerTitlesList) GetAvailablePrefixTitles() []*Title {
ptl.mutex.RLock()
defer ptl.mutex.RUnlock()
result := make([]*Title, 0)
for titleID := range ptl.titles {
if masterTitle, exists := ptl.masterList.GetTitle(titleID); exists {
if masterTitle.Position == TitlePositionPrefix {
// Check if not expired
if playerTitle := ptl.titles[titleID]; !playerTitle.IsExpired() {
result = append(result, masterTitle)
}
}
}
}
return result
}
// GetAvailableSuffixTitles returns all titles that can be used as suffix
func (ptl *PlayerTitlesList) GetAvailableSuffixTitles() []*Title {
ptl.mutex.RLock()
defer ptl.mutex.RUnlock()
result := make([]*Title, 0)
for titleID := range ptl.titles {
if masterTitle, exists := ptl.masterList.GetTitle(titleID); exists {
if masterTitle.Position == TitlePositionSuffix {
// Check if not expired
if playerTitle := ptl.titles[titleID]; !playerTitle.IsExpired() {
result = append(result, masterTitle)
}
}
}
}
return result
}
// CleanupExpiredTitles removes expired temporary titles
func (ptl *PlayerTitlesList) CleanupExpiredTitles() int {
ptl.mutex.Lock()
defer ptl.mutex.Unlock()
expiredCount := 0
expiredTitles := make([]int32, 0)
// Find expired titles
for titleID, playerTitle := range ptl.titles {
if playerTitle.IsExpired() {
expiredTitles = append(expiredTitles, titleID)
expiredCount++
}
}
// Remove expired titles
for _, titleID := range expiredTitles {
playerTitle := ptl.titles[titleID]
// If this expired title is currently active, deactivate it
if playerTitle.IsActive {
if playerTitle.IsPrefix && ptl.activePrefixID == titleID {
ptl.activePrefixID = TitleIDNone
} else if !playerTitle.IsPrefix && ptl.activeSuffixID == titleID {
ptl.activeSuffixID = TitleIDCitizen // Revert to default
}
}
delete(ptl.titles, titleID)
}
return expiredCount
}
// GetTitleCount returns the number of titles owned by the player
func (ptl *PlayerTitlesList) GetTitleCount() int {
ptl.mutex.RLock()
defer ptl.mutex.RUnlock()
return len(ptl.titles)
}
// BuildPacketData creates title data for network transmission
func (ptl *PlayerTitlesList) BuildPacketData(playerName string) *TitlePacketData {
ptl.mutex.RLock()
defer ptl.mutex.RUnlock()
data := &TitlePacketData{
PlayerID: uint32(ptl.playerID),
PlayerName: playerName,
PrefixTitle: "",
SuffixTitle: "",
SubTitle: "", // TODO: Implement subtitle system
LastName: "", // TODO: Implement last name system
Titles: make([]TitleEntry, 0, len(ptl.titles)),
CurrentPrefix: int16(ptl.activePrefixID),
CurrentSuffix: int16(ptl.activeSuffixID),
}
// Get active prefix title name
if prefixTitle, exists := ptl.GetActivePrefixTitle(); exists {
data.PrefixTitle = prefixTitle.GetDisplayName()
}
// Get active suffix title name
if suffixTitle, exists := ptl.GetActiveSuffixTitle(); exists {
data.SuffixTitle = suffixTitle.GetDisplayName()
}
// Build title array for UI
for titleID := range ptl.titles {
if masterTitle, exists := ptl.masterList.GetTitle(titleID); exists {
// Skip hidden titles unless it's a GM viewing
// TODO: Add GM check parameter
if masterTitle.IsHidden() {
continue
}
// Skip expired titles
if ptl.titles[titleID].IsExpired() {
continue
}
entry := TitleEntry{
Name: masterTitle.GetDisplayName(),
IsPrefix: masterTitle.Position == TitlePositionPrefix,
}
data.Titles = append(data.Titles, entry)
}
}
data.NumTitles = uint16(len(data.Titles))
return data
}
// GrantTitleFromAchievement grants a title when an achievement is completed
func (ptl *PlayerTitlesList) GrantTitleFromAchievement(achievementID uint32) error {
// Find title associated with this achievement
title, exists := ptl.masterList.GetTitleByAchievement(achievementID)
if !exists {
return nil // No title associated with this achievement
}
// Grant the title
return ptl.AddTitle(title.ID, achievementID, 0)
}
// LoadFromDatabase loads player titles from the database
func (ptl *PlayerTitlesList) LoadFromDatabase(db *database.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)
if err != nil {
return fmt.Errorf("failed to load player titles from database: %w", err)
}
return nil
}
// SaveToDatabase saves player titles to the database
func (ptl *PlayerTitlesList) SaveToDatabase(db *database.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)
}
// Insert all current titles
for _, playerTitle := range ptl.titles {
var achievementID interface{}
if playerTitle.AchievementID != 0 {
achievementID = playerTitle.AchievementID
}
var expirationDate interface{}
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
})
}
// GetFormattedName returns the player name with active titles applied
func (ptl *PlayerTitlesList) GetFormattedName(playerName string) string {
ptl.mutex.RLock()
defer ptl.mutex.RUnlock()
result := playerName
// Add prefix if active
if prefixTitle, exists := ptl.GetActivePrefixTitle(); exists {
result = prefixTitle.GetDisplayName() + " " + result
}
// Add suffix if active
if suffixTitle, exists := ptl.GetActiveSuffixTitle(); exists {
result = result + " " + suffixTitle.GetDisplayName()
}
return result
}