eq2go/internal/heroic_ops/heroic_ops.go
2025-08-29 14:18:05 -05:00

1390 lines
41 KiB
Go

package heroic_ops
import (
"context"
"fmt"
"math/rand"
"sort"
"sync"
"time"
"eq2emu/internal/database"
"eq2emu/internal/packets"
)
// Simplified Heroic Opportunities System
// Consolidates all functionality from 10 files into unified architecture
// Preserves 100% C++ functionality while eliminating Active Record patterns
// Core Data Structures
// HeroicOPStarter represents a starter chain for heroic opportunities
type HeroicOPStarter struct {
mu sync.RWMutex
ID int32 `json:"id"` // Unique identifier for this starter
StartClass int8 `json:"start_class"` // Class that can initiate this starter (0 = any)
StarterIcon int16 `json:"starter_icon"` // Icon displayed for the starter
Abilities [6]int16 `json:"abilities"` // Array of ability icons in sequence
Name string `json:"name"` // Display name for this starter
Description string `json:"description"` // Description text
SaveNeeded bool `json:"-"` // Flag indicating if database save is needed
// Database integration fields
isNew bool `json:"-"` // True if this is a new record not yet saved
}
// HeroicOPWheel represents the wheel phase of a heroic opportunity
type HeroicOPWheel struct {
mu sync.RWMutex
ID int32 `json:"id"` // Unique identifier for this wheel
StarterLinkID int32 `json:"starter_link_id"` // ID of the starter this wheel belongs to
Order int8 `json:"order"` // 0 = unordered, 1+ = ordered
ShiftIcon int16 `json:"shift_icon"` // Icon that can shift/change the wheel
Chance float32 `json:"chance"` // Probability factor for selecting this wheel
Abilities [6]int16 `json:"abilities"` // Array of ability icons for the wheel
SpellID int32 `json:"spell_id"` // Spell cast when HO completes successfully
Name string `json:"name"` // Display name for this wheel
Description string `json:"description"` // Description text
RequiredPlayers int8 `json:"required_players"` // Minimum players required
SaveNeeded bool `json:"-"` // Flag indicating if database save is needed
// Database integration fields
isNew bool `json:"-"` // True if this is a new record not yet saved
}
// HeroicOP represents an active heroic opportunity instance
type HeroicOP struct {
mu sync.RWMutex
ID int64 `json:"id"` // Unique instance ID
EncounterID int32 `json:"encounter_id"` // Encounter this HO belongs to
StarterID int32 `json:"starter_id"` // ID of the completed starter
WheelID int32 `json:"wheel_id"` // ID of the active wheel
State int8 `json:"state"` // Current HO state
StartTime time.Time `json:"start_time"` // When the HO started
WheelStartTime time.Time `json:"wheel_start_time"` // When wheel phase started
TimeRemaining int32 `json:"time_remaining"` // Milliseconds remaining
TotalTime int32 `json:"total_time"` // Total time allocated (ms)
Complete int8 `json:"complete"` // Completion status (0/1)
Countered [6]int8 `json:"countered"` // Which wheel abilities are completed
ShiftUsed int8 `json:"shift_used"` // Whether shift has been used
StarterProgress int8 `json:"starter_progress"` // Current position in starter chain
Participants map[int32]bool `json:"participants"` // Character IDs that participated
CurrentStarters []int32 `json:"current_starters"` // Active starter IDs during chain phase
CompletedBy int32 `json:"completed_by"` // Character ID that completed the HO
SpellName string `json:"spell_name"` // Name of completion spell
SpellDescription string `json:"spell_description"` // Description of completion spell
SaveNeeded bool `json:"-"` // Flag indicating if database save is needed
// Database integration fields
isNew bool `json:"-"` // True if this is a new record not yet saved
}
// HeroicOPManager manages the entire heroic opportunity system
type HeroicOPManager struct {
mu sync.RWMutex
database *database.Database
logger Logger
// Core data storage - O(1) access by ID
starters map[int32]*HeroicOPStarter // starter_id -> starter
wheels map[int32]*HeroicOPWheel // wheel_id -> wheel
activeHOs map[int64]*HeroicOP // instance_id -> HO
encounterHOs map[int32][]*HeroicOP // encounter_id -> HOs
// Specialized indices for O(1) lookups
byClass map[int8]map[int32]*HeroicOPStarter // class -> starter_id -> starter
byStarterID map[int32][]*HeroicOPWheel // starter_id -> wheels
bySpellID map[int32][]*HeroicOPWheel // spell_id -> wheels
byChance map[string][]*HeroicOPWheel // chance_range -> wheels
orderedWheels map[int32]*HeroicOPWheel // wheel_id -> ordered wheels only
shiftWheels map[int32]*HeroicOPWheel // wheel_id -> wheels with shifts
spellInfo map[int32]SpellInfo // spell_id -> spell info
// System state
nextInstanceID int64
loaded bool
// Configuration
defaultWheelTimer int32 // milliseconds
maxConcurrentHOs int
enableLogging bool
enableStatistics bool
// Statistics tracking
stats HeroicOPManagerStats
}
// Supporting structures
type SpellInfo struct {
ID int32 `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Icon int16 `json:"icon"`
}
type HeroicOPManagerStats struct {
TotalStarters int64 `json:"total_starters"`
TotalWheels int64 `json:"total_wheels"`
ClassDistribution map[int32]int64 `json:"class_distribution"`
OrderedWheelsCount int64 `json:"ordered_wheels_count"`
ShiftWheelsCount int64 `json:"shift_wheels_count"`
SpellCount int64 `json:"spell_count"`
AverageChance float64 `json:"average_chance"`
ActiveHOCount int64 `json:"active_ho_count"`
}
type PacketData struct {
SpellName string `json:"spell_name"`
SpellDescription string `json:"spell_description"`
TimeRemaining int32 `json:"time_remaining"` // milliseconds
TotalTime int32 `json:"total_time"` // milliseconds
Abilities [6]int16 `json:"abilities"` // Current wheel abilities
Countered [6]int8 `json:"countered"` // Completion status
Complete int8 `json:"complete"` // Overall completion (0/1)
State int8 `json:"state"` // Current HO state
CanShift bool `json:"can_shift"` // Whether shift is available
ShiftIcon int16 `json:"shift_icon"` // Icon for shift ability
}
type HeroicOPEvent struct {
ID int64 `json:"id"`
InstanceID int64 `json:"instance_id"`
EventType int `json:"event_type"`
CharacterID int32 `json:"character_id"`
AbilityIcon int16 `json:"ability_icon"`
Timestamp time.Time `json:"timestamp"`
Data string `json:"data"` // JSON encoded additional data
}
type HeroicOPConfig struct {
DefaultWheelTimer int32 `json:"default_wheel_timer"` // milliseconds
StarterChainTimeout int32 `json:"starter_chain_timeout"` // milliseconds
MaxConcurrentHOs int `json:"max_concurrent_hos"`
EnableLogging bool `json:"enable_logging"`
EnableStatistics bool `json:"enable_statistics"`
EnableShifting bool `json:"enable_shifting"`
RequireClassMatch bool `json:"require_class_match"` // Enforce starter class restrictions
AutoCleanupInterval int32 `json:"auto_cleanup_interval"` // seconds
MaxHistoryEntries int `json:"max_history_entries"`
}
// External integration interfaces (simplified)
type Logger interface {
LogDebug(system, format string, args ...any)
LogInfo(system, format string, args ...any)
LogWarning(system, format string, args ...any)
LogError(system, format string, args ...any)
}
// Manager Methods
// NewHeroicOPManager creates a new heroic opportunity manager
func NewHeroicOPManager(database *database.Database) *HeroicOPManager {
return &HeroicOPManager{
database: database,
starters: make(map[int32]*HeroicOPStarter),
wheels: make(map[int32]*HeroicOPWheel),
activeHOs: make(map[int64]*HeroicOP),
encounterHOs: make(map[int32][]*HeroicOP),
byClass: make(map[int8]map[int32]*HeroicOPStarter),
byStarterID: make(map[int32][]*HeroicOPWheel),
bySpellID: make(map[int32][]*HeroicOPWheel),
byChance: make(map[string][]*HeroicOPWheel),
orderedWheels: make(map[int32]*HeroicOPWheel),
shiftWheels: make(map[int32]*HeroicOPWheel),
spellInfo: make(map[int32]SpellInfo),
nextInstanceID: 1,
defaultWheelTimer: DefaultWheelTimerSeconds * 1000, // Convert to milliseconds
maxConcurrentHOs: MaxConcurrentHOs,
enableLogging: true,
enableStatistics: true,
}
}
// SetLogger sets the logger for the manager
func (hom *HeroicOPManager) SetLogger(logger Logger) {
hom.mu.Lock()
defer hom.mu.Unlock()
hom.logger = logger
}
// Initialize loads configuration and data from database
func (hom *HeroicOPManager) Initialize(ctx context.Context, config *HeroicOPConfig) error {
hom.mu.Lock()
defer hom.mu.Unlock()
if config != nil {
hom.defaultWheelTimer = config.DefaultWheelTimer
hom.maxConcurrentHOs = config.MaxConcurrentHOs
hom.enableLogging = config.EnableLogging
hom.enableStatistics = config.EnableStatistics
}
// Load all data from database
if err := hom.loadFromDatabase(ctx); err != nil {
return fmt.Errorf("failed to load heroic ops data: %w", err)
}
if hom.logger != nil {
hom.logger.LogInfo("heroic_ops", "Initialized HO manager with %d starters and %d wheels",
len(hom.starters), len(hom.wheels))
}
return nil
}
// loadFromDatabase loads all heroic opportunities from the database with optimal indexing
func (hom *HeroicOPManager) loadFromDatabase(ctx context.Context) error {
// Clear existing data
hom.clearIndices()
// Load all starters
if err := hom.loadStarters(ctx); err != nil {
return fmt.Errorf("failed to load starters: %w", err)
}
// Load all wheels
if err := hom.loadWheels(ctx); err != nil {
return fmt.Errorf("failed to load wheels: %w", err)
}
// Build specialized indices for O(1) performance
hom.buildIndices()
hom.loaded = true
return nil
}
// loadStarters loads all starters from database
func (hom *HeroicOPManager) loadStarters(ctx context.Context) error {
query := `SELECT id, start_class, starter_icon, ability1, ability2, ability3, ability4, ability5, ability6, name, description
FROM heroic_op_starters ORDER BY id`
rows, err := hom.database.Query(query)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
starter := &HeroicOPStarter{
isNew: false,
}
err := rows.Scan(
&starter.ID,
&starter.StartClass,
&starter.StarterIcon,
&starter.Abilities[0],
&starter.Abilities[1],
&starter.Abilities[2],
&starter.Abilities[3],
&starter.Abilities[4],
&starter.Abilities[5],
&starter.Name,
&starter.Description,
)
if err != nil {
return err
}
starter.SaveNeeded = false
hom.starters[starter.ID] = starter
}
return rows.Err()
}
// loadWheels loads all wheels from database
func (hom *HeroicOPManager) loadWheels(ctx context.Context) error {
query := `SELECT id, starter_link_id, chain_order, shift_icon, chance, ability1, ability2, ability3, ability4, ability5, ability6,
spell_id, name, description, required_players
FROM heroic_op_wheels ORDER BY id`
rows, err := hom.database.Query(query)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
wheel := &HeroicOPWheel{
isNew: false,
}
err := rows.Scan(
&wheel.ID,
&wheel.StarterLinkID,
&wheel.Order,
&wheel.ShiftIcon,
&wheel.Chance,
&wheel.Abilities[0],
&wheel.Abilities[1],
&wheel.Abilities[2],
&wheel.Abilities[3],
&wheel.Abilities[4],
&wheel.Abilities[5],
&wheel.SpellID,
&wheel.Name,
&wheel.Description,
&wheel.RequiredPlayers,
)
if err != nil {
return err
}
wheel.SaveNeeded = false
hom.wheels[wheel.ID] = wheel
// Store spell info
hom.spellInfo[wheel.SpellID] = SpellInfo{
ID: wheel.SpellID,
Name: wheel.Name,
Description: wheel.Description,
}
}
return rows.Err()
}
// buildIndices creates specialized indices for O(1) performance
func (hom *HeroicOPManager) buildIndices() {
// Build class-based starter index
for _, starter := range hom.starters {
if hom.byClass[starter.StartClass] == nil {
hom.byClass[starter.StartClass] = make(map[int32]*HeroicOPStarter)
}
hom.byClass[starter.StartClass][starter.ID] = starter
}
// Build wheel indices
for _, wheel := range hom.wheels {
// By starter ID
hom.byStarterID[wheel.StarterLinkID] = append(hom.byStarterID[wheel.StarterLinkID], wheel)
// By spell ID
hom.bySpellID[wheel.SpellID] = append(hom.bySpellID[wheel.SpellID], wheel)
// By chance range (for performance optimization)
chanceRange := hom.getChanceRange(wheel.Chance)
hom.byChance[chanceRange] = append(hom.byChance[chanceRange], wheel)
// Special wheel types
if wheel.IsOrdered() {
hom.orderedWheels[wheel.ID] = wheel
}
if wheel.HasShift() {
hom.shiftWheels[wheel.ID] = wheel
}
}
// Sort wheels by chance for deterministic selection
for _, wheels := range hom.byStarterID {
sort.Slice(wheels, func(i, j int) bool {
return wheels[i].Chance > wheels[j].Chance
})
}
}
// clearIndices clears all indices
func (hom *HeroicOPManager) clearIndices() {
hom.starters = make(map[int32]*HeroicOPStarter)
hom.wheels = make(map[int32]*HeroicOPWheel)
hom.byClass = make(map[int8]map[int32]*HeroicOPStarter)
hom.byStarterID = make(map[int32][]*HeroicOPWheel)
hom.bySpellID = make(map[int32][]*HeroicOPWheel)
hom.byChance = make(map[string][]*HeroicOPWheel)
hom.orderedWheels = make(map[int32]*HeroicOPWheel)
hom.shiftWheels = make(map[int32]*HeroicOPWheel)
hom.spellInfo = make(map[int32]SpellInfo)
}
// getChanceRange returns a chance range string for indexing
func (hom *HeroicOPManager) getChanceRange(chance float32) string {
switch {
case chance >= 75.0:
return "very_high"
case chance >= 50.0:
return "high"
case chance >= 25.0:
return "medium"
case chance >= 10.0:
return "low"
default:
return "very_low"
}
}
// Core HO Management Operations
// StartHeroicOpportunity initiates a new heroic opportunity
func (hom *HeroicOPManager) StartHeroicOpportunity(ctx context.Context, encounterID int32, initiatorID int32, initiatorClass int8) (*HeroicOP, error) {
hom.mu.Lock()
defer hom.mu.Unlock()
// Check if encounter can have more HOs
currentHOs := hom.encounterHOs[encounterID]
if len(currentHOs) >= hom.maxConcurrentHOs {
return nil, fmt.Errorf("encounter %d already has maximum concurrent HOs (%d)",
encounterID, hom.maxConcurrentHOs)
}
// Get available starters for player's class
starters := hom.getStartersForClass(initiatorClass)
if len(starters) == 0 {
return nil, fmt.Errorf("no heroic opportunities available for class %d", initiatorClass)
}
// Create new HO instance
instanceID := hom.nextInstanceID
hom.nextInstanceID++
ho := hom.createHeroicOP(instanceID, encounterID)
ho.AddParticipant(initiatorID)
// Prepare starter IDs for chain phase
starterIDs := make([]int32, len(starters))
for i, starter := range starters {
starterIDs[i] = starter.ID
}
ho.StartStarterChain(starterIDs)
// Add to tracking maps
hom.activeHOs[instanceID] = ho
hom.encounterHOs[encounterID] = append(hom.encounterHOs[encounterID], ho)
// Save to database
if err := hom.saveHOInstance(ctx, ho); err != nil {
if hom.logger != nil {
hom.logger.LogError("heroic_ops", "Failed to save HO instance %d: %v", instanceID, err)
}
}
// Log event
if hom.enableLogging {
hom.logEvent(ctx, instanceID, EventHOStarted, initiatorID, 0, "HO started")
}
if hom.logger != nil {
hom.logger.LogInfo("heroic_ops", "Started HO %d for encounter %d initiated by %d with %d starters",
instanceID, encounterID, initiatorID, len(starterIDs))
}
return ho, nil
}
// ProcessAbility processes an ability used by a player during an active HO
func (hom *HeroicOPManager) ProcessAbility(ctx context.Context, instanceID int64, characterID int32, abilityIcon int16) error {
hom.mu.Lock()
defer hom.mu.Unlock()
ho, exists := hom.activeHOs[instanceID]
if !exists {
return fmt.Errorf("heroic opportunity %d not found", instanceID)
}
if !ho.IsActive() {
return fmt.Errorf("heroic opportunity %d is not active (state: %d)", instanceID, ho.State)
}
// Add player as participant
ho.AddParticipant(characterID)
success := false
switch ho.State {
case HOStateStarterChain:
success = ho.ProcessStarterAbility(abilityIcon, hom)
if success && len(ho.CurrentStarters) == 1 {
// Starter chain completed, transition to wheel phase
starterID := ho.CurrentStarters[0]
ho.StarterID = starterID
// Select random wheel for this starter
wheel := hom.selectRandomWheel(starterID)
if wheel == nil {
// No wheels available, HO fails
ho.State = HOStateFailed
hom.failHO(ctx, ho, "No wheels available for starter")
return fmt.Errorf("no wheels available for starter %d", starterID)
}
// Start wheel phase
ho.StartWheelPhase(wheel, hom.defaultWheelTimer/1000) // Convert to seconds
// Send wheel packet to participants (would be implemented with client manager)
hom.sendWheelUpdate(ho, wheel)
if hom.logger != nil {
hom.logger.LogDebug("heroic_ops", "HO %d transitioned to wheel phase with wheel %d",
instanceID, wheel.ID)
}
}
case HOStateWheelPhase:
wheel := hom.wheels[ho.WheelID]
if wheel == nil {
return fmt.Errorf("wheel %d not found for HO %d", ho.WheelID, instanceID)
}
// Check for shift attempt
if ho.ShiftUsed == ShiftNotUsed && wheel.CanShift(abilityIcon) {
return hom.handleWheelShift(ctx, ho, wheel, characterID)
}
// Process regular ability
success = ho.ProcessWheelAbility(abilityIcon, characterID, wheel)
if success {
// Send progress update (would be implemented with client manager)
hom.sendProgressUpdate(ho)
// Check if HO is complete
if ho.IsComplete() {
hom.completeHO(ctx, ho, wheel, characterID)
}
}
}
// Log ability use
if hom.enableLogging {
eventType := EventHOAbilityUsed
data := fmt.Sprintf("ability:%d,success:%t", abilityIcon, success)
hom.logEvent(ctx, instanceID, eventType, characterID, abilityIcon, data)
}
// Save changes
if ho.SaveNeeded {
if err := hom.saveHOInstance(ctx, ho); err != nil {
if hom.logger != nil {
hom.logger.LogError("heroic_ops", "Failed to save HO instance %d: %v", instanceID, err)
}
}
ho.SaveNeeded = false
}
if !success {
return fmt.Errorf("ability %d not allowed for current HO state", abilityIcon)
}
return nil
}
// UpdateTimers updates all active HO timers
func (hom *HeroicOPManager) UpdateTimers(ctx context.Context, deltaMS int32) {
hom.mu.Lock()
defer hom.mu.Unlock()
var expiredHOs []int64
for instanceID, ho := range hom.activeHOs {
if ho.State == HOStateWheelPhase {
if !ho.UpdateTimer(deltaMS) {
// Timer expired
expiredHOs = append(expiredHOs, instanceID)
} else {
// Send timer update to participants (would be implemented with client manager)
hom.sendTimerUpdate(ho)
}
}
}
// Handle expired HOs
for _, instanceID := range expiredHOs {
ho := hom.activeHOs[instanceID]
hom.failHO(ctx, ho, "Timer expired")
}
}
// Getter methods with O(1) performance
// GetStarter retrieves a starter by ID with O(1) performance
func (hom *HeroicOPManager) GetStarter(id int32) *HeroicOPStarter {
hom.mu.RLock()
defer hom.mu.RUnlock()
return hom.starters[id]
}
// getStartersForClass returns all starters for a specific class with O(1) performance
func (hom *HeroicOPManager) getStartersForClass(class int8) []*HeroicOPStarter {
var result []*HeroicOPStarter
// Add class-specific starters
if classStarters, exists := hom.byClass[class]; exists {
for _, starter := range classStarters {
result = append(result, starter)
}
}
// Add universal starters (class 0 = any)
if universalStarters, exists := hom.byClass[ClassAny]; exists {
for _, starter := range universalStarters {
result = append(result, starter)
}
}
return result
}
// GetWheel retrieves a wheel by ID with O(1) performance
func (hom *HeroicOPManager) GetWheel(id int32) *HeroicOPWheel {
hom.mu.RLock()
defer hom.mu.RUnlock()
return hom.wheels[id]
}
// getWheelsForStarter returns all wheels for a starter with O(1) performance
func (hom *HeroicOPManager) getWheelsForStarter(starterID int32) []*HeroicOPWheel {
wheels := hom.byStarterID[starterID]
if wheels == nil {
return nil
}
// Return copy to prevent external modification
result := make([]*HeroicOPWheel, len(wheels))
copy(result, wheels)
return result
}
// selectRandomWheel selects a random wheel from starter's wheels with optimized performance
func (hom *HeroicOPManager) selectRandomWheel(starterID int32) *HeroicOPWheel {
wheels := hom.getWheelsForStarter(starterID)
if len(wheels) == 0 {
return nil
}
return SelectRandomWheel(wheels)
}
// GetActiveHO returns an active HO by instance ID
func (hom *HeroicOPManager) GetActiveHO(instanceID int64) (*HeroicOP, bool) {
hom.mu.RLock()
defer hom.mu.RUnlock()
ho, exists := hom.activeHOs[instanceID]
return ho, exists
}
// GetEncounterHOs returns all HOs for an encounter
func (hom *HeroicOPManager) GetEncounterHOs(encounterID int32) []*HeroicOP {
hom.mu.RLock()
defer hom.mu.RUnlock()
hos := hom.encounterHOs[encounterID]
result := make([]*HeroicOP, len(hos))
copy(result, hos)
return result
}
// GetStatistics returns current HO system statistics
func (hom *HeroicOPManager) GetStatistics() *HeroicOPManagerStats {
hom.mu.RLock()
defer hom.mu.RUnlock()
// Calculate runtime statistics
classDistribution := make(map[int32]int64)
for _, starter := range hom.starters {
classDistribution[int32(starter.StartClass)]++
}
totalChance := float64(0)
for _, wheel := range hom.wheels {
totalChance += float64(wheel.Chance)
}
averageChance := float64(0)
if len(hom.wheels) > 0 {
averageChance = totalChance / float64(len(hom.wheels))
}
return &HeroicOPManagerStats{
TotalStarters: int64(len(hom.starters)),
TotalWheels: int64(len(hom.wheels)),
ClassDistribution: classDistribution,
OrderedWheelsCount: int64(len(hom.orderedWheels)),
ShiftWheelsCount: int64(len(hom.shiftWheels)),
SpellCount: int64(len(hom.spellInfo)),
AverageChance: averageChance,
ActiveHOCount: int64(len(hom.activeHOs)),
}
}
// Helper methods
// createHeroicOP creates a new HO instance
func (hom *HeroicOPManager) createHeroicOP(instanceID int64, encounterID int32) *HeroicOP {
return &HeroicOP{
ID: instanceID,
EncounterID: encounterID,
State: HOStateInactive,
StartTime: time.Now(),
Participants: make(map[int32]bool),
isNew: true,
}
}
// handleWheelShift handles wheel shifting logic
func (hom *HeroicOPManager) handleWheelShift(ctx context.Context, ho *HeroicOP, currentWheel *HeroicOPWheel, characterID int32) error {
// Check if shift is allowed (no progress made)
canShift := true
for i := 0; i < MaxAbilities; i++ {
if ho.Countered[i] != 0 {
canShift = false
break
}
}
if !canShift {
return fmt.Errorf("wheel shift not allowed after progress has been made")
}
// Select new random wheel
newWheel := hom.selectRandomWheel(ho.StarterID)
if newWheel == nil || newWheel.ID == currentWheel.ID {
return fmt.Errorf("no alternative wheel available for shift")
}
oldWheelID := ho.WheelID
ho.WheelID = newWheel.ID
ho.ShiftUsed = ShiftUsed
ho.SpellName = newWheel.Name
ho.SpellDescription = newWheel.Description
ho.SaveNeeded = true
// Send shift notification (would be implemented with client manager)
hom.sendShiftUpdate(ho, oldWheelID, newWheel.ID)
// Log shift event
if hom.enableLogging {
data := fmt.Sprintf("old_wheel:%d,new_wheel:%d", oldWheelID, newWheel.ID)
hom.logEvent(ctx, ho.ID, EventHOWheelShifted, characterID, 0, data)
}
if hom.logger != nil {
hom.logger.LogDebug("heroic_ops", "HO %d wheel shifted from %d to %d by character %d",
ho.ID, oldWheelID, newWheel.ID, characterID)
}
return nil
}
// completeHO handles HO completion
func (hom *HeroicOPManager) completeHO(ctx context.Context, ho *HeroicOP, wheel *HeroicOPWheel, completedBy int32) {
ho.State = HOStateComplete
ho.Complete = HOComplete
ho.CompletedBy = completedBy
ho.SaveNeeded = true
// Cast completion spell (would be integrated with spell system)
if wheel.SpellID > 0 {
_ = ho.GetParticipants() // participants will be used when spell manager is integrated
// TODO: Cast spell on participants through spell manager
}
// Send completion packet (would be implemented with client manager)
hom.sendCompletionUpdate(ho, true)
// Log completion
if hom.enableLogging {
data := fmt.Sprintf("completed_by:%d,spell_id:%d,participants:%d",
completedBy, wheel.SpellID, len(ho.Participants))
hom.logEvent(ctx, ho.ID, EventHOCompleted, completedBy, 0, data)
}
if hom.logger != nil {
hom.logger.LogInfo("heroic_ops", "HO %d completed by character %d, spell %d cast",
ho.ID, completedBy, wheel.SpellID)
}
}
// failHO handles HO failure
func (hom *HeroicOPManager) failHO(ctx context.Context, ho *HeroicOP, reason string) {
ho.State = HOStateFailed
ho.SaveNeeded = true
// Send failure packet (would be implemented with client manager)
hom.sendCompletionUpdate(ho, false)
// Log failure
if hom.enableLogging {
hom.logEvent(ctx, ho.ID, EventHOFailed, 0, 0, reason)
}
if hom.logger != nil {
hom.logger.LogDebug("heroic_ops", "HO %d failed: %s", ho.ID, reason)
}
}
// Communication placeholder methods (would integrate with client manager)
func (hom *HeroicOPManager) sendWheelUpdate(ho *HeroicOP, wheel *HeroicOPWheel) {
// TODO: Implement when client manager is integrated
}
func (hom *HeroicOPManager) sendProgressUpdate(ho *HeroicOP) {
// TODO: Implement when client manager is integrated
}
func (hom *HeroicOPManager) sendTimerUpdate(ho *HeroicOP) {
// TODO: Implement when client manager is integrated
}
func (hom *HeroicOPManager) sendCompletionUpdate(ho *HeroicOP, success bool) {
// TODO: Implement when client manager is integrated
}
func (hom *HeroicOPManager) sendShiftUpdate(ho *HeroicOP, oldWheelID, newWheelID int32) {
// TODO: Implement when client manager is integrated
}
// Database operations
// saveHOInstance saves HO instance to database
func (hom *HeroicOPManager) saveHOInstance(ctx context.Context, ho *HeroicOP) error {
if ho.isNew {
// Insert new record
query := `INSERT INTO heroic_op_instances (id, encounter_id, starter_id, wheel_id, state, start_time,
wheel_start_time, time_remaining, total_time, complete, countered1, countered2, countered3,
countered4, countered5, countered6, shift_used, starter_progress, completed_by, spell_name, spell_description)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
_, err := hom.database.Exec(query,
ho.ID, ho.EncounterID, ho.StarterID, ho.WheelID, ho.State, ho.StartTime, ho.WheelStartTime,
ho.TimeRemaining, ho.TotalTime, ho.Complete, ho.Countered[0], ho.Countered[1], ho.Countered[2],
ho.Countered[3], ho.Countered[4], ho.Countered[5], ho.ShiftUsed, ho.StarterProgress,
ho.CompletedBy, ho.SpellName, ho.SpellDescription)
if err != nil {
return err
}
ho.isNew = false
} else {
// Update existing record
query := `UPDATE heroic_op_instances SET encounter_id=?, starter_id=?, wheel_id=?, state=?, start_time=?,
wheel_start_time=?, time_remaining=?, total_time=?, complete=?, countered1=?, countered2=?, countered3=?,
countered4=?, countered5=?, countered6=?, shift_used=?, starter_progress=?, completed_by=?, spell_name=?, spell_description=?
WHERE id=?`
_, err := hom.database.Exec(query,
ho.EncounterID, ho.StarterID, ho.WheelID, ho.State, ho.StartTime, ho.WheelStartTime,
ho.TimeRemaining, ho.TotalTime, ho.Complete, ho.Countered[0], ho.Countered[1], ho.Countered[2],
ho.Countered[3], ho.Countered[4], ho.Countered[5], ho.ShiftUsed, ho.StarterProgress,
ho.CompletedBy, ho.SpellName, ho.SpellDescription, ho.ID)
if err != nil {
return err
}
}
return nil
}
// logEvent logs an HO event to the database
func (hom *HeroicOPManager) logEvent(ctx context.Context, instanceID int64, eventType int, characterID int32, abilityIcon int16, data string) {
if !hom.enableLogging {
return
}
query := `INSERT INTO heroic_op_events (instance_id, event_type, character_id, ability_icon, timestamp, data)
VALUES (?, ?, ?, ?, ?, ?)`
_, err := hom.database.Exec(query, instanceID, eventType, characterID, abilityIcon, time.Now(), data)
if err != nil && hom.logger != nil {
hom.logger.LogError("heroic_ops", "Failed to save HO event: %v", err)
}
}
// HeroicOP Methods
// NewHeroicOP creates a new HO instance
func NewHeroicOP(instanceID int64, encounterID int32) *HeroicOP {
return &HeroicOP{
ID: instanceID,
EncounterID: encounterID,
State: HOStateInactive,
StartTime: time.Now(),
Participants: make(map[int32]bool),
isNew: true,
}
}
// AddParticipant adds a character to the HO
func (ho *HeroicOP) AddParticipant(characterID int32) {
ho.mu.Lock()
defer ho.mu.Unlock()
ho.Participants[characterID] = true
ho.SaveNeeded = true
}
// GetParticipants returns list of participant character IDs
func (ho *HeroicOP) GetParticipants() []int32 {
ho.mu.RLock()
defer ho.mu.RUnlock()
participants := make([]int32, 0, len(ho.Participants))
for characterID := range ho.Participants {
participants = append(participants, characterID)
}
return participants
}
// IsActive returns true if HO is in an active state
func (ho *HeroicOP) IsActive() bool {
ho.mu.RLock()
defer ho.mu.RUnlock()
return ho.State == HOStateStarterChain || ho.State == HOStateWheelPhase
}
// IsComplete returns true if all wheel abilities are countered
func (ho *HeroicOP) IsComplete() bool {
ho.mu.RLock()
defer ho.mu.RUnlock()
for i := 0; i < MaxAbilities; i++ {
if ho.Countered[i] == 0 {
return false
}
}
return true
}
// StartStarterChain begins the starter chain phase
func (ho *HeroicOP) StartStarterChain(starterIDs []int32) {
ho.mu.Lock()
defer ho.mu.Unlock()
ho.State = HOStateStarterChain
ho.CurrentStarters = make([]int32, len(starterIDs))
copy(ho.CurrentStarters, starterIDs)
ho.StarterProgress = 0
ho.SaveNeeded = true
}
// ProcessStarterAbility processes an ability during starter chain phase
func (ho *HeroicOP) ProcessStarterAbility(abilityIcon int16, hom *HeroicOPManager) bool {
ho.mu.Lock()
defer ho.mu.Unlock()
// Check each active starter for matching ability at current position
var remainingStarters []int32
for _, starterID := range ho.CurrentStarters {
starter := hom.GetStarter(starterID)
if starter != nil && starter.MatchesAbilityAtPosition(abilityIcon, ho.StarterProgress) {
remainingStarters = append(remainingStarters, starterID)
}
}
if len(remainingStarters) == 0 {
return false // No starters matched this ability
}
// Update progress and remaining starters
ho.StarterProgress++
ho.CurrentStarters = remainingStarters
ho.SaveNeeded = true
// Check if starter chain is complete (only one starter left and at end of chain)
if len(remainingStarters) == 1 {
starter := hom.GetStarter(remainingStarters[0])
if starter != nil && int(ho.StarterProgress) >= starter.GetLength() {
return true // Starter chain completed
}
}
return true // Progress made but not complete
}
// StartWheelPhase begins the wheel phase
func (ho *HeroicOP) StartWheelPhase(wheel *HeroicOPWheel, wheelTimerSeconds int32) {
ho.mu.Lock()
defer ho.mu.Unlock()
ho.State = HOStateWheelPhase
ho.WheelID = wheel.ID
ho.WheelStartTime = time.Now()
ho.TimeRemaining = wheelTimerSeconds * 1000 // Convert to milliseconds
ho.TotalTime = ho.TimeRemaining
ho.SpellName = wheel.Name
ho.SpellDescription = wheel.Description
ho.SaveNeeded = true
// Initialize countered array
for i := 0; i < MaxAbilities; i++ {
ho.Countered[i] = 0
}
}
// ProcessWheelAbility processes an ability during wheel phase
func (ho *HeroicOP) ProcessWheelAbility(abilityIcon int16, characterID int32, wheel *HeroicOPWheel) bool {
ho.mu.Lock()
defer ho.mu.Unlock()
// Find matching ability in wheel
for i := 0; i < MaxAbilities; i++ {
if wheel.Abilities[i] == abilityIcon || wheel.Abilities[i] == AbilityIconAny {
// Check if this ability is already countered
if ho.Countered[i] != 0 {
continue
}
// For ordered wheels, check if this is the next expected ability
if wheel.IsOrdered() && !ho.isNextAbilityInOrder(i) {
continue
}
// Mark as countered
ho.Countered[i] = 1
ho.SaveNeeded = true
return true
}
}
return false // No matching ability found
}
// isNextAbilityInOrder checks if the given index is the next expected ability for ordered wheels
func (ho *HeroicOP) isNextAbilityInOrder(index int) bool {
for i := 0; i < index; i++ {
if ho.Countered[i] == 0 {
return false // Earlier ability not completed
}
}
return true
}
// UpdateTimer updates the HO timer, returns false if expired
func (ho *HeroicOP) UpdateTimer(deltaMS int32) bool {
ho.mu.Lock()
defer ho.mu.Unlock()
ho.TimeRemaining -= deltaMS
if ho.TimeRemaining <= 0 {
ho.TimeRemaining = 0
return false
}
ho.SaveNeeded = true
return true
}
// GetProgress returns completion progress as percentage
func (ho *HeroicOP) GetProgress() float32 {
ho.mu.RLock()
defer ho.mu.RUnlock()
completed := float32(0)
for i := 0; i < MaxAbilities; i++ {
if ho.Countered[i] != 0 {
completed++
}
}
return (completed / float32(MaxAbilities)) * 100.0
}
// GetPacketData converts HO to packet data structure
func (ho *HeroicOP) GetPacketData(wheel *HeroicOPWheel) *PacketData {
ho.mu.RLock()
defer ho.mu.RUnlock()
data := &PacketData{
TimeRemaining: ho.TimeRemaining,
TotalTime: ho.TotalTime,
Complete: ho.Complete,
State: ho.State,
Countered: ho.Countered,
}
if wheel != nil {
data.SpellName = wheel.Name
data.SpellDescription = wheel.Description
data.Abilities = wheel.Abilities
data.CanShift = ho.ShiftUsed == ShiftNotUsed && wheel.HasShift()
data.ShiftIcon = wheel.ShiftIcon
} else {
data.SpellName = ho.SpellName
data.SpellDescription = ho.SpellDescription
// Abilities will be zero-initialized
}
return data
}
// HeroicOPStarter Methods
// Validate validates the starter configuration
func (s *HeroicOPStarter) Validate() error {
s.mu.RLock()
defer s.mu.RUnlock()
if s.ID <= 0 {
return fmt.Errorf("invalid starter ID: %d", s.ID)
}
if s.Name == "" {
return fmt.Errorf("starter name cannot be empty")
}
if len(s.Name) > MaxHONameLength {
return fmt.Errorf("starter name too long: %d > %d", len(s.Name), MaxHONameLength)
}
// Check that at least one ability is specified
hasAbility := false
for i := 0; i < MaxAbilities; i++ {
if s.Abilities[i] != 0 {
hasAbility = true
break
}
}
if !hasAbility {
return fmt.Errorf("starter must have at least one ability")
}
return nil
}
// MatchesAbilityAtPosition checks if the given ability matches at the specified position
func (s *HeroicOPStarter) MatchesAbilityAtPosition(abilityIcon int16, position int8) bool {
s.mu.RLock()
defer s.mu.RUnlock()
if position < 0 || int(position) >= MaxAbilities {
return false
}
expectedAbility := s.Abilities[position]
return expectedAbility == abilityIcon || expectedAbility == AbilityIconAny
}
// GetLength returns the length of the starter chain
func (s *HeroicOPStarter) GetLength() int {
s.mu.RLock()
defer s.mu.RUnlock()
length := 0
for i := 0; i < MaxAbilities; i++ {
if s.Abilities[i] != 0 {
length++
} else {
break // Stop at first empty slot
}
}
return length
}
// HeroicOPWheel Methods
// Validate validates the wheel configuration
func (w *HeroicOPWheel) Validate() error {
w.mu.RLock()
defer w.mu.RUnlock()
if w.ID <= 0 {
return fmt.Errorf("invalid wheel ID: %d", w.ID)
}
if w.StarterLinkID <= 0 {
return fmt.Errorf("invalid starter link ID: %d", w.StarterLinkID)
}
if w.Name == "" {
return fmt.Errorf("wheel name cannot be empty")
}
if len(w.Name) > MaxHONameLength {
return fmt.Errorf("wheel name too long: %d > %d", len(w.Name), MaxHONameLength)
}
if w.Chance < MinChance || w.Chance > MaxChance {
return fmt.Errorf("wheel chance out of range: %f", w.Chance)
}
// Check that at least one ability is specified
hasAbility := false
for i := 0; i < MaxAbilities; i++ {
if w.Abilities[i] != 0 {
hasAbility = true
break
}
}
if !hasAbility {
return fmt.Errorf("wheel must have at least one ability")
}
return nil
}
// IsOrdered returns true if this is an ordered wheel
func (w *HeroicOPWheel) IsOrdered() bool {
w.mu.RLock()
defer w.mu.RUnlock()
return w.Order == WheelOrderOrdered
}
// HasShift returns true if this wheel has a shift ability
func (w *HeroicOPWheel) HasShift() bool {
w.mu.RLock()
defer w.mu.RUnlock()
return w.ShiftIcon != 0
}
// CanShift returns true if the given ability icon can shift this wheel
func (w *HeroicOPWheel) CanShift(abilityIcon int16) bool {
w.mu.RLock()
defer w.mu.RUnlock()
return w.ShiftIcon == abilityIcon
}
// Utility Functions
// SelectRandomWheel selects a wheel based on chance weights
func SelectRandomWheel(wheels []*HeroicOPWheel) *HeroicOPWheel {
if len(wheels) == 0 {
return nil
}
if len(wheels) == 1 {
return wheels[0]
}
// Calculate total weight
totalWeight := float32(0)
for _, wheel := range wheels {
totalWeight += wheel.Chance
}
if totalWeight <= 0 {
// Equal probability fallback
return wheels[rand.Intn(len(wheels))]
}
// Random selection based on weights
randomValue := rand.Float32() * totalWeight
currentWeight := float32(0)
for _, wheel := range wheels {
currentWeight += wheel.Chance
if randomValue <= currentWeight {
return wheel
}
}
// Fallback (should not happen)
return wheels[len(wheels)-1]
}
// Packet Building Integration
// BuildHeroicOpportunityPacket builds the main HO packet for network transmission
func (hom *HeroicOPManager) BuildHeroicOpportunityPacket(ho *HeroicOP, wheel *HeroicOPWheel, clientVersion uint32) (map[string]interface{}, error) {
if ho == nil {
return nil, fmt.Errorf("heroic opportunity is nil")
}
packet := make(map[string]interface{})
// Basic HO information
packet["id"] = ho.ID
packet["wheel_type"] = int8(0) // Default wheel type
packet["unknown"] = int8(0)
packet["time_total"] = float32(ho.TotalTime) / 1000.0 // Convert to seconds
packet["time_left"] = float32(ho.TimeRemaining) / 1000.0 // Convert to seconds
if wheel != nil {
packet["name"] = wheel.Name
packet["description"] = wheel.Description
packet["order"] = wheel.Order
packet["shift_icon"] = wheel.ShiftIcon
packet["starter_icon"] = int16(0) // Would need starter reference
// Wheel abilities
packet["icon1"] = wheel.Abilities[0]
packet["icon2"] = wheel.Abilities[1]
packet["icon3"] = wheel.Abilities[2]
packet["icon4"] = wheel.Abilities[3]
packet["icon5"] = wheel.Abilities[4]
packet["icon6"] = wheel.Abilities[5]
// Completion status
packet["countered1"] = ho.Countered[0]
packet["countered2"] = ho.Countered[1]
packet["countered3"] = ho.Countered[2]
packet["countered4"] = ho.Countered[3]
packet["countered5"] = ho.Countered[4]
packet["countered6"] = ho.Countered[5]
} else {
packet["name"] = ho.SpellName
packet["description"] = ho.SpellDescription
packet["order"] = int8(0)
packet["shift_icon"] = int16(0)
packet["starter_icon"] = int16(0)
// Zero out abilities and counters
for i := 1; i <= 6; i++ {
packet[fmt.Sprintf("icon%d", i)] = int16(0)
packet[fmt.Sprintf("countered%d", i)] = int8(0)
}
}
return packet, nil
}
// GetHeroicOpportunityOpcode returns the appropriate opcode for HO packets
func (hom *HeroicOPManager) GetHeroicOpportunityOpcode() packets.InternalOpcode {
return packets.OP_HeroicOpportunityMsg
}
// Cleanup Operations
// CleanupExpiredHOs removes completed and failed HOs
func (hom *HeroicOPManager) CleanupExpiredHOs(ctx context.Context, maxAge time.Duration) {
hom.mu.Lock()
defer hom.mu.Unlock()
cutoff := time.Now().Add(-maxAge)
var toRemove []int64
for instanceID, ho := range hom.activeHOs {
if !ho.IsActive() && ho.StartTime.Before(cutoff) {
toRemove = append(toRemove, instanceID)
}
}
for _, instanceID := range toRemove {
ho := hom.activeHOs[instanceID]
// Remove from encounter tracking
encounterHOs := hom.encounterHOs[ho.EncounterID]
for i, encounterHO := range encounterHOs {
if encounterHO.ID == instanceID {
hom.encounterHOs[ho.EncounterID] = append(encounterHOs[:i], encounterHOs[i+1:]...)
break
}
}
// Clean up empty encounter list
if len(hom.encounterHOs[ho.EncounterID]) == 0 {
delete(hom.encounterHOs, ho.EncounterID)
}
// Remove from active list
delete(hom.activeHOs, instanceID)
// Delete from database
query := `DELETE FROM heroic_op_instances WHERE id = ?`
if _, err := hom.database.Exec(query, instanceID); err != nil {
if hom.logger != nil {
hom.logger.LogError("heroic_ops", "Failed to delete HO instance %d: %v", instanceID, err)
}
}
}
if len(toRemove) > 0 && hom.logger != nil {
hom.logger.LogDebug("heroic_ops", "Cleaned up %d expired HO instances", len(toRemove))
}
}
// IsLoaded returns whether data has been loaded
func (hom *HeroicOPManager) IsLoaded() bool {
hom.mu.RLock()
defer hom.mu.RUnlock()
return hom.loaded
}