diff --git a/internal/heroic_ops/constants.go b/internal/heroic_ops/constants.go index 6cf8c26..c34bf36 100644 --- a/internal/heroic_ops/constants.go +++ b/internal/heroic_ops/constants.go @@ -6,8 +6,8 @@ const ( MaxAbilities = 6 // Special ability icon values - AbilityIconAny = 0xFFFF // Wildcard - any ability can be used - AbilityIconNone = 0 // No ability required + AbilityIconAny = -1 // Wildcard - any ability can be used (using -1 to fit in int16) + AbilityIconNone = 0 // No ability required // Default wheel timer (in seconds) DefaultWheelTimerSeconds = 10 diff --git a/internal/heroic_ops/database.go b/internal/heroic_ops/database.go index 05ab8c7..96b6974 100644 --- a/internal/heroic_ops/database.go +++ b/internal/heroic_ops/database.go @@ -5,119 +5,121 @@ import ( "fmt" "time" - "eq2emu/internal/database" + "zombiezen.com/go/sqlite" + "zombiezen.com/go/sqlite/sqlitex" ) -// DatabaseHeroicOPManager implements HeroicOPDatabase interface using the existing database wrapper +// DatabaseHeroicOPManager implements HeroicOPDatabase interface using sqlitex.Pool type DatabaseHeroicOPManager struct { - db *database.DB + pool *sqlitex.Pool } // NewDatabaseHeroicOPManager creates a new database heroic OP manager -func NewDatabaseHeroicOPManager(db *database.DB) *DatabaseHeroicOPManager { +func NewDatabaseHeroicOPManager(pool *sqlitex.Pool) *DatabaseHeroicOPManager { return &DatabaseHeroicOPManager{ - db: db, + pool: pool, } } // LoadStarters retrieves all starters from database func (dhom *DatabaseHeroicOPManager) LoadStarters(ctx context.Context) ([]HeroicOPData, error) { + conn, err := dhom.pool.Take(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get database connection: %w", err) + } + defer dhom.pool.Put(conn) + query := `SELECT id, ho_type, starter_class, starter_icon, 0 as starter_link_id, 0 as chain_order, 0 as shift_icon, 0 as spell_id, 0.0 as chance, ability1, ability2, ability3, ability4, ability5, ability6, name, description FROM heroic_ops WHERE ho_type = ?` - rows, err := dhom.db.QueryContext(ctx, query, HOTypeStarter) + var starters []HeroicOPData + err = sqlitex.Execute(conn, query, &sqlitex.ExecOptions{ + Args: []any{HOTypeStarter}, + ResultFunc: func(stmt *sqlite.Stmt) error { + var starter HeroicOPData + starter.ID = int32(stmt.ColumnInt64(0)) + starter.HOType = stmt.ColumnText(1) + starter.StarterClass = int8(stmt.ColumnInt64(2)) + starter.StarterIcon = int16(stmt.ColumnInt64(3)) + starter.StarterLinkID = int32(stmt.ColumnInt64(4)) + starter.ChainOrder = int8(stmt.ColumnInt64(5)) + starter.ShiftIcon = int16(stmt.ColumnInt64(6)) + starter.SpellID = int32(stmt.ColumnInt64(7)) + starter.Chance = float32(stmt.ColumnFloat(8)) + starter.Ability1 = int16(stmt.ColumnInt64(9)) + starter.Ability2 = int16(stmt.ColumnInt64(10)) + starter.Ability3 = int16(stmt.ColumnInt64(11)) + starter.Ability4 = int16(stmt.ColumnInt64(12)) + starter.Ability5 = int16(stmt.ColumnInt64(13)) + starter.Ability6 = int16(stmt.ColumnInt64(14)) + if stmt.ColumnType(15) != sqlite.TypeNull { + starter.Name = stmt.ColumnText(15) + } + if stmt.ColumnType(16) != sqlite.TypeNull { + starter.Description = stmt.ColumnText(16) + } + starters = append(starters, starter) + return nil + }, + }) if err != nil { return nil, fmt.Errorf("failed to query heroic op starters: %w", err) } - defer rows.Close() - - var starters []HeroicOPData - for rows.Next() { - var starter HeroicOPData - var name, description *string - - err := rows.Scan( - &starter.ID, - &starter.HOType, - &starter.StarterClass, - &starter.StarterIcon, - &starter.StarterLinkID, - &starter.ChainOrder, - &starter.ShiftIcon, - &starter.SpellID, - &starter.Chance, - &starter.Ability1, - &starter.Ability2, - &starter.Ability3, - &starter.Ability4, - &starter.Ability5, - &starter.Ability6, - &name, - &description, - ) - if err != nil { - return nil, fmt.Errorf("failed to scan heroic op starter row: %w", err) - } - - // Handle nullable fields - if name != nil { - starter.Name = *name - } - if description != nil { - starter.Description = *description - } - - starters = append(starters, starter) - } - - if err := rows.Err(); err != nil { - return nil, fmt.Errorf("error iterating heroic op starter rows: %w", err) - } return starters, nil } // LoadStarter retrieves a specific starter from database func (dhom *DatabaseHeroicOPManager) LoadStarter(ctx context.Context, starterID int32) (*HeroicOPData, error) { + conn, err := dhom.pool.Take(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get database connection: %w", err) + } + defer dhom.pool.Put(conn) + query := `SELECT id, ho_type, starter_class, starter_icon, 0 as starter_link_id, 0 as chain_order, 0 as shift_icon, 0 as spell_id, 0.0 as chance, ability1, ability2, ability3, ability4, ability5, ability6, name, description FROM heroic_ops WHERE id = ? AND ho_type = ?` var starter HeroicOPData - var name, description *string - - err := dhom.db.QueryRowContext(ctx, query, starterID, HOTypeStarter).Scan( - &starter.ID, - &starter.HOType, - &starter.StarterClass, - &starter.StarterIcon, - &starter.StarterLinkID, - &starter.ChainOrder, - &starter.ShiftIcon, - &starter.SpellID, - &starter.Chance, - &starter.Ability1, - &starter.Ability2, - &starter.Ability3, - &starter.Ability4, - &starter.Ability5, - &starter.Ability6, - &name, - &description, - ) + var found bool + err = sqlitex.Execute(conn, query, &sqlitex.ExecOptions{ + Args: []any{starterID, HOTypeStarter}, + ResultFunc: func(stmt *sqlite.Stmt) error { + found = true + starter.ID = int32(stmt.ColumnInt64(0)) + starter.HOType = stmt.ColumnText(1) + starter.StarterClass = int8(stmt.ColumnInt64(2)) + starter.StarterIcon = int16(stmt.ColumnInt64(3)) + starter.StarterLinkID = int32(stmt.ColumnInt64(4)) + starter.ChainOrder = int8(stmt.ColumnInt64(5)) + starter.ShiftIcon = int16(stmt.ColumnInt64(6)) + starter.SpellID = int32(stmt.ColumnInt64(7)) + starter.Chance = float32(stmt.ColumnFloat(8)) + starter.Ability1 = int16(stmt.ColumnInt64(9)) + starter.Ability2 = int16(stmt.ColumnInt64(10)) + starter.Ability3 = int16(stmt.ColumnInt64(11)) + starter.Ability4 = int16(stmt.ColumnInt64(12)) + starter.Ability5 = int16(stmt.ColumnInt64(13)) + starter.Ability6 = int16(stmt.ColumnInt64(14)) + if stmt.ColumnType(15) != sqlite.TypeNull { + starter.Name = stmt.ColumnText(15) + } + if stmt.ColumnType(16) != sqlite.TypeNull { + starter.Description = stmt.ColumnText(16) + } + return nil + }, + }) if err != nil { return nil, fmt.Errorf("failed to load heroic op starter %d: %w", starterID, err) } - // Handle nullable fields - if name != nil { - starter.Name = *name - } - if description != nil { - starter.Description = *description + if !found { + return nil, fmt.Errorf("heroic op starter %d not found", starterID) } return &starter, nil @@ -125,161 +127,153 @@ func (dhom *DatabaseHeroicOPManager) LoadStarter(ctx context.Context, starterID // LoadWheels retrieves all wheels from database func (dhom *DatabaseHeroicOPManager) LoadWheels(ctx context.Context) ([]HeroicOPData, error) { + conn, err := dhom.pool.Take(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get database connection: %w", err) + } + defer dhom.pool.Put(conn) + query := `SELECT id, ho_type, 0 as starter_class, 0 as starter_icon, starter_link_id, chain_order, shift_icon, spell_id, chance, ability1, ability2, ability3, ability4, ability5, ability6, name, description FROM heroic_ops WHERE ho_type = ?` - rows, err := dhom.db.QueryContext(ctx, query, HOTypeWheel) + var wheels []HeroicOPData + err = sqlitex.Execute(conn, query, &sqlitex.ExecOptions{ + Args: []any{HOTypeWheel}, + ResultFunc: func(stmt *sqlite.Stmt) error { + var wheel HeroicOPData + wheel.ID = int32(stmt.ColumnInt64(0)) + wheel.HOType = stmt.ColumnText(1) + wheel.StarterClass = int8(stmt.ColumnInt64(2)) + wheel.StarterIcon = int16(stmt.ColumnInt64(3)) + wheel.StarterLinkID = int32(stmt.ColumnInt64(4)) + wheel.ChainOrder = int8(stmt.ColumnInt64(5)) + wheel.ShiftIcon = int16(stmt.ColumnInt64(6)) + wheel.SpellID = int32(stmt.ColumnInt64(7)) + wheel.Chance = float32(stmt.ColumnFloat(8)) + wheel.Ability1 = int16(stmt.ColumnInt64(9)) + wheel.Ability2 = int16(stmt.ColumnInt64(10)) + wheel.Ability3 = int16(stmt.ColumnInt64(11)) + wheel.Ability4 = int16(stmt.ColumnInt64(12)) + wheel.Ability5 = int16(stmt.ColumnInt64(13)) + wheel.Ability6 = int16(stmt.ColumnInt64(14)) + if stmt.ColumnType(15) != sqlite.TypeNull { + wheel.Name = stmt.ColumnText(15) + } + if stmt.ColumnType(16) != sqlite.TypeNull { + wheel.Description = stmt.ColumnText(16) + } + wheels = append(wheels, wheel) + return nil + }, + }) if err != nil { return nil, fmt.Errorf("failed to query heroic op wheels: %w", err) } - defer rows.Close() - - var wheels []HeroicOPData - for rows.Next() { - var wheel HeroicOPData - var name, description *string - - err := rows.Scan( - &wheel.ID, - &wheel.HOType, - &wheel.StarterClass, - &wheel.StarterIcon, - &wheel.StarterLinkID, - &wheel.ChainOrder, - &wheel.ShiftIcon, - &wheel.SpellID, - &wheel.Chance, - &wheel.Ability1, - &wheel.Ability2, - &wheel.Ability3, - &wheel.Ability4, - &wheel.Ability5, - &wheel.Ability6, - &name, - &description, - ) - if err != nil { - return nil, fmt.Errorf("failed to scan heroic op wheel row: %w", err) - } - - // Handle nullable fields - if name != nil { - wheel.Name = *name - } - if description != nil { - wheel.Description = *description - } - - wheels = append(wheels, wheel) - } - - if err := rows.Err(); err != nil { - return nil, fmt.Errorf("error iterating heroic op wheel rows: %w", err) - } return wheels, nil } // LoadWheelsForStarter retrieves wheels for a specific starter func (dhom *DatabaseHeroicOPManager) LoadWheelsForStarter(ctx context.Context, starterID int32) ([]HeroicOPData, error) { + conn, err := dhom.pool.Take(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get database connection: %w", err) + } + defer dhom.pool.Put(conn) + query := `SELECT id, ho_type, 0 as starter_class, 0 as starter_icon, starter_link_id, chain_order, shift_icon, spell_id, chance, ability1, ability2, ability3, ability4, ability5, ability6, name, description FROM heroic_ops WHERE starter_link_id = ? AND ho_type = ?` - rows, err := dhom.db.QueryContext(ctx, query, starterID, HOTypeWheel) + var wheels []HeroicOPData + err = sqlitex.Execute(conn, query, &sqlitex.ExecOptions{ + Args: []any{starterID, HOTypeWheel}, + ResultFunc: func(stmt *sqlite.Stmt) error { + var wheel HeroicOPData + wheel.ID = int32(stmt.ColumnInt64(0)) + wheel.HOType = stmt.ColumnText(1) + wheel.StarterClass = int8(stmt.ColumnInt64(2)) + wheel.StarterIcon = int16(stmt.ColumnInt64(3)) + wheel.StarterLinkID = int32(stmt.ColumnInt64(4)) + wheel.ChainOrder = int8(stmt.ColumnInt64(5)) + wheel.ShiftIcon = int16(stmt.ColumnInt64(6)) + wheel.SpellID = int32(stmt.ColumnInt64(7)) + wheel.Chance = float32(stmt.ColumnFloat(8)) + wheel.Ability1 = int16(stmt.ColumnInt64(9)) + wheel.Ability2 = int16(stmt.ColumnInt64(10)) + wheel.Ability3 = int16(stmt.ColumnInt64(11)) + wheel.Ability4 = int16(stmt.ColumnInt64(12)) + wheel.Ability5 = int16(stmt.ColumnInt64(13)) + wheel.Ability6 = int16(stmt.ColumnInt64(14)) + if stmt.ColumnType(15) != sqlite.TypeNull { + wheel.Name = stmt.ColumnText(15) + } + if stmt.ColumnType(16) != sqlite.TypeNull { + wheel.Description = stmt.ColumnText(16) + } + wheels = append(wheels, wheel) + return nil + }, + }) if err != nil { return nil, fmt.Errorf("failed to query wheels for starter %d: %w", starterID, err) } - defer rows.Close() - - var wheels []HeroicOPData - for rows.Next() { - var wheel HeroicOPData - var name, description *string - - err := rows.Scan( - &wheel.ID, - &wheel.HOType, - &wheel.StarterClass, - &wheel.StarterIcon, - &wheel.StarterLinkID, - &wheel.ChainOrder, - &wheel.ShiftIcon, - &wheel.SpellID, - &wheel.Chance, - &wheel.Ability1, - &wheel.Ability2, - &wheel.Ability3, - &wheel.Ability4, - &wheel.Ability5, - &wheel.Ability6, - &name, - &description, - ) - if err != nil { - return nil, fmt.Errorf("failed to scan wheel row: %w", err) - } - - // Handle nullable fields - if name != nil { - wheel.Name = *name - } - if description != nil { - wheel.Description = *description - } - - wheels = append(wheels, wheel) - } - - if err := rows.Err(); err != nil { - return nil, fmt.Errorf("error iterating wheel rows: %w", err) - } return wheels, nil } // LoadWheel retrieves a specific wheel from database func (dhom *DatabaseHeroicOPManager) LoadWheel(ctx context.Context, wheelID int32) (*HeroicOPData, error) { + conn, err := dhom.pool.Take(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get database connection: %w", err) + } + defer dhom.pool.Put(conn) + query := `SELECT id, ho_type, 0 as starter_class, 0 as starter_icon, starter_link_id, chain_order, shift_icon, spell_id, chance, ability1, ability2, ability3, ability4, ability5, ability6, name, description FROM heroic_ops WHERE id = ? AND ho_type = ?` var wheel HeroicOPData - var name, description *string - - err := dhom.db.QueryRowContext(ctx, query, wheelID, HOTypeWheel).Scan( - &wheel.ID, - &wheel.HOType, - &wheel.StarterClass, - &wheel.StarterIcon, - &wheel.StarterLinkID, - &wheel.ChainOrder, - &wheel.ShiftIcon, - &wheel.SpellID, - &wheel.Chance, - &wheel.Ability1, - &wheel.Ability2, - &wheel.Ability3, - &wheel.Ability4, - &wheel.Ability5, - &wheel.Ability6, - &name, - &description, - ) + var found bool + err = sqlitex.Execute(conn, query, &sqlitex.ExecOptions{ + Args: []any{wheelID, HOTypeWheel}, + ResultFunc: func(stmt *sqlite.Stmt) error { + found = true + wheel.ID = int32(stmt.ColumnInt64(0)) + wheel.HOType = stmt.ColumnText(1) + wheel.StarterClass = int8(stmt.ColumnInt64(2)) + wheel.StarterIcon = int16(stmt.ColumnInt64(3)) + wheel.StarterLinkID = int32(stmt.ColumnInt64(4)) + wheel.ChainOrder = int8(stmt.ColumnInt64(5)) + wheel.ShiftIcon = int16(stmt.ColumnInt64(6)) + wheel.SpellID = int32(stmt.ColumnInt64(7)) + wheel.Chance = float32(stmt.ColumnFloat(8)) + wheel.Ability1 = int16(stmt.ColumnInt64(9)) + wheel.Ability2 = int16(stmt.ColumnInt64(10)) + wheel.Ability3 = int16(stmt.ColumnInt64(11)) + wheel.Ability4 = int16(stmt.ColumnInt64(12)) + wheel.Ability5 = int16(stmt.ColumnInt64(13)) + wheel.Ability6 = int16(stmt.ColumnInt64(14)) + if stmt.ColumnType(15) != sqlite.TypeNull { + wheel.Name = stmt.ColumnText(15) + } + if stmt.ColumnType(16) != sqlite.TypeNull { + wheel.Description = stmt.ColumnText(16) + } + return nil + }, + }) if err != nil { return nil, fmt.Errorf("failed to load heroic op wheel %d: %w", wheelID, err) } - // Handle nullable fields - if name != nil { - wheel.Name = *name - } - if description != nil { - wheel.Description = *description + if !found { + return nil, fmt.Errorf("heroic op wheel %d not found", wheelID) } return &wheel, nil @@ -287,26 +281,34 @@ func (dhom *DatabaseHeroicOPManager) LoadWheel(ctx context.Context, wheelID int3 // SaveStarter saves a heroic op starter func (dhom *DatabaseHeroicOPManager) SaveStarter(ctx context.Context, starter *HeroicOPStarter) error { + conn, err := dhom.pool.Take(ctx) + if err != nil { + return fmt.Errorf("failed to get database connection: %w", err) + } + defer dhom.pool.Put(conn) + query := `INSERT OR REPLACE INTO heroic_ops (id, ho_type, starter_class, starter_icon, starter_link_id, chain_order, shift_icon, spell_id, chance, ability1, ability2, ability3, ability4, ability5, ability6, name, description) VALUES (?, ?, ?, ?, 0, 0, 0, 0, 0.0, ?, ?, ?, ?, ?, ?, ?, ?)` - _, err := dhom.db.ExecContext(ctx, query, - starter.ID, - HOTypeStarter, - 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, - ) + err = sqlitex.Execute(conn, query, &sqlitex.ExecOptions{ + Args: []any{ + starter.ID, + HOTypeStarter, + 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 fmt.Errorf("failed to save heroic op starter %d: %w", starter.ID, err) } @@ -316,29 +318,37 @@ func (dhom *DatabaseHeroicOPManager) SaveStarter(ctx context.Context, starter *H // SaveWheel saves a heroic op wheel func (dhom *DatabaseHeroicOPManager) SaveWheel(ctx context.Context, wheel *HeroicOPWheel) error { + conn, err := dhom.pool.Take(ctx) + if err != nil { + return fmt.Errorf("failed to get database connection: %w", err) + } + defer dhom.pool.Put(conn) + query := `INSERT OR REPLACE INTO heroic_ops (id, ho_type, starter_class, starter_icon, starter_link_id, chain_order, shift_icon, spell_id, chance, ability1, ability2, ability3, ability4, ability5, ability6, name, description) VALUES (?, ?, 0, 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` - _, err := dhom.db.ExecContext(ctx, query, - wheel.ID, - HOTypeWheel, - wheel.StarterLinkID, - wheel.Order, - wheel.ShiftIcon, - wheel.SpellID, - wheel.Chance, - wheel.Abilities[0], - wheel.Abilities[1], - wheel.Abilities[2], - wheel.Abilities[3], - wheel.Abilities[4], - wheel.Abilities[5], - wheel.Name, - wheel.Description, - ) + err = sqlitex.Execute(conn, query, &sqlitex.ExecOptions{ + Args: []any{ + wheel.ID, + HOTypeWheel, + wheel.StarterLinkID, + wheel.Order, + wheel.ShiftIcon, + wheel.SpellID, + wheel.Chance, + wheel.Abilities[0], + wheel.Abilities[1], + wheel.Abilities[2], + wheel.Abilities[3], + wheel.Abilities[4], + wheel.Abilities[5], + wheel.Name, + wheel.Description, + }, + }) if err != nil { return fmt.Errorf("failed to save heroic op wheel %d: %w", wheel.ID, err) } @@ -348,38 +358,49 @@ func (dhom *DatabaseHeroicOPManager) SaveWheel(ctx context.Context, wheel *Heroi // DeleteStarter removes a starter from database func (dhom *DatabaseHeroicOPManager) DeleteStarter(ctx context.Context, starterID int32) error { + conn, err := dhom.pool.Take(ctx) + if err != nil { + return fmt.Errorf("failed to get database connection: %w", err) + } + defer dhom.pool.Put(conn) + // Use a transaction to delete starter and associated wheels - tx, err := dhom.db.BeginTx(ctx, nil) + err = sqlitex.Execute(conn, "BEGIN", nil) if err != nil { return fmt.Errorf("failed to begin transaction: %w", err) } - defer tx.Rollback() + defer sqlitex.Execute(conn, "ROLLBACK", nil) // Delete associated wheels first - _, err = tx.ExecContext(ctx, "DELETE FROM heroic_ops WHERE starter_link_id = ? AND ho_type = ?", - starterID, HOTypeWheel) + err = sqlitex.Execute(conn, "DELETE FROM heroic_ops WHERE starter_link_id = ? AND ho_type = ?", &sqlitex.ExecOptions{ + Args: []any{starterID, HOTypeWheel}, + }) if err != nil { return fmt.Errorf("failed to delete wheels for starter %d: %w", starterID, err) } // Delete the starter - _, err = tx.ExecContext(ctx, "DELETE FROM heroic_ops WHERE id = ? AND ho_type = ?", - starterID, HOTypeStarter) + err = sqlitex.Execute(conn, "DELETE FROM heroic_ops WHERE id = ? AND ho_type = ?", &sqlitex.ExecOptions{ + Args: []any{starterID, HOTypeStarter}, + }) if err != nil { return fmt.Errorf("failed to delete starter %d: %w", starterID, err) } - if err := tx.Commit(); err != nil { - return fmt.Errorf("failed to commit transaction: %w", err) - } - - return nil + return sqlitex.Execute(conn, "COMMIT", nil) } // DeleteWheel removes a wheel from database func (dhom *DatabaseHeroicOPManager) DeleteWheel(ctx context.Context, wheelID int32) error { - _, err := dhom.db.ExecContext(ctx, "DELETE FROM heroic_ops WHERE id = ? AND ho_type = ?", - wheelID, HOTypeWheel) + conn, err := dhom.pool.Take(ctx) + if err != nil { + return fmt.Errorf("failed to get database connection: %w", err) + } + defer dhom.pool.Put(conn) + + err = sqlitex.Execute(conn, "DELETE FROM heroic_ops WHERE id = ? AND ho_type = ?", &sqlitex.ExecOptions{ + Args: []any{wheelID, HOTypeWheel}, + }) if err != nil { return fmt.Errorf("failed to delete wheel %d: %w", wheelID, err) } @@ -389,6 +410,12 @@ func (dhom *DatabaseHeroicOPManager) DeleteWheel(ctx context.Context, wheelID in // SaveHOInstance saves a heroic opportunity instance func (dhom *DatabaseHeroicOPManager) SaveHOInstance(ctx context.Context, ho *HeroicOP) error { + conn, err := dhom.pool.Take(ctx) + if err != nil { + return fmt.Errorf("failed to get database connection: %w", err) + } + defer dhom.pool.Put(conn) + query := `INSERT OR REPLACE INTO heroic_op_instances (id, encounter_id, starter_id, wheel_id, state, start_time, wheel_start_time, time_remaining, total_time, complete, countered_1, countered_2, countered_3, @@ -399,29 +426,31 @@ func (dhom *DatabaseHeroicOPManager) SaveHOInstance(ctx context.Context, ho *Her startTimeUnix := ho.StartTime.Unix() wheelStartTimeUnix := ho.WheelStartTime.Unix() - _, err := dhom.db.ExecContext(ctx, query, - ho.ID, - ho.EncounterID, - ho.StarterID, - ho.WheelID, - ho.State, - startTimeUnix, - wheelStartTimeUnix, - 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, - ) + err = sqlitex.Execute(conn, query, &sqlitex.ExecOptions{ + Args: []any{ + ho.ID, + ho.EncounterID, + ho.StarterID, + ho.WheelID, + ho.State, + startTimeUnix, + wheelStartTimeUnix, + 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 fmt.Errorf("failed to save HO instance %d: %w", ho.ID, err) } @@ -431,6 +460,12 @@ func (dhom *DatabaseHeroicOPManager) SaveHOInstance(ctx context.Context, ho *Her // LoadHOInstance retrieves a heroic opportunity instance func (dhom *DatabaseHeroicOPManager) LoadHOInstance(ctx context.Context, instanceID int64) (*HeroicOP, error) { + conn, err := dhom.pool.Take(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get database connection: %w", err) + } + defer dhom.pool.Put(conn) + query := `SELECT id, encounter_id, starter_id, wheel_id, state, start_time, wheel_start_time, time_remaining, total_time, complete, countered_1, countered_2, countered_3, countered_4, countered_5, countered_6, shift_used, starter_progress, @@ -438,46 +473,50 @@ func (dhom *DatabaseHeroicOPManager) LoadHOInstance(ctx context.Context, instanc FROM heroic_op_instances WHERE id = ?` var ho HeroicOP - var startTimeUnix, wheelStartTimeUnix int64 - var spellName, spellDescription *string + var found bool + err = sqlitex.Execute(conn, query, &sqlitex.ExecOptions{ + Args: []any{instanceID}, + ResultFunc: func(stmt *sqlite.Stmt) error { + found = true + ho.ID = stmt.ColumnInt64(0) + ho.EncounterID = int32(stmt.ColumnInt64(1)) + ho.StarterID = int32(stmt.ColumnInt64(2)) + ho.WheelID = int32(stmt.ColumnInt64(3)) + ho.State = int8(stmt.ColumnInt64(4)) + startTimeUnix := stmt.ColumnInt64(5) + wheelStartTimeUnix := stmt.ColumnInt64(6) + ho.TimeRemaining = int32(stmt.ColumnInt64(7)) + ho.TotalTime = int32(stmt.ColumnInt64(8)) + ho.Complete = int8(stmt.ColumnInt64(9)) + ho.Countered[0] = int8(stmt.ColumnInt64(10)) + ho.Countered[1] = int8(stmt.ColumnInt64(11)) + ho.Countered[2] = int8(stmt.ColumnInt64(12)) + ho.Countered[3] = int8(stmt.ColumnInt64(13)) + ho.Countered[4] = int8(stmt.ColumnInt64(14)) + ho.Countered[5] = int8(stmt.ColumnInt64(15)) + ho.ShiftUsed = int8(stmt.ColumnInt64(16)) + ho.StarterProgress = int8(stmt.ColumnInt64(17)) + ho.CompletedBy = int32(stmt.ColumnInt64(18)) + if stmt.ColumnType(19) != sqlite.TypeNull { + ho.SpellName = stmt.ColumnText(19) + } + if stmt.ColumnType(20) != sqlite.TypeNull { + ho.SpellDescription = stmt.ColumnText(20) + } - err := dhom.db.QueryRowContext(ctx, query, instanceID).Scan( - &ho.ID, - &ho.EncounterID, - &ho.StarterID, - &ho.WheelID, - &ho.State, - &startTimeUnix, - &wheelStartTimeUnix, - &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, - &spellName, - &spellDescription, - ) + // Convert timestamps + ho.StartTime = time.Unix(startTimeUnix, 0) + ho.WheelStartTime = time.Unix(wheelStartTimeUnix, 0) + + return nil + }, + }) if err != nil { return nil, fmt.Errorf("failed to load HO instance %d: %w", instanceID, err) } - // Convert timestamps - ho.StartTime = time.Unix(startTimeUnix, 0) - ho.WheelStartTime = time.Unix(wheelStartTimeUnix, 0) - - // Handle nullable fields - if spellName != nil { - ho.SpellName = *spellName - } - if spellDescription != nil { - ho.SpellDescription = *spellDescription + if !found { + return nil, fmt.Errorf("HO instance %d not found", instanceID) } // Initialize maps @@ -489,7 +528,15 @@ func (dhom *DatabaseHeroicOPManager) LoadHOInstance(ctx context.Context, instanc // DeleteHOInstance removes a heroic opportunity instance func (dhom *DatabaseHeroicOPManager) DeleteHOInstance(ctx context.Context, instanceID int64) error { - _, err := dhom.db.ExecContext(ctx, "DELETE FROM heroic_op_instances WHERE id = ?", instanceID) + conn, err := dhom.pool.Take(ctx) + if err != nil { + return fmt.Errorf("failed to get database connection: %w", err) + } + defer dhom.pool.Put(conn) + + err = sqlitex.Execute(conn, "DELETE FROM heroic_op_instances WHERE id = ?", &sqlitex.ExecOptions{ + Args: []any{instanceID}, + }) if err != nil { return fmt.Errorf("failed to delete HO instance %d: %w", instanceID, err) } @@ -499,21 +546,29 @@ func (dhom *DatabaseHeroicOPManager) DeleteHOInstance(ctx context.Context, insta // SaveHOEvent saves a heroic opportunity event func (dhom *DatabaseHeroicOPManager) SaveHOEvent(ctx context.Context, event *HeroicOPEvent) error { + conn, err := dhom.pool.Take(ctx) + if err != nil { + return fmt.Errorf("failed to get database connection: %w", err) + } + defer dhom.pool.Put(conn) + query := `INSERT INTO heroic_op_events (id, instance_id, event_type, character_id, ability_icon, timestamp, data) VALUES (?, ?, ?, ?, ?, ?, ?)` timestampUnix := event.Timestamp.Unix() - _, err := dhom.db.ExecContext(ctx, query, - event.ID, - event.InstanceID, - event.EventType, - event.CharacterID, - event.AbilityIcon, - timestampUnix, - event.Data, - ) + err = sqlitex.Execute(conn, query, &sqlitex.ExecOptions{ + Args: []any{ + event.ID, + event.InstanceID, + event.EventType, + event.CharacterID, + event.AbilityIcon, + timestampUnix, + event.Data, + }, + }) if err != nil { return fmt.Errorf("failed to save HO event %d: %w", event.ID, err) } @@ -523,51 +578,49 @@ func (dhom *DatabaseHeroicOPManager) SaveHOEvent(ctx context.Context, event *Her // LoadHOEvents retrieves events for a heroic opportunity instance func (dhom *DatabaseHeroicOPManager) LoadHOEvents(ctx context.Context, instanceID int64) ([]HeroicOPEvent, error) { + conn, err := dhom.pool.Take(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get database connection: %w", err) + } + defer dhom.pool.Put(conn) + query := `SELECT id, instance_id, event_type, character_id, ability_icon, timestamp, data FROM heroic_op_events WHERE instance_id = ? ORDER BY timestamp ASC` - rows, err := dhom.db.QueryContext(ctx, query, instanceID) + var events []HeroicOPEvent + err = sqlitex.Execute(conn, query, &sqlitex.ExecOptions{ + Args: []any{instanceID}, + ResultFunc: func(stmt *sqlite.Stmt) error { + var event HeroicOPEvent + event.ID = stmt.ColumnInt64(0) + event.InstanceID = stmt.ColumnInt64(1) + event.EventType = int(stmt.ColumnInt64(2)) + event.CharacterID = int32(stmt.ColumnInt64(3)) + event.AbilityIcon = int16(stmt.ColumnInt64(4)) + timestampUnix := stmt.ColumnInt64(5) + event.Timestamp = time.Unix(timestampUnix, 0) + if stmt.ColumnType(6) != sqlite.TypeNull { + event.Data = stmt.ColumnText(6) + } + events = append(events, event) + return nil + }, + }) if err != nil { return nil, fmt.Errorf("failed to query HO events for instance %d: %w", instanceID, err) } - defer rows.Close() - - var events []HeroicOPEvent - for rows.Next() { - var event HeroicOPEvent - var timestampUnix int64 - var data *string - - err := rows.Scan( - &event.ID, - &event.InstanceID, - &event.EventType, - &event.CharacterID, - &event.AbilityIcon, - ×tampUnix, - &data, - ) - if err != nil { - return nil, fmt.Errorf("failed to scan HO event row: %w", err) - } - - event.Timestamp = time.Unix(timestampUnix, 0) - if data != nil { - event.Data = *data - } - - events = append(events, event) - } - - if err := rows.Err(); err != nil { - return nil, fmt.Errorf("error iterating HO event rows: %w", err) - } return events, nil } // GetHOStatistics retrieves statistics for a character func (dhom *DatabaseHeroicOPManager) GetHOStatistics(ctx context.Context, characterID int32) (*HeroicOPStatistics, error) { + conn, err := dhom.pool.Take(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get database connection: %w", err) + } + defer dhom.pool.Put(conn) + // This is a simplified implementation - in practice you'd want more complex statistics stats := &HeroicOPStatistics{ ParticipationStats: make(map[int32]int64), @@ -576,7 +629,13 @@ func (dhom *DatabaseHeroicOPManager) GetHOStatistics(ctx context.Context, charac // Count total HOs started by this character query := `SELECT COUNT(*) FROM heroic_op_events WHERE character_id = ? AND event_type = ?` - err := dhom.db.QueryRowContext(ctx, query, characterID, EventHOStarted).Scan(&stats.TotalHOsStarted) + err = sqlitex.Execute(conn, query, &sqlitex.ExecOptions{ + Args: []any{characterID, EventHOStarted}, + ResultFunc: func(stmt *sqlite.Stmt) error { + stats.TotalHOsStarted = stmt.ColumnInt64(0) + return nil + }, + }) if err != nil { return nil, fmt.Errorf("failed to get HO started count: %w", err) } @@ -584,7 +643,13 @@ func (dhom *DatabaseHeroicOPManager) GetHOStatistics(ctx context.Context, charac // Count total HOs completed by this character query = `SELECT COUNT(*) FROM heroic_op_events WHERE character_id = ? AND event_type = ?` - err = dhom.db.QueryRowContext(ctx, query, characterID, EventHOCompleted).Scan(&stats.TotalHOsCompleted) + err = sqlitex.Execute(conn, query, &sqlitex.ExecOptions{ + Args: []any{characterID, EventHOCompleted}, + ResultFunc: func(stmt *sqlite.Stmt) error { + stats.TotalHOsCompleted = stmt.ColumnInt64(0) + return nil + }, + }) if err != nil { return nil, fmt.Errorf("failed to get HO completed count: %w", err) } @@ -601,10 +666,22 @@ func (dhom *DatabaseHeroicOPManager) GetHOStatistics(ctx context.Context, charac // GetNextStarterID returns the next available starter ID func (dhom *DatabaseHeroicOPManager) GetNextStarterID(ctx context.Context) (int32, error) { + conn, err := dhom.pool.Take(ctx) + if err != nil { + return 0, fmt.Errorf("failed to get database connection: %w", err) + } + defer dhom.pool.Put(conn) + query := "SELECT COALESCE(MAX(id), 0) + 1 FROM heroic_ops WHERE ho_type = ?" var nextID int32 - err := dhom.db.QueryRowContext(ctx, query, HOTypeStarter).Scan(&nextID) + err = sqlitex.Execute(conn, query, &sqlitex.ExecOptions{ + Args: []any{HOTypeStarter}, + ResultFunc: func(stmt *sqlite.Stmt) error { + nextID = int32(stmt.ColumnInt64(0)) + return nil + }, + }) if err != nil { return 0, fmt.Errorf("failed to get next starter ID: %w", err) } @@ -614,10 +691,22 @@ func (dhom *DatabaseHeroicOPManager) GetNextStarterID(ctx context.Context) (int3 // GetNextWheelID returns the next available wheel ID func (dhom *DatabaseHeroicOPManager) GetNextWheelID(ctx context.Context) (int32, error) { + conn, err := dhom.pool.Take(ctx) + if err != nil { + return 0, fmt.Errorf("failed to get database connection: %w", err) + } + defer dhom.pool.Put(conn) + query := "SELECT COALESCE(MAX(id), 0) + 1 FROM heroic_ops WHERE ho_type = ?" var nextID int32 - err := dhom.db.QueryRowContext(ctx, query, HOTypeWheel).Scan(&nextID) + err = sqlitex.Execute(conn, query, &sqlitex.ExecOptions{ + Args: []any{HOTypeWheel}, + ResultFunc: func(stmt *sqlite.Stmt) error { + nextID = int32(stmt.ColumnInt64(0)) + return nil + }, + }) if err != nil { return 0, fmt.Errorf("failed to get next wheel ID: %w", err) } @@ -627,10 +716,21 @@ func (dhom *DatabaseHeroicOPManager) GetNextWheelID(ctx context.Context) (int32, // GetNextInstanceID returns the next available instance ID func (dhom *DatabaseHeroicOPManager) GetNextInstanceID(ctx context.Context) (int64, error) { + conn, err := dhom.pool.Take(ctx) + if err != nil { + return 0, fmt.Errorf("failed to get database connection: %w", err) + } + defer dhom.pool.Put(conn) + query := "SELECT COALESCE(MAX(id), 0) + 1 FROM heroic_op_instances" var nextID int64 - err := dhom.db.QueryRowContext(ctx, query).Scan(&nextID) + err = sqlitex.Execute(conn, query, &sqlitex.ExecOptions{ + ResultFunc: func(stmt *sqlite.Stmt) error { + nextID = stmt.ColumnInt64(0) + return nil + }, + }) if err != nil { return 0, fmt.Errorf("failed to get next instance ID: %w", err) } @@ -640,6 +740,12 @@ func (dhom *DatabaseHeroicOPManager) GetNextInstanceID(ctx context.Context) (int // EnsureHOTables creates the heroic opportunity tables if they don't exist func (dhom *DatabaseHeroicOPManager) EnsureHOTables(ctx context.Context) error { + conn, err := dhom.pool.Take(ctx) + if err != nil { + return fmt.Errorf("failed to get database connection: %w", err) + } + defer dhom.pool.Put(conn) + queries := []string{ `CREATE TABLE IF NOT EXISTS heroic_ops ( id INTEGER NOT NULL, @@ -700,7 +806,7 @@ func (dhom *DatabaseHeroicOPManager) EnsureHOTables(ctx context.Context) error { } for i, query := range queries { - _, err := dhom.db.ExecContext(ctx, query) + err := sqlitex.Execute(conn, query, nil) if err != nil { return fmt.Errorf("failed to create HO table %d: %w", i+1, err) } @@ -720,7 +826,7 @@ func (dhom *DatabaseHeroicOPManager) EnsureHOTables(ctx context.Context) error { } for i, query := range indexes { - _, err := dhom.db.ExecContext(ctx, query) + err := sqlitex.Execute(conn, query, nil) if err != nil { return fmt.Errorf("failed to create HO index %d: %w", i+1, err) } diff --git a/internal/heroic_ops/database_test.go b/internal/heroic_ops/database_test.go new file mode 100644 index 0000000..b8fcf7d --- /dev/null +++ b/internal/heroic_ops/database_test.go @@ -0,0 +1,721 @@ +package heroic_ops + +import ( + "context" + "fmt" + "path/filepath" + "testing" + "time" + + "zombiezen.com/go/sqlite" + "zombiezen.com/go/sqlite/sqlitex" +) + +// createTestPool creates a temporary test database pool +func createTestPool(t *testing.T) *sqlitex.Pool { + // Create temporary directory for test database + tempDir := t.TempDir() + dbPath := filepath.Join(tempDir, "test_heroic_ops.db") + + // Create and initialize database pool + pool, err := sqlitex.NewPool(dbPath, sqlitex.PoolOptions{ + Flags: sqlite.OpenReadWrite | sqlite.OpenCreate, + }) + if err != nil { + t.Fatalf("Failed to create test database pool: %v", err) + } + + // Create heroic ops tables for testing + dhom := NewDatabaseHeroicOPManager(pool) + err = dhom.EnsureHOTables(context.Background()) + if err != nil { + t.Fatalf("Failed to create heroic ops tables: %v", err) + } + + return pool +} + +// execSQL is a helper to execute SQL with parameters +func execSQL(t *testing.T, pool *sqlitex.Pool, query string, args ...interface{}) { + conn, err := pool.Take(context.Background()) + if err != nil { + t.Fatalf("Failed to get connection: %v", err) + } + defer pool.Put(conn) + + err = sqlitex.Execute(conn, query, &sqlitex.ExecOptions{ + Args: args, + }) + if err != nil { + t.Fatalf("Failed to execute SQL: %v", err) + } +} + +// TestDatabaseHeroicOPManager_LoadStarters tests loading starters from database +func TestDatabaseHeroicOPManager_LoadStarters(t *testing.T) { + pool := createTestPool(t) + defer pool.Close() + + dhom := NewDatabaseHeroicOPManager(pool) + ctx := context.Background() + + // Insert test starter data + execSQL(t, pool, `INSERT INTO heroic_ops + (id, ho_type, starter_class, starter_icon, starter_link_id, chain_order, + shift_icon, spell_id, chance, ability1, ability2, ability3, ability4, + ability5, ability6, name, description) + VALUES (?, ?, ?, ?, 0, 0, 0, 0, 0.0, ?, ?, ?, ?, ?, ?, ?, ?)`, + 1, HOTypeStarter, 5, 100, 10, 20, 30, 40, 50, 60, "Test Starter", "A test starter") + + // Test loading starters + starters, err := dhom.LoadStarters(ctx) + if err != nil { + t.Fatalf("Failed to load starters: %v", err) + } + + if len(starters) != 1 { + t.Errorf("Expected 1 starter, got %d", len(starters)) + } + + if len(starters) > 0 { + starter := starters[0] + if starter.ID != 1 { + t.Errorf("Expected starter ID 1, got %d", starter.ID) + } + if starter.StarterClass != 5 { + t.Errorf("Expected starter class 5, got %d", starter.StarterClass) + } + if starter.Name != "Test Starter" { + t.Errorf("Expected name 'Test Starter', got '%s'", starter.Name) + } + if starter.Ability1 != 10 { + t.Errorf("Expected ability1 10, got %d", starter.Ability1) + } + } +} + +// TestDatabaseHeroicOPManager_LoadStarter tests loading a specific starter +func TestDatabaseHeroicOPManager_LoadStarter(t *testing.T) { + pool := createTestPool(t) + defer pool.Close() + + dhom := NewDatabaseHeroicOPManager(pool) + ctx := context.Background() + + // Insert test starter data + execSQL(t, pool, `INSERT INTO heroic_ops + (id, ho_type, starter_class, starter_icon, starter_link_id, chain_order, + shift_icon, spell_id, chance, ability1, ability2, ability3, ability4, + ability5, ability6, name, description) + VALUES (?, ?, ?, ?, 0, 0, 0, 0, 0.0, ?, ?, ?, ?, ?, ?, ?, ?)`, + 2, HOTypeStarter, 10, 200, 11, 21, 31, 41, 51, 61, "Specific Starter", "A specific test starter") + + // Test loading specific starter + starter, err := dhom.LoadStarter(ctx, 2) + if err != nil { + t.Fatalf("Failed to load starter: %v", err) + } + + if starter.ID != 2 { + t.Errorf("Expected starter ID 2, got %d", starter.ID) + } + if starter.StarterClass != 10 { + t.Errorf("Expected starter class 10, got %d", starter.StarterClass) + } + if starter.Name != "Specific Starter" { + t.Errorf("Expected name 'Specific Starter', got '%s'", starter.Name) + } + + // Test loading non-existent starter + _, err = dhom.LoadStarter(ctx, 999) + if err == nil { + t.Error("Expected error loading non-existent starter, got nil") + } +} + +// TestDatabaseHeroicOPManager_LoadWheels tests loading wheels from database +func TestDatabaseHeroicOPManager_LoadWheels(t *testing.T) { + pool := createTestPool(t) + defer pool.Close() + + dhom := NewDatabaseHeroicOPManager(pool) + ctx := context.Background() + + // Insert test wheel data + execSQL(t, pool, `INSERT INTO heroic_ops + (id, ho_type, starter_class, starter_icon, starter_link_id, chain_order, + shift_icon, spell_id, chance, ability1, ability2, ability3, ability4, + ability5, ability6, name, description) + VALUES (?, ?, 0, 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + 10, HOTypeWheel, 1, 1, 300, 1001, 0.75, 15, 25, 35, 45, 55, 65, "Test Wheel", "A test wheel") + + // Test loading wheels + wheels, err := dhom.LoadWheels(ctx) + if err != nil { + t.Fatalf("Failed to load wheels: %v", err) + } + + if len(wheels) != 1 { + t.Errorf("Expected 1 wheel, got %d", len(wheels)) + } + + if len(wheels) > 0 { + wheel := wheels[0] + if wheel.ID != 10 { + t.Errorf("Expected wheel ID 10, got %d", wheel.ID) + } + if wheel.StarterLinkID != 1 { + t.Errorf("Expected starter link ID 1, got %d", wheel.StarterLinkID) + } + if wheel.SpellID != 1001 { + t.Errorf("Expected spell ID 1001, got %d", wheel.SpellID) + } + if wheel.Chance != 0.75 { + t.Errorf("Expected chance 0.75, got %f", wheel.Chance) + } + } +} + +// TestDatabaseHeroicOPManager_LoadWheelsForStarter tests loading wheels for specific starter +func TestDatabaseHeroicOPManager_LoadWheelsForStarter(t *testing.T) { + pool := createTestPool(t) + defer pool.Close() + + dhom := NewDatabaseHeroicOPManager(pool) + ctx := context.Background() + + starterID := int32(5) + + // Insert multiple wheels for the same starter + execSQL(t, pool, `INSERT INTO heroic_ops + (id, ho_type, starter_class, starter_icon, starter_link_id, chain_order, + shift_icon, spell_id, chance, ability1, ability2, ability3, ability4, + ability5, ability6, name, description) + VALUES (?, ?, 0, 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + 20, HOTypeWheel, starterID, 1, 400, 1002, 0.5, 16, 26, 36, 46, 56, 66, "Wheel 1", "First wheel") + + execSQL(t, pool, `INSERT INTO heroic_ops + (id, ho_type, starter_class, starter_icon, starter_link_id, chain_order, + shift_icon, spell_id, chance, ability1, ability2, ability3, ability4, + ability5, ability6, name, description) + VALUES (?, ?, 0, 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + 21, HOTypeWheel, starterID, 2, 500, 1003, 0.8, 17, 27, 37, 47, 57, 67, "Wheel 2", "Second wheel") + + // Test loading wheels for specific starter + wheels, err := dhom.LoadWheelsForStarter(ctx, starterID) + if err != nil { + t.Fatalf("Failed to load wheels for starter: %v", err) + } + + if len(wheels) != 2 { + t.Errorf("Expected 2 wheels, got %d", len(wheels)) + } + + // Test loading wheels for non-existent starter + wheels, err = dhom.LoadWheelsForStarter(ctx, 999) + if err != nil { + t.Fatalf("Failed to load wheels for non-existent starter: %v", err) + } + + if len(wheels) != 0 { + t.Errorf("Expected 0 wheels for non-existent starter, got %d", len(wheels)) + } +} + +// TestDatabaseHeroicOPManager_SaveStarter tests saving a heroic op starter +func TestDatabaseHeroicOPManager_SaveStarter(t *testing.T) { + pool := createTestPool(t) + defer pool.Close() + + dhom := NewDatabaseHeroicOPManager(pool) + ctx := context.Background() + + // Create a test starter + starter := &HeroicOPStarter{ + ID: 100, + StartClass: 15, + StarterIcon: 500, + Abilities: [6]int16{70, 80, 90, 100, 110, 120}, + Name: "Saved Starter", + Description: "A saved test starter", + } + + // Save the starter + err := dhom.SaveStarter(ctx, starter) + if err != nil { + t.Fatalf("Failed to save starter: %v", err) + } + + // Load and verify + loaded, err := dhom.LoadStarter(ctx, 100) + if err != nil { + t.Fatalf("Failed to load saved starter: %v", err) + } + + if loaded.StarterClass != 15 { + t.Errorf("Expected starter class 15, got %d", loaded.StarterClass) + } + if loaded.Name != "Saved Starter" { + t.Errorf("Expected name 'Saved Starter', got '%s'", loaded.Name) + } + if loaded.Ability1 != 70 { + t.Errorf("Expected ability1 70, got %d", loaded.Ability1) + } +} + +// TestDatabaseHeroicOPManager_SaveWheel tests saving a heroic op wheel +func TestDatabaseHeroicOPManager_SaveWheel(t *testing.T) { + pool := createTestPool(t) + defer pool.Close() + + dhom := NewDatabaseHeroicOPManager(pool) + ctx := context.Background() + + // Create a test wheel + wheel := &HeroicOPWheel{ + ID: 200, + StarterLinkID: 100, + Order: 2, + ShiftIcon: 600, + SpellID: 2001, + Chance: 0.9, + Abilities: [6]int16{71, 81, 91, 101, 111, 121}, + Name: "Saved Wheel", + Description: "A saved test wheel", + } + + // Save the wheel + err := dhom.SaveWheel(ctx, wheel) + if err != nil { + t.Fatalf("Failed to save wheel: %v", err) + } + + // Load and verify + loaded, err := dhom.LoadWheel(ctx, 200) + if err != nil { + t.Fatalf("Failed to load saved wheel: %v", err) + } + + if loaded.StarterLinkID != 100 { + t.Errorf("Expected starter link ID 100, got %d", loaded.StarterLinkID) + } + if loaded.SpellID != 2001 { + t.Errorf("Expected spell ID 2001, got %d", loaded.SpellID) + } + if loaded.Chance != 0.9 { + t.Errorf("Expected chance 0.9, got %f", loaded.Chance) + } +} + +// TestDatabaseHeroicOPManager_DeleteStarter tests deleting a starter and its wheels +func TestDatabaseHeroicOPManager_DeleteStarter(t *testing.T) { + pool := createTestPool(t) + defer pool.Close() + + dhom := NewDatabaseHeroicOPManager(pool) + ctx := context.Background() + + starterID := int32(300) + + // Insert starter + execSQL(t, pool, `INSERT INTO heroic_ops + (id, ho_type, starter_class, starter_icon, starter_link_id, chain_order, + shift_icon, spell_id, chance, ability1, ability2, ability3, ability4, + ability5, ability6, name, description) + VALUES (?, ?, ?, ?, 0, 0, 0, 0, 0.0, ?, ?, ?, ?, ?, ?, ?, ?)`, + starterID, HOTypeStarter, 20, 700, 72, 82, 92, 102, 112, 122, "Delete Test Starter", "To be deleted") + + // Insert associated wheels + execSQL(t, pool, `INSERT INTO heroic_ops + (id, ho_type, starter_class, starter_icon, starter_link_id, chain_order, + shift_icon, spell_id, chance, ability1, ability2, ability3, ability4, + ability5, ability6, name, description) + VALUES (?, ?, 0, 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + 400, HOTypeWheel, starterID, 1, 800, 3001, 0.6, 73, 83, 93, 103, 113, 123, "Wheel to Delete", "Will be deleted") + + // Verify they exist + _, err := dhom.LoadStarter(ctx, starterID) + if err != nil { + t.Fatalf("Starter should exist before deletion: %v", err) + } + + wheels, err := dhom.LoadWheelsForStarter(ctx, starterID) + if err != nil { + t.Fatalf("Failed to load wheels before deletion: %v", err) + } + if len(wheels) != 1 { + t.Errorf("Expected 1 wheel before deletion, got %d", len(wheels)) + } + + // Delete the starter + err = dhom.DeleteStarter(ctx, starterID) + if err != nil { + t.Fatalf("Failed to delete starter: %v", err) + } + + // Verify they're gone + _, err = dhom.LoadStarter(ctx, starterID) + if err == nil { + t.Error("Expected error loading deleted starter, got nil") + } + + wheels, err = dhom.LoadWheelsForStarter(ctx, starterID) + if err != nil { + t.Fatalf("Failed to load wheels after deletion: %v", err) + } + if len(wheels) != 0 { + t.Errorf("Expected 0 wheels after deletion, got %d", len(wheels)) + } +} + +// TestDatabaseHeroicOPManager_HeroicOPInstance tests HO instance operations +func TestDatabaseHeroicOPManager_HeroicOPInstance(t *testing.T) { + pool := createTestPool(t) + defer pool.Close() + + dhom := NewDatabaseHeroicOPManager(pool) + ctx := context.Background() + + // Create a test HO instance + ho := &HeroicOP{ + ID: 1000, + EncounterID: 500, + StarterID: 10, + WheelID: 20, + State: int8(HOStateWheelPhase), + StartTime: time.Now(), + WheelStartTime: time.Now().Add(5 * time.Second), + TimeRemaining: 8000, + TotalTime: 10000, + Complete: 0, + Countered: [6]int8{1, 1, 0, 0, 0, 0}, + ShiftUsed: 0, + StarterProgress: 3, + CompletedBy: 0, + SpellName: "Test Spell", + SpellDescription: "A test completion spell", + } + + // Save the HO instance + err := dhom.SaveHOInstance(ctx, ho) + if err != nil { + t.Fatalf("Failed to save HO instance: %v", err) + } + + // Load and verify + loaded, err := dhom.LoadHOInstance(ctx, 1000) + if err != nil { + t.Fatalf("Failed to load HO instance: %v", err) + } + + if loaded.EncounterID != 500 { + t.Errorf("Expected encounter ID 500, got %d", loaded.EncounterID) + } + if loaded.State != int8(HOStateWheelPhase) { + t.Errorf("Expected state %d, got %d", HOStateWheelPhase, loaded.State) + } + if loaded.TimeRemaining != 8000 { + t.Errorf("Expected time remaining 8000, got %d", loaded.TimeRemaining) + } + if loaded.SpellName != "Test Spell" { + t.Errorf("Expected spell name 'Test Spell', got '%s'", loaded.SpellName) + } + + // Delete the instance + err = dhom.DeleteHOInstance(ctx, 1000) + if err != nil { + t.Fatalf("Failed to delete HO instance: %v", err) + } + + // Verify it's gone + _, err = dhom.LoadHOInstance(ctx, 1000) + if err == nil { + t.Error("Expected error loading deleted HO instance, got nil") + } +} + +// TestDatabaseHeroicOPManager_HeroicOPEvents tests HO event operations +func TestDatabaseHeroicOPManager_HeroicOPEvents(t *testing.T) { + pool := createTestPool(t) + defer pool.Close() + + dhom := NewDatabaseHeroicOPManager(pool) + ctx := context.Background() + + instanceID := int64(2000) + + // Create test events + events := []*HeroicOPEvent{ + { + ID: 1, + InstanceID: instanceID, + EventType: EventHOStarted, + CharacterID: 100, + AbilityIcon: 50, + Timestamp: time.Now(), + Data: "started", + }, + { + ID: 2, + InstanceID: instanceID, + EventType: EventHOAbilityUsed, + CharacterID: 101, + AbilityIcon: 51, + Timestamp: time.Now().Add(1 * time.Second), + Data: "ability used", + }, + } + + // Save events + for _, event := range events { + err := dhom.SaveHOEvent(ctx, event) + if err != nil { + t.Fatalf("Failed to save HO event: %v", err) + } + } + + // Load and verify + loadedEvents, err := dhom.LoadHOEvents(ctx, instanceID) + if err != nil { + t.Fatalf("Failed to load HO events: %v", err) + } + + if len(loadedEvents) != 2 { + t.Errorf("Expected 2 events, got %d", len(loadedEvents)) + } + + // Events should be ordered by timestamp + if len(loadedEvents) >= 2 { + if loadedEvents[0].EventType != EventHOStarted { + t.Errorf("Expected first event type %d, got %d", EventHOStarted, loadedEvents[0].EventType) + } + if loadedEvents[1].EventType != EventHOAbilityUsed { + t.Errorf("Expected second event type %d, got %d", EventHOAbilityUsed, loadedEvents[1].EventType) + } + } +} + +// TestDatabaseHeroicOPManager_Statistics tests HO statistics retrieval +func TestDatabaseHeroicOPManager_Statistics(t *testing.T) { + pool := createTestPool(t) + defer pool.Close() + + dhom := NewDatabaseHeroicOPManager(pool) + ctx := context.Background() + + characterID := int32(1001) + + // Insert test events for statistics + execSQL(t, pool, `INSERT INTO heroic_op_events + (id, instance_id, event_type, character_id, ability_icon, timestamp, data) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + 1, 100, EventHOStarted, characterID, 0, time.Now().Unix(), "") + + execSQL(t, pool, `INSERT INTO heroic_op_events + (id, instance_id, event_type, character_id, ability_icon, timestamp, data) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + 2, 100, EventHOCompleted, characterID, 0, time.Now().Add(10*time.Second).Unix(), "") + + execSQL(t, pool, `INSERT INTO heroic_op_events + (id, instance_id, event_type, character_id, ability_icon, timestamp, data) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + 3, 101, EventHOStarted, characterID, 0, time.Now().Add(20*time.Second).Unix(), "") + + // Get statistics + stats, err := dhom.GetHOStatistics(ctx, characterID) + if err != nil { + t.Fatalf("Failed to get HO statistics: %v", err) + } + + if stats.TotalHOsStarted != 2 { + t.Errorf("Expected 2 HOs started, got %d", stats.TotalHOsStarted) + } + if stats.TotalHOsCompleted != 1 { + t.Errorf("Expected 1 HO completed, got %d", stats.TotalHOsCompleted) + } + if stats.SuccessRate != 50.0 { + t.Errorf("Expected success rate 50.0, got %f", stats.SuccessRate) + } +} + +// TestDatabaseHeroicOPManager_NextIDs tests ID generation +func TestDatabaseHeroicOPManager_NextIDs(t *testing.T) { + pool := createTestPool(t) + defer pool.Close() + + dhom := NewDatabaseHeroicOPManager(pool) + ctx := context.Background() + + // Test next starter ID (should be 1 for empty database) + starterID, err := dhom.GetNextStarterID(ctx) + if err != nil { + t.Fatalf("Failed to get next starter ID: %v", err) + } + if starterID != 1 { + t.Errorf("Expected next starter ID 1, got %d", starterID) + } + + // Insert a starter and test again + execSQL(t, pool, `INSERT INTO heroic_ops + (id, ho_type, starter_class, starter_icon, starter_link_id, chain_order, + shift_icon, spell_id, chance, ability1, ability2, ability3, ability4, + ability5, ability6, name, description) + VALUES (?, ?, ?, ?, 0, 0, 0, 0, 0.0, ?, ?, ?, ?, ?, ?, ?, ?)`, + 5, HOTypeStarter, 1, 100, 1, 2, 3, 4, 5, 6, "Test", "Test") + + starterID, err = dhom.GetNextStarterID(ctx) + if err != nil { + t.Fatalf("Failed to get next starter ID after insert: %v", err) + } + if starterID != 6 { + t.Errorf("Expected next starter ID 6, got %d", starterID) + } + + // Test next wheel ID + wheelID, err := dhom.GetNextWheelID(ctx) + if err != nil { + t.Fatalf("Failed to get next wheel ID: %v", err) + } + if wheelID != 1 { + t.Errorf("Expected next wheel ID 1, got %d", wheelID) + } + + // Test next instance ID + instanceID, err := dhom.GetNextInstanceID(ctx) + if err != nil { + t.Fatalf("Failed to get next instance ID: %v", err) + } + if instanceID != 1 { + t.Errorf("Expected next instance ID 1, got %d", instanceID) + } +} + +// BenchmarkDatabaseHeroicOPManager_LoadStarters benchmarks loading starters +func BenchmarkDatabaseHeroicOPManager_LoadStarters(b *testing.B) { + pool := createTestPool(&testing.T{}) + defer pool.Close() + + dhom := NewDatabaseHeroicOPManager(pool) + ctx := context.Background() + + // Insert test data + for i := 1; i <= 50; i++ { + execSQL(&testing.T{}, pool, `INSERT INTO heroic_ops + (id, ho_type, starter_class, starter_icon, starter_link_id, chain_order, + shift_icon, spell_id, chance, ability1, ability2, ability3, ability4, + ability5, ability6, name, description) + VALUES (?, ?, ?, ?, 0, 0, 0, 0, 0.0, ?, ?, ?, ?, ?, ?, ?, ?)`, + i, HOTypeStarter, i%10+1, i*10, + i*1, i*2, i*3, i*4, i*5, i*6, + fmt.Sprintf("Starter %d", i), fmt.Sprintf("Description %d", i)) + } + + b.ResetTimer() + + for i := 0; i < b.N; i++ { + starters, err := dhom.LoadStarters(ctx) + if err != nil { + b.Fatalf("Failed to load starters: %v", err) + } + if len(starters) != 50 { + b.Errorf("Expected 50 starters, got %d", len(starters)) + } + } +} + +// BenchmarkDatabaseHeroicOPManager_LoadWheels benchmarks loading wheels +func BenchmarkDatabaseHeroicOPManager_LoadWheels(b *testing.B) { + pool := createTestPool(&testing.T{}) + defer pool.Close() + + dhom := NewDatabaseHeroicOPManager(pool) + ctx := context.Background() + + // Insert test wheel data + for i := 1; i <= 100; i++ { + execSQL(&testing.T{}, pool, `INSERT INTO heroic_ops + (id, ho_type, starter_class, starter_icon, starter_link_id, chain_order, + shift_icon, spell_id, chance, ability1, ability2, ability3, ability4, + ability5, ability6, name, description) + VALUES (?, ?, 0, 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + i, HOTypeWheel, i%10+1, i%3+1, i*10, i*100+1000, float32(i%100)/100.0, + i*1, i*2, i*3, i*4, i*5, i*6, + fmt.Sprintf("Wheel %d", i), fmt.Sprintf("Wheel Description %d", i)) + } + + b.ResetTimer() + + for i := 0; i < b.N; i++ { + wheels, err := dhom.LoadWheels(ctx) + if err != nil { + b.Fatalf("Failed to load wheels: %v", err) + } + if len(wheels) != 100 { + b.Errorf("Expected 100 wheels, got %d", len(wheels)) + } + } +} + +// BenchmarkDatabaseHeroicOPManager_SaveStarter benchmarks saving starters +func BenchmarkDatabaseHeroicOPManager_SaveStarter(b *testing.B) { + pool := createTestPool(&testing.T{}) + defer pool.Close() + + dhom := NewDatabaseHeroicOPManager(pool) + ctx := context.Background() + + starter := &HeroicOPStarter{ + ID: 1000, + StartClass: 5, + StarterIcon: 100, + Abilities: [6]int16{10, 20, 30, 40, 50, 60}, + Name: "Benchmark Starter", + Description: "A benchmark test starter", + } + + b.ResetTimer() + + for i := 0; i < b.N; i++ { + err := dhom.SaveStarter(ctx, starter) + if err != nil { + b.Fatalf("Failed to save starter: %v", err) + } + } +} + +// BenchmarkDatabaseHeroicOPManager_SaveHOInstance benchmarks saving HO instances +func BenchmarkDatabaseHeroicOPManager_SaveHOInstance(b *testing.B) { + pool := createTestPool(&testing.T{}) + defer pool.Close() + + dhom := NewDatabaseHeroicOPManager(pool) + ctx := context.Background() + + ho := &HeroicOP{ + ID: 5000, + EncounterID: 1000, + StarterID: 50, + WheelID: 100, + State: int8(HOStateWheelPhase), + StartTime: time.Now(), + WheelStartTime: time.Now(), + TimeRemaining: 10000, + TotalTime: 10000, + Complete: 0, + Countered: [6]int8{0, 0, 0, 0, 0, 0}, + ShiftUsed: 0, + StarterProgress: 0, + CompletedBy: 0, + SpellName: "Benchmark Spell", + SpellDescription: "A benchmark spell", + } + + b.ResetTimer() + + for i := 0; i < b.N; i++ { + err := dhom.SaveHOInstance(ctx, ho) + if err != nil { + b.Fatalf("Failed to save HO instance: %v", err) + } + } +} \ No newline at end of file diff --git a/internal/heroic_ops/manager.go b/internal/heroic_ops/manager.go index 90e48df..729f553 100644 --- a/internal/heroic_ops/manager.go +++ b/internal/heroic_ops/manager.go @@ -14,6 +14,9 @@ func NewHeroicOPManager(masterList *MasterHeroicOPList, database HeroicOPDatabas encounterHOs: make(map[int32][]*HeroicOP), masterList: masterList, database: database, + clientManager: clientManager, + encounterManager: encounterManager, + playerManager: playerManager, nextInstanceID: 1, defaultWheelTimer: DefaultWheelTimerSeconds * 1000, // Convert to milliseconds maxConcurrentHOs: MaxConcurrentHOs, @@ -415,7 +418,7 @@ func (hom *HeroicOPManager) completeHO(ctx context.Context, ho *HeroicOP, wheel // Cast completion spell if wheel.SpellID > 0 { - participants := ho.GetParticipants() + _ = ho.GetParticipants() // participants will be used when spell manager is integrated // TODO: Cast spell on participants through spell manager // hom.spellManager.CastSpell(completedBy, wheel.SpellID, participants) } @@ -547,9 +550,9 @@ func (hom *HeroicOPManager) sendShiftUpdate(ho *HeroicOP, oldWheelID, newWheelID participants := ho.GetParticipants() packetBuilder := NewHeroicOPPacketBuilder(0) - for _, characterID := range participants { + for range participants { if packet, err := packetBuilder.BuildHOShiftPacket(ho, oldWheelID, newWheelID); err == nil { - // TODO: Send packet through client manager + // TODO: Send packet through client manager using characterID _ = packet // Placeholder } } diff --git a/internal/heroic_ops/types.go b/internal/heroic_ops/types.go index baab123..ff60ae8 100644 --- a/internal/heroic_ops/types.go +++ b/internal/heroic_ops/types.go @@ -97,14 +97,17 @@ type MasterHeroicOPList struct { // HeroicOPManager manages active heroic opportunity instances type HeroicOPManager struct { - mu sync.RWMutex - activeHOs map[int64]*HeroicOP // instance_id -> HO - encounterHOs map[int32][]*HeroicOP // encounter_id -> HOs - masterList *MasterHeroicOPList - database HeroicOPDatabase - eventHandler HeroicOPEventHandler - logger LogHandler - nextInstanceID int64 + mu sync.RWMutex + activeHOs map[int64]*HeroicOP // instance_id -> HO + encounterHOs map[int32][]*HeroicOP // encounter_id -> HOs + masterList *MasterHeroicOPList + database HeroicOPDatabase + eventHandler HeroicOPEventHandler + logger LogHandler + clientManager ClientManager + encounterManager EncounterManager + playerManager PlayerManager + nextInstanceID int64 // Configuration defaultWheelTimer int32 // milliseconds maxConcurrentHOs int