HASH 22cc45a47954
DATE 2026-05-02
SUBJECT ookstats: dedupe player_equipment + fix profile staleness check
FILES 9 CHANGED
HASH 22cc45a47954
DATE 2026-05-02
SUBJECT ookstats: dedupe player_equipment +
fix profile staleness check
FILES 9 CHANGED
┌─ nix/pkgs/ookstats/src/cmd/build.go ───────────────────────────────────────┐
│ diff --git a/nix/pkgs/ookstats/src/cmd/build.go b/nix/pkgs/ookstats/src/cmd/build. │
│ go │
│ index 7986179..381476d 100644 │
│ --- a/nix/pkgs/ookstats/src/cmd/build.go │
│ +++ b/nix/pkgs/ookstats/src/cmd/build.go │
│ @@ -264,11 +264,7 @@ func fetchProfilesOnce(db *sql.DB, client *blizzard.Client) e │
│ rror { │
│ return nil │
│ } │
│ │
│ - // batch in reasonable size │
│ batchSize := 20 │
│ - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) │
│ - defer cancel() │
│ - │
│ totalProfiles := 0 │
│ totalItems := 0 │
│ processed := 0 │
│ @@ -286,7 +282,8 @@ func fetchProfilesOnce(db *sql.DB, client *blizzard.Client) er │
│ ror { │
│ "batch", batchNum, │
│ "total_batches", totalBatches, │
│ "players", len(batch)) │
│ - results := client.FetchPlayerProfilesConcurrent(ctx, batch) │
│ + batchCtx, batchCancel := context.WithTimeout(context.Background(), 2*time.Minut │
│ e) │
│ + results := client.FetchPlayerProfilesConcurrent(batchCtx, batch) │
│ ts := time.Now().UnixMilli() │
│ batchProfiles := 0 │
│ batchItems := 0 │
│ @@ -310,6 +307,7 @@ func fetchProfilesOnce(db *sql.DB, client *blizzard.Client) er │
│ ror { │
│ batchProfiles += profs │
│ batchItems += items │
│ } │
│ + batchCancel() │
│ totalProfiles += batchProfiles │
│ totalItems += batchItems │
│ log.Info("batch complete", │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...kgs/ookstats/src/cmd/build.go ───┐
│ diff --git a/nix/pkgs/ookstats/src/cmd/build │
│ .go b/nix/pkgs/ookstats/src/cmd/build.go │
│ index 7986179..381476d 100644 │
│ --- a/nix/pkgs/ookstats/src/cmd/build.go │
│ +++ b/nix/pkgs/ookstats/src/cmd/build.go │
│ @@ -264,11 +264,7 @@ func fetchProfilesOnce( │
│ db *sql.DB, client *blizzard.Client) error { │
│ return nil │
│ } │
│ │
│ - // batch in reasonable size │
│ batchSize := 20 │
│ - ctx, cancel := context.WithTimeout(context │
│ .Background(), 30*time.Minute) │
│ - defer cancel() │
│ - │
│ totalProfiles := 0 │
│ totalItems := 0 │
│ processed := 0 │
│ @@ -286,7 +282,8 @@ func fetchProfilesOnce(d │
│ b *sql.DB, client *blizzard.Client) error { │
│ "batch", batchNum, │
│ "total_batches", totalBatches, │
│ "players", len(batch)) │
│ - results := client.FetchPlayerProfilesConc │
│ urrent(ctx, batch) │
│ + batchCtx, batchCancel := context.WithTime │
│ out(context.Background(), 2*time.Minute) │
│ + results := client.FetchPlayerProfilesConc │
│ urrent(batchCtx, batch) │
│ ts := time.Now().UnixMilli() │
│ batchProfiles := 0 │
│ batchItems := 0 │
│ @@ -310,6 +307,7 @@ func fetchProfilesOnce(d │
│ b *sql.DB, client *blizzard.Client) error { │
│ batchProfiles += profs │
│ batchItems += items │
│ } │
│ + batchCancel() │
│ totalProfiles += batchProfiles │
│ totalItems += batchItems │
│ log.Info("batch complete", │
└──────────────────────────────────────────────┘
┌─ nix/pkgs/ookstats/src/cmd/populate.go ────────────────────────────────────┐
│ diff --git a/nix/pkgs/ookstats/src/cmd/populate.go b/nix/pkgs/ookstats/src/cmd/pop │
│ ulate.go │
│ index e07eb65..aedfd12 100644 │
│ --- a/nix/pkgs/ookstats/src/cmd/populate.go │
│ +++ b/nix/pkgs/ookstats/src/cmd/populate.go │
│ @@ -3,11 +3,15 @@ package cmd │
│ import ( │
│ "database/sql" │
│ "encoding/json" │
│ + "errors" │
│ "fmt" │
│ + "net/http" │
│ "os" │
│ "strings" │
│ + "time" │
│ │
│ "github.com/spf13/cobra" │
│ + "ookstats/internal/blizzard" │
│ "ookstats/internal/database" │
│ ) │
│ │
│ @@ -51,12 +55,13 @@ type WowSimsDatabase struct { │
│ } │
│ │
│ type WowSimsItem struct { │
│ - ID int `json:"id"` │
│ - Name string `json:"name"` │
│ - Icon string `json:"icon"` │
│ - Quality int `json:"quality"` │
│ - Type int `json:"type"` │
│ - // items don't have stats array, they have other fields like weaponType, etc. │
│ + ID int `json:"id"` │
│ + Name string `json:"name"` │
│ + Icon string `json:"icon"` │
│ + Quality int `json:"quality"` │
│ + Type int `json:"type"` │
│ + ScalingOptions json.RawMessage `json:"scalingOptions,omitempty"` │
│ + ItemEffect json.RawMessage `json:"itemEffect,omitempty"` │
│ } │
│ │
│ type WowSimsGem struct { │
│ @@ -131,16 +136,26 @@ func populateItems(db *sql.DB, wowsimsDBPath string) error { │
│ fmt.Println("Inserting items...") │
│ insertCount := 0 │
│ │
│ - // insert items (no stats array) │
│ for _, item := range wowsimsDB.Items { │
│ if item.ID == 0 { │
│ continue │
│ } │
│ │
│ + statsJSON := "{}" │
│ + if len(item.ScalingOptions) > 0 { │
│ + statsJSON = string(item.ScalingOptions) │
│ + } │
│ + │
│ + var itemEffectJSON *string │
│ + if len(item.ItemEffect) > 0 { │
│ + s := string(item.ItemEffect) │
│ + itemEffectJSON = &s │
│ + } │
│ + │
│ _, err = tx.Exec(` │
│ - INSERT OR REPLACE INTO items (id, name, icon, quality, type, stats) │
│ - VALUES (?, ?, ?, ?, ?, ?) │
│ - `, item.ID, item.Name, item.Icon, item.Quality, item.Type, "{}") │
│ + INSERT OR REPLACE INTO items (id, name, icon, quality, type, stats, item_effec │
│ t) │
│ + VALUES (?, ?, ?, ?, ?, ?, ?) │
│ + `, item.ID, item.Name, item.Icon, item.Quality, item.Type, statsJSON, itemEffec │
│ tJSON) │
│ │
│ if err != nil { │
│ return fmt.Errorf("failed to insert item %d: %w", item.ID, err) │
│ @@ -221,10 +236,149 @@ func populateItems(db *sql.DB, wowsimsDBPath string) error │
│ { │
│ return nil │
│ } │
│ │
│ +var populateItemsEnrichCmd = &cobra.Command{ │
│ + Use: "items-enrich", │
│ + Short: "Enrich items with Blizzard spell descriptions", │
│ + Long: `Fetch spell descriptions from Blizzard API for items missing them.`, │
│ + RunE: func(cmd *cobra.Command, args []string) error { │
│ + fmt.Println("=== Item Enrichment ===") │
│ + │
│ + region, _ := cmd.Flags().GetString("region") │
│ + │
│ + db, err := database.Connect() │
│ + if err != nil { │
│ + return fmt.Errorf("failed to connect to database: %w", err) │
│ + } │
│ + defer db.Close() │
│ + │
│ + client, err := blizzard.NewClient() │
│ + if err != nil { │
│ + return fmt.Errorf("failed to create blizzard client: %w", err) │
│ + } │
│ + │
│ + if err := enrichItemsWithDescriptions(db, client, region); err != nil { │
│ + return fmt.Errorf("failed to enrich items: %w", err) │
│ + } │
│ + │
│ + fmt.Printf("Item enrichment complete!\n") │
│ + return nil │
│ + }, │
│ +} │
│ + │
│ +func enrichItemsWithDescriptions(db *sql.DB, client *blizzard.Client, region stri │
│ ng) error { │
│ + // get all item IDs that are missing spell_description (only trinkets type 12) │
│ + rows, err := db.Query(` │
│ + SELECT id FROM items │
│ + WHERE spell_description IS NULL │
│ + AND type = 12 │
│ + ORDER BY id │
│ + `) │
│ + if err != nil { │
│ + return fmt.Errorf("query items: %w", err) │
│ + } │
│ + │
│ + var itemIDs []int │
│ + for rows.Next() { │
│ + var id int │
│ + if err := rows.Scan(&id); err != nil { │
│ + rows.Close() │
│ + return fmt.Errorf("scan item id: %w", err) │
│ + } │
│ + itemIDs = append(itemIDs, id) │
│ + } │
│ + rows.Close() │
│ + │
│ + if len(itemIDs) == 0 { │
│ + fmt.Println("All items already have spell descriptions") │
│ + return nil │
│ + } │
│ + │
│ + fmt.Printf("Found %d items missing spell descriptions\n", len(itemIDs)) │
│ + │
│ + // prepare update statement │
│ + updateStmt, err := db.Prepare(`UPDATE items SET spell_description = ? WHERE id = │
│ ?`) │
│ + if err != nil { │
│ + return fmt.Errorf("prepare update: %w", err) │
│ + } │
│ + defer updateStmt.Close() │
│ + │
│ + fetched := 0 │
│ + skipped := 0 │
│ + notFound := 0 │
│ + startTime := time.Now() │
│ + │
│ + for i, itemID := range itemIDs { │
│ + // progress every 100 items │
│ + if (i+1)%100 == 0 { │
│ + elapsed := time.Since(startTime) │
│ + rate := float64(i+1) / elapsed.Seconds() │
│ + remaining := float64(len(itemIDs)-i-1) / rate │
│ + fmt.Printf(" Progress: %d/%d (%.1f/sec, ~%.0fs remaining)\n", │
│ + i+1, len(itemIDs), rate, remaining) │
│ + } │
│ + │
│ + item, err := client.FetchItem(itemID, region) │
│ + if err != nil { │
│ + var apiErr *blizzard.APIError │
│ + if errors.As(err, &apiErr) && apiErr.Status == http.StatusNotFound { │
│ + notFound++ │
│ + continue │
│ + } │
│ + fmt.Printf(" Warning: failed to fetch item %d: %v\n", itemID, err) │
│ + skipped++ │
│ + continue │
│ + } │
│ + │
│ + // extract spell descriptions │
│ + var descriptions []string │
│ + if item.PreviewItem != nil { │
│ + for _, spell := range item.PreviewItem.Spells { │
│ + if spell.Description != "" { │
│ + descriptions = append(descriptions, spell.Description) │
│ + } │
│ + } │
│ + } │
│ + │
│ + if len(descriptions) == 0 { │
│ + continue │
│ + } │
│ + │
│ + // debug: log first few items with descriptions │
│ + if fetched < 5 { │
│ + fmt.Printf(" DEBUG: Item %d (%s) has description: %.50s...\n", itemID, item.N │
│ ame, descriptions[0]) │
│ + } │
│ + │
│ + // join multiple descriptions with newline │
│ + desc := strings.Join(descriptions, "\n") │
│ + result, err := updateStmt.Exec(desc, itemID) │
│ + if err != nil { │
│ + fmt.Printf(" Warning: failed to update item %d: %v\n", itemID, err) │
│ + skipped++ │
│ + continue │
│ + } │
│ + │
│ + rowsAffected, _ := result.RowsAffected() │
│ + if rowsAffected == 0 { │
│ + fmt.Printf(" Warning: item %d update affected 0 rows\n", itemID) │
│ + } │
│ + │
│ + fetched++ │
│ + } │
│ + │
│ + elapsed := time.Since(startTime) │
│ + fmt.Printf("[OK] Enriched %d items with spell descriptions (%.1fs)\n", fetched, │
│ elapsed.Seconds()) │
│ + fmt.Printf(" %d not found in Blizzard API, %d skipped due to errors\n", notF │
│ ound, skipped) │
│ + return nil │
│ +} │
│ + │
│ func init() { │
│ rootCmd.AddCommand(populateCmd) │
│ populateCmd.AddCommand(populateItemsCmd) │
│ + populateCmd.AddCommand(populateItemsEnrichCmd) │
│ │
│ // add flag for wowsims database path │
│ populateItemsCmd.Flags().String("wowsims-db", "", "Path to WoW Sims database JSO │
│ N file") │
│ + │
│ + // add flag for region │
│ + populateItemsEnrichCmd.Flags().String("region", "us", "Blizzard API region (us, │
│ eu, etc)") │
│ } │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ .../ookstats/src/cmd/populate.go ───┐
│ diff --git a/nix/pkgs/ookstats/src/cmd/popul │
│ ate.go b/nix/pkgs/ookstats/src/cmd/populate. │
│ go │
│ index e07eb65..aedfd12 100644 │
│ --- a/nix/pkgs/ookstats/src/cmd/populate.go │
│ +++ b/nix/pkgs/ookstats/src/cmd/populate.go │
│ @@ -3,11 +3,15 @@ package cmd │
│ import ( │
│ "database/sql" │
│ "encoding/json" │
│ + "errors" │
│ "fmt" │
│ + "net/http" │
│ "os" │
│ "strings" │
│ + "time" │
│ │
│ "github.com/spf13/cobra" │
│ + "ookstats/internal/blizzard" │
│ "ookstats/internal/database" │
│ ) │
│ │
│ @@ -51,12 +55,13 @@ type WowSimsDatabase str │
│ uct { │
│ } │
│ │
│ type WowSimsItem struct { │
│ - ID int `json:"id"` │
│ - Name string `json:"name"` │
│ - Icon string `json:"icon"` │
│ - Quality int `json:"quality"` │
│ - Type int `json:"type"` │
│ - // items don't have stats array, they have │
│ other fields like weaponType, etc. │
│ + ID int `json:"id"` │
│ + Name string `json:"name │
│ "` │
│ + Icon string `json:"icon │
│ "` │
│ + Quality int `json:"qual │
│ ity"` │
│ + Type int `json:"type │
│ "` │
│ + ScalingOptions json.RawMessage `json:"scal │
│ ingOptions,omitempty"` │
│ + ItemEffect json.RawMessage `json:"item │
│ Effect,omitempty"` │
│ } │
│ │
│ type WowSimsGem struct { │
│ @@ -131,16 +136,26 @@ func populateItems(db │
│ *sql.DB, wowsimsDBPath string) error { │
│ fmt.Println("Inserting items...") │
│ insertCount := 0 │
│ │
│ - // insert items (no stats array) │
│ for _, item := range wowsimsDB.Items { │
│ if item.ID == 0 { │
│ continue │
│ } │
│ │
│ + statsJSON := "{}" │
│ + if len(item.ScalingOptions) > 0 { │
│ + statsJSON = string(item.ScalingOptions) │
│ + } │
│ + │
│ + var itemEffectJSON *string │
│ + if len(item.ItemEffect) > 0 { │
│ + s := string(item.ItemEffect) │
│ + itemEffectJSON = &s │
│ + } │
│ + │
│ _, err = tx.Exec(` │
│ - INSERT OR REPLACE INTO items (id, name, │
│ icon, quality, type, stats) │
│ - VALUES (?, ?, ?, ?, ?, ?) │
│ - `, item.ID, item.Name, item.Icon, item.Qu │
│ ality, item.Type, "{}") │
│ + INSERT OR REPLACE INTO items (id, name, │
│ icon, quality, type, stats, item_effect) │
│ + VALUES (?, ?, ?, ?, ?, ?, ?) │
│ + `, item.ID, item.Name, item.Icon, item.Qu │
│ ality, item.Type, statsJSON, itemEffectJSON) │
│ │
│ if err != nil { │
│ return fmt.Errorf("failed to insert item │
│ %d: %w", item.ID, err) │
│ @@ -221,10 +236,149 @@ func populateItems(db │
│ *sql.DB, wowsimsDBPath string) error { │
│ return nil │
│ } │
│ │
│ +var populateItemsEnrichCmd = &cobra.Command │
│ { │
│ + Use: "items-enrich", │
│ + Short: "Enrich items with Blizzard spell d │
│ escriptions", │
│ + Long: `Fetch spell descriptions from Bliz │
│ zard API for items missing them.`, │
│ + RunE: func(cmd *cobra.Command, args []stri │
│ ng) error { │
│ + fmt.Println("=== Item Enrichment ===") │
│ + │
│ + region, _ := cmd.Flags().GetString("regio │
│ n") │
│ + │
│ + db, err := database.Connect() │
│ + if err != nil { │
│ + return fmt.Errorf("failed to connect to │
│ database: %w", err) │
│ + } │
│ + defer db.Close() │
│ + │
│ + client, err := blizzard.NewClient() │
│ + if err != nil { │
│ + return fmt.Errorf("failed to create bliz │
│ zard client: %w", err) │
│ + } │
│ + │
│ + if err := enrichItemsWithDescriptions(db, │
│ client, region); err != nil { │
│ + return fmt.Errorf("failed to enrich item │
│ s: %w", err) │
│ + } │
│ + │
│ + fmt.Printf("Item enrichment complete!\n") │
│ + return nil │
│ + }, │
│ +} │
│ + │
│ +func enrichItemsWithDescriptions(db *sql.DB │
│ , client *blizzard.Client, region string) er │
│ ror { │
│ + // get all item IDs that are missing spell │
│ _description (only trinkets type 12) │
│ + rows, err := db.Query(` │
│ + SELECT id FROM items │
│ + WHERE spell_description IS NULL │
│ + AND type = 12 │
│ + ORDER BY id │
│ + `) │
│ + if err != nil { │
│ + return fmt.Errorf("query items: %w", err) │
│ + } │
│ + │
│ + var itemIDs []int │
│ + for rows.Next() { │
│ + var id int │
│ + if err := rows.Scan(&id); err != nil { │
│ + rows.Close() │
│ + return fmt.Errorf("scan item id: %w", er │
│ r) │
│ + } │
│ + itemIDs = append(itemIDs, id) │
│ + } │
│ + rows.Close() │
│ + │
│ + if len(itemIDs) == 0 { │
│ + fmt.Println("All items already have spell │
│ descriptions") │
│ + return nil │
│ + } │
│ + │
│ + fmt.Printf("Found %d items missing spell d │
│ escriptions\n", len(itemIDs)) │
│ + │
│ + // prepare update statement │
│ + updateStmt, err := db.Prepare(`UPDATE item │
│ s SET spell_description = ? WHERE id = ?`) │
│ + if err != nil { │
│ + return fmt.Errorf("prepare update: %w", e │
│ rr) │
│ + } │
│ + defer updateStmt.Close() │
│ + │
│ + fetched := 0 │
│ + skipped := 0 │
│ + notFound := 0 │
│ + startTime := time.Now() │
│ + │
│ + for i, itemID := range itemIDs { │
│ + // progress every 100 items │
│ + if (i+1)%100 == 0 { │
│ + elapsed := time.Since(startTime) │
│ + rate := float64(i+1) / elapsed.Seconds() │
│ + remaining := float64(len(itemIDs)-i-1) / │
│ rate │
│ + fmt.Printf(" Progress: %d/%d (%.1f/sec, │
│ ~%.0fs remaining)\n", │
│ + i+1, len(itemIDs), rate, remaining) │
│ + } │
│ + │
│ + item, err := client.FetchItem(itemID, reg │
│ ion) │
│ + if err != nil { │
│ + var apiErr *blizzard.APIError │
│ + if errors.As(err, &apiErr) && apiErr.Sta │
│ tus == http.StatusNotFound { │
│ + notFound++ │
│ + continue │
│ + } │
│ + fmt.Printf(" Warning: failed to fetch i │
│ tem %d: %v\n", itemID, err) │
│ + skipped++ │
│ + continue │
│ + } │
│ + │
│ + // extract spell descriptions │
│ + var descriptions []string │
│ + if item.PreviewItem != nil { │
│ + for _, spell := range item.PreviewItem.S │
│ pells { │
│ + if spell.Description != "" { │
│ + descriptions = append(descriptions, sp │
│ ell.Description) │
│ + } │
│ + } │
│ + } │
│ + │
│ + if len(descriptions) == 0 { │
│ + continue │
│ + } │
│ + │
│ + // debug: log first few items with descri │
│ ptions │
│ + if fetched < 5 { │
│ + fmt.Printf(" DEBUG: Item %d (%s) has de │
│ scription: %.50s...\n", itemID, item.Name, d │
│ escriptions[0]) │
│ + } │
│ + │
│ + // join multiple descriptions with newlin │
│ e │
│ + desc := strings.Join(descriptions, "\n") │
│ + result, err := updateStmt.Exec(desc, item │
│ ID) │
│ + if err != nil { │
│ + fmt.Printf(" Warning: failed to update │
│ item %d: %v\n", itemID, err) │
│ + skipped++ │
│ + continue │
│ + } │
│ + │
│ + rowsAffected, _ := result.RowsAffected() │
│ + if rowsAffected == 0 { │
│ + fmt.Printf(" Warning: item %d update af │
│ fected 0 rows\n", itemID) │
│ + } │
│ + │
│ + fetched++ │
│ + } │
│ + │
│ + elapsed := time.Since(startTime) │
│ + fmt.Printf("[OK] Enriched %d items with sp │
│ ell descriptions (%.1fs)\n", fetched, elapse │
│ d.Seconds()) │
│ + fmt.Printf(" %d not found in Blizzard │
│ API, %d skipped due to errors\n", notFound, │
│ skipped) │
│ + return nil │
│ +} │
│ + │
│ func init() { │
│ rootCmd.AddCommand(populateCmd) │
│ populateCmd.AddCommand(populateItemsCmd) │
│ + populateCmd.AddCommand(populateItemsEnrich │
│ Cmd) │
│ │
│ // add flag for wowsims database path │
│ populateItemsCmd.Flags().String("wowsims-d │
│ b", "", "Path to WoW Sims database JSON file │
│ ") │
│ + │
│ + // add flag for region │
│ + populateItemsEnrichCmd.Flags().String("reg │
│ ion", "us", "Blizzard API region (us, eu, et │
│ c)") │
│ } │
└──────────────────────────────────────────────┘
┌─ nix/pkgs/ookstats/src/internal/blizzard/profiles.go ──────────────────────┐
│ diff --git a/nix/pkgs/ookstats/src/internal/blizzard/profiles.go b/nix/pkgs/ooksta │
│ ts/src/internal/blizzard/profiles.go │
│ index 7bc92eb..33a60d6 100644 │
│ --- a/nix/pkgs/ookstats/src/internal/blizzard/profiles.go │
│ +++ b/nix/pkgs/ookstats/src/internal/blizzard/profiles.go │
│ @@ -87,6 +87,18 @@ func (c *Client) FetchCharacterAchievements(playerName, realmSl │
│ ug, region string │
│ return fetchPlayerProfileAPI[CharacterAchievementsResponse](c, url) │
│ } │
│ │
│ +// FetchItem fetches item details from the static game data API │
│ +func (c *Client) FetchItem(itemID int, region string) (*ItemDetailResponse, error │
│ ) { │
│ + // hardcoded namespace version - update when game patches │
│ + namespace := fmt.Sprintf("static-5.5.3_64530-classic-%s", region) │
│ + url := fmt.Sprintf( │
│ + "https://%s.api.blizzard.com/data/wow/item/%d?namespace=%s&locale=en_US", │
│ + region, itemID, namespace, │
│ + ) │
│ + │
│ + return fetchPlayerProfileAPI[ItemDetailResponse](c, url) │
│ +} │
│ + │
│ // fetchPlayerProfileAPI is a generic function for fetching player profile data │
│ func fetchPlayerProfileAPI[T any](c *Client, url string) (*T, error) { │
│ const maxRetries = 3 │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...internal/blizzard/profiles.go ───┐
│ diff --git a/nix/pkgs/ookstats/src/internal/ │
│ blizzard/profiles.go b/nix/pkgs/ookstats/src │
│ /internal/blizzard/profiles.go │
│ index 7bc92eb..33a60d6 100644 │
│ --- a/nix/pkgs/ookstats/src/internal/blizzar │
│ d/profiles.go │
│ +++ b/nix/pkgs/ookstats/src/internal/blizzar │
│ d/profiles.go │
│ @@ -87,6 +87,18 @@ func (c *Client) FetchCha │
│ racterAchievements(playerName, realmSlug, re │
│ gion string │
│ return fetchPlayerProfileAPI[CharacterAchi │
│ evementsResponse](c, url) │
│ } │
│ │
│ +// FetchItem fetches item details from the │
│ static game data API │
│ +func (c *Client) FetchItem(itemID int, regi │
│ on string) (*ItemDetailResponse, error) { │
│ + // hardcoded namespace version - update wh │
│ en game patches │
│ + namespace := fmt.Sprintf("static-5.5.3_645 │
│ 30-classic-%s", region) │
│ + url := fmt.Sprintf( │
│ + "https://%s.api.blizzard.com/data/wow/ite │
│ m/%d?namespace=%s&locale=en_US", │
│ + region, itemID, namespace, │
│ + ) │
│ + │
│ + return fetchPlayerProfileAPI[ItemDetailRes │
│ ponse](c, url) │
│ +} │
│ + │
│ // fetchPlayerProfileAPI is a generic funct │
│ ion for fetching player profile data │
│ func fetchPlayerProfileAPI[T any](c *Client │
│ , url string) (*T, error) { │
│ const maxRetries = 3 │
└──────────────────────────────────────────────┘
┌─ nix/pkgs/ookstats/src/internal/blizzard/types.go ─────────────────────────┐
│ diff --git a/nix/pkgs/ookstats/src/internal/blizzard/types.go b/nix/pkgs/ookstats/ │
│ src/internal/blizzard/types.go │
│ index d2ed1fb..6b5adcb 100644 │
│ --- a/nix/pkgs/ookstats/src/internal/blizzard/types.go │
│ +++ b/nix/pkgs/ookstats/src/internal/blizzard/types.go │
│ @@ -317,3 +317,24 @@ type SeasonDetailResponse struct { │
│ ID int `json:"id"` │
│ } `json:"periods"` │
│ } │
│ + │
│ +// ItemDetailResponse represents the response from the item detail API │
│ +type ItemDetailResponse struct { │
│ + ID int `json:"id"` │
│ + Name string `json:"name"` │
│ + PreviewItem *ItemPreviewData `json:"preview_item,omitempty"` │
│ +} │
│ + │
│ +// ItemPreviewData contains the preview_item section with spells │
│ +type ItemPreviewData struct { │
│ + Spells []ItemSpellInfo `json:"spells,omitempty"` │
│ +} │
│ + │
│ +// ItemSpellInfo contains spell information including description │
│ +type ItemSpellInfo struct { │
│ + Spell struct { │
│ + ID int `json:"id"` │
│ + Name string `json:"name"` │
│ + } `json:"spell"` │
│ + Description string `json:"description"` │
│ +} │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...rc/internal/blizzard/types.go ───┐
│ diff --git a/nix/pkgs/ookstats/src/internal/ │
│ blizzard/types.go b/nix/pkgs/ookstats/src/in │
│ ternal/blizzard/types.go │
│ index d2ed1fb..6b5adcb 100644 │
│ --- a/nix/pkgs/ookstats/src/internal/blizzar │
│ d/types.go │
│ +++ b/nix/pkgs/ookstats/src/internal/blizzar │
│ d/types.go │
│ @@ -317,3 +317,24 @@ type SeasonDetailRespon │
│ se struct { │
│ ID int `json:"id"` │
│ } `json:"periods"` │
│ } │
│ + │
│ +// ItemDetailResponse represents the respon │
│ se from the item detail API │
│ +type ItemDetailResponse struct { │
│ + ID int `json:"id"` │
│ + Name string `json:"name"` │
│ + PreviewItem *ItemPreviewData `json:"previe │
│ w_item,omitempty"` │
│ +} │
│ + │
│ +// ItemPreviewData contains the preview_ite │
│ m section with spells │
│ +type ItemPreviewData struct { │
│ + Spells []ItemSpellInfo `json:"spells,omite │
│ mpty"` │
│ +} │
│ + │
│ +// ItemSpellInfo contains spell information │
│ including description │
│ +type ItemSpellInfo struct { │
│ + Spell struct { │
│ + ID int `json:"id"` │
│ + Name string `json:"name"` │
│ + } `json:"spell"` │
│ + Description string `json:"description"` │
│ +} │
└──────────────────────────────────────────────┘
┌─ nix/pkgs/ookstats/src/internal/database/migrations.go ────────────────────┐
│ diff --git a/nix/pkgs/ookstats/src/internal/database/migrations.go b/nix/pkgs/ooks │
│ tats/src/internal/database/migrations.go │
│ index 6072212..45d4c54 100644 │
│ --- a/nix/pkgs/ookstats/src/internal/database/migrations.go │
│ +++ b/nix/pkgs/ookstats/src/internal/database/migrations.go │
│ @@ -277,3 +277,156 @@ func migratePlayerRankingsPrimaryKey(db *sql.DB) error { │
│ fmt.Printf("[OK] player_rankings table migrated - added PRIMARY KEY and removed │
│ duplicates\n") │
│ return nil │
│ } │
│ + │
│ +// migrateEquipmentToUpsert adds UNIQUE constraint on (player_id, slot_type) and │
│ removes historical snapshots │
│ +func migrateEquipmentToUpsert(db *sql.DB) error { │
│ + // Check if unique index already exists │
│ + var indexCount int │
│ + err := db.QueryRow(` │
│ + SELECT COUNT(*) FROM sqlite_master │
│ + WHERE type='index' AND name='idx_player_equipment_unique' │
│ + `).Scan(&indexCount) │
│ + if err != nil { │
│ + return fmt.Errorf("check index: %w", err) │
│ + } │
│ + │
│ + if indexCount > 0 { │
│ + // Already migrated │
│ + return nil │
│ + } │
│ + │
│ + fmt.Printf("Migrating player_equipment to single-row-per-slot model...\n") │
│ + │
│ + // Count rows before │
│ + var beforeCount int │
│ + db.QueryRow(`SELECT COUNT(*) FROM player_equipment`).Scan(&beforeCount) │
│ + │
│ + tx, err := db.Begin() │
│ + if err != nil { │
│ + return fmt.Errorf("begin transaction: %w", err) │
│ + } │
│ + defer tx.Rollback() │
│ + │
│ + // Create new table with UNIQUE constraint │
│ + if _, err := tx.Exec(` │
│ + CREATE TABLE player_equipment_new ( │
│ + id INTEGER PRIMARY KEY, │
│ + player_id INTEGER, │
│ + slot_type TEXT, │
│ + item_id INTEGER, │
│ + upgrade_id INTEGER, │
│ + quality TEXT, │
│ + item_name TEXT, │
│ + snapshot_timestamp INTEGER, │
│ + UNIQUE(player_id, slot_type) │
│ + ) │
│ + `); err != nil { │
│ + return fmt.Errorf("create new table: %w", err) │
│ + } │
│ + │
│ + // Copy only one row per (player_id, slot_type) - pick the one with highest ID │
│ + if _, err := tx.Exec(` │
│ + INSERT INTO player_equipment_new (id, player_id, slot_type, item_id, upgrade_id │
│ , quality, item_name, snapshot_timestamp) │
│ + SELECT pe.id, pe.player_id, pe.slot_type, pe.item_id, pe.upgrade_id, pe.quality │
│ , pe.item_name, pe.snapshot_timestamp │
│ + FROM player_equipment pe │
│ + INNER JOIN ( │
│ + SELECT MAX(id) as max_id │
│ + FROM player_equipment │
│ + GROUP BY player_id, slot_type │
│ + ) latest ON pe.id = latest.max_id │
│ + `); err != nil { │
│ + return fmt.Errorf("copy data: %w", err) │
│ + } │
│ + │
│ + // Delete orphaned enchantments (those referencing equipment IDs not in the new │
│ table) │
│ + if _, err := tx.Exec(` │
│ + DELETE FROM player_equipment_enchantments │
│ + WHERE equipment_id NOT IN (SELECT id FROM player_equipment_new) │
│ + `); err != nil { │
│ + return fmt.Errorf("delete orphaned enchantments: %w", err) │
│ + } │
│ + │
│ + // Drop old table and rename │
│ + if _, err := tx.Exec(`DROP TABLE player_equipment`); err != nil { │
│ + return fmt.Errorf("drop old table: %w", err) │
│ + } │
│ + │
│ + if _, err := tx.Exec(`ALTER TABLE player_equipment_new RENAME TO player_equipmen │
│ t`); err != nil { │
│ + return fmt.Errorf("rename table: %w", err) │
│ + } │
│ + │
│ + // Create the unique index for the ON CONFLICT clause │
│ + if _, err := tx.Exec(`CREATE UNIQUE INDEX idx_player_equipment_unique ON player_ │
│ equipment(player_id, slot_type)`); err != nil { │
│ + return fmt.Errorf("create unique index: %w", err) │
│ + } │
│ + │
│ + if err := tx.Commit(); err != nil { │
│ + return fmt.Errorf("commit: %w", err) │
│ + } │
│ + │
│ + // Count rows after │
│ + var afterCount int │
│ + db.QueryRow(`SELECT COUNT(*) FROM player_equipment`).Scan(&afterCount) │
│ + │
│ + fmt.Printf("[OK] player_equipment migrated: %d ? %d rows (removed %d historical │
│ snapshots)\n", │
│ + beforeCount, afterCount, beforeCount-afterCount) │
│ + return nil │
│ +} │
│ + │
│ +// migrateItemsAddItemEffect adds item_effect column to items table if missing │
│ +func migrateItemsAddItemEffect(db *sql.DB) error { │
│ + // Check if column already exists │
│ + rows, err := db.Query("PRAGMA table_info(items)") │
│ + if err != nil { │
│ + return nil // table doesn't exist yet │
│ + } │
│ + defer rows.Close() │
│ + │
│ + hasItemEffect := false │
│ + for rows.Next() { │
│ + var cid int │
│ + var name, ctype string │
│ + var notnull, pk int │
│ + var dflt any │
│ + if err := rows.Scan(&cid, &name, &ctype, ¬null, &dflt, &pk); err != nil { │
│ + continue │
│ + } │
│ + if name == "item_effect" { │
│ + hasItemEffect = true │
│ + break │
│ + } │
│ + } │
│ + │
│ + if hasItemEffect { │
│ + return nil │
│ + } │
│ + │
│ + fmt.Printf("Adding item_effect column to items table...\n") │
│ + _, err = db.Exec(`ALTER TABLE items ADD COLUMN item_effect TEXT`) │
│ + if err != nil { │
│ + return fmt.Errorf("add item_effect column: %w", err) │
│ + } │
│ + │
│ + fmt.Printf("[OK] Added item_effect column to items table\n") │
│ + return nil │
│ +} │
│ + │
│ +// migrateItemsAddSpellDescription adds spell_description column to items table i │
│ f missing │
│ +func migrateItemsAddSpellDescription(db *sql.DB) error { │
│ + has, err := columnExists(db, "items", "spell_description") │
│ + if err != nil { │
│ + return nil // table doesn't exist yet │
│ + } │
│ + if has { │
│ + return nil │
│ + } │
│ + │
│ + fmt.Printf("Adding spell_description column to items table...\n") │
│ + _, err = db.Exec(`ALTER TABLE items ADD COLUMN spell_description TEXT`) │
│ + if err != nil { │
│ + return fmt.Errorf("add spell_description column: %w", err) │
│ + } │
│ + │
│ + fmt.Printf("[OK] Added spell_description column to items table\n") │
│ + return nil │
│ +} │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...ternal/database/migrations.go ───┐
│ diff --git a/nix/pkgs/ookstats/src/internal/ │
│ database/migrations.go b/nix/pkgs/ookstats/s │
│ rc/internal/database/migrations.go │
│ index 6072212..45d4c54 100644 │
│ --- a/nix/pkgs/ookstats/src/internal/databas │
│ e/migrations.go │
│ +++ b/nix/pkgs/ookstats/src/internal/databas │
│ e/migrations.go │
│ @@ -277,3 +277,156 @@ func migratePlayerRank │
│ ingsPrimaryKey(db *sql.DB) error { │
│ fmt.Printf("[OK] player_rankings table mig │
│ rated - added PRIMARY KEY and removed duplic │
│ ates\n") │
│ return nil │
│ } │
│ + │
│ +// migrateEquipmentToUpsert adds UNIQUE con │
│ straint on (player_id, slot_type) and remove │
│ s historical snapshots │
│ +func migrateEquipmentToUpsert(db *sql.DB) e │
│ rror { │
│ + // Check if unique index already exists │
│ + var indexCount int │
│ + err := db.QueryRow(` │
│ + SELECT COUNT(*) FROM sqlite_master │
│ + WHERE type='index' AND name='idx_player_e │
│ quipment_unique' │
│ + `).Scan(&indexCount) │
│ + if err != nil { │
│ + return fmt.Errorf("check index: %w", err) │
│ + } │
│ + │
│ + if indexCount > 0 { │
│ + // Already migrated │
│ + return nil │
│ + } │
│ + │
│ + fmt.Printf("Migrating player_equipment to │
│ single-row-per-slot model...\n") │
│ + │
│ + // Count rows before │
│ + var beforeCount int │
│ + db.QueryRow(`SELECT COUNT(*) FROM player_e │
│ quipment`).Scan(&beforeCount) │
│ + │
│ + tx, err := db.Begin() │
│ + if err != nil { │
│ + return fmt.Errorf("begin transaction: %w" │
│ , err) │
│ + } │
│ + defer tx.Rollback() │
│ + │
│ + // Create new table with UNIQUE constraint │
│ + if _, err := tx.Exec(` │
│ + CREATE TABLE player_equipment_new ( │
│ + id INTEGER PRIMARY KEY, │
│ + player_id INTEGER, │
│ + slot_type TEXT, │
│ + item_id INTEGER, │
│ + upgrade_id INTEGER, │
│ + quality TEXT, │
│ + item_name TEXT, │
│ + snapshot_timestamp INTEGER, │
│ + UNIQUE(player_id, slot_type) │
│ + ) │
│ + `); err != nil { │
│ + return fmt.Errorf("create new table: %w", │
│ err) │
│ + } │
│ + │
│ + // Copy only one row per (player_id, slot_ │
│ type) - pick the one with highest ID │
│ + if _, err := tx.Exec(` │
│ + INSERT INTO player_equipment_new (id, pla │
│ yer_id, slot_type, item_id, upgrade_id, qual │
│ ity, item_name, snapshot_timestamp) │
│ + SELECT pe.id, pe.player_id, pe.slot_type, │
│ pe.item_id, pe.upgrade_id, pe.quality, pe.i │
│ tem_name, pe.snapshot_timestamp │
│ + FROM player_equipment pe │
│ + INNER JOIN ( │
│ + SELECT MAX(id) as max_id │
│ + FROM player_equipment │
│ + GROUP BY player_id, slot_type │
│ + ) latest ON pe.id = latest.max_id │
│ + `); err != nil { │
│ + return fmt.Errorf("copy data: %w", err) │
│ + } │
│ + │
│ + // Delete orphaned enchantments (those ref │
│ erencing equipment IDs not in the new table) │
│ + if _, err := tx.Exec(` │
│ + DELETE FROM player_equipment_enchantments │
│ + WHERE equipment_id NOT IN (SELECT id FROM │
│ player_equipment_new) │
│ + `); err != nil { │
│ + return fmt.Errorf("delete orphaned enchan │
│ tments: %w", err) │
│ + } │
│ + │
│ + // Drop old table and rename │
│ + if _, err := tx.Exec(`DROP TABLE player_eq │
│ uipment`); err != nil { │
│ + return fmt.Errorf("drop old table: %w", e │
│ rr) │
│ + } │
│ + │
│ + if _, err := tx.Exec(`ALTER TABLE player_e │
│ quipment_new RENAME TO player_equipment`); e │
│ rr != nil { │
│ + return fmt.Errorf("rename table: %w", err │
│ ) │
│ + } │
│ + │
│ + // Create the unique index for the ON CONF │
│ LICT clause │
│ + if _, err := tx.Exec(`CREATE UNIQUE INDEX │
│ idx_player_equipment_unique ON player_equipm │
│ ent(player_id, slot_type)`); err != nil { │
│ + return fmt.Errorf("create unique index: % │
│ w", err) │
│ + } │
│ + │
│ + if err := tx.Commit(); err != nil { │
│ + return fmt.Errorf("commit: %w", err) │
│ + } │
│ + │
│ + // Count rows after │
│ + var afterCount int │
│ + db.QueryRow(`SELECT COUNT(*) FROM player_e │
│ quipment`).Scan(&afterCount) │
│ + │
│ + fmt.Printf("[OK] player_equipment migrated │
│ : %d ? %d rows (removed %d historical snapsh │
│ ots)\n", │
│ + beforeCount, afterCount, beforeCount-afte │
│ rCount) │
│ + return nil │
│ +} │
│ + │
│ +// migrateItemsAddItemEffect adds item_effe │
│ ct column to items table if missing │
│ +func migrateItemsAddItemEffect(db *sql.DB) │
│ error { │
│ + // Check if column already exists │
│ + rows, err := db.Query("PRAGMA table_info(i │
│ tems)") │
│ + if err != nil { │
│ + return nil // table doesn't exist yet │
│ + } │
│ + defer rows.Close() │
│ + │
│ + hasItemEffect := false │
│ + for rows.Next() { │
│ + var cid int │
│ + var name, ctype string │
│ + var notnull, pk int │
│ + var dflt any │
│ + if err := rows.Scan(&cid, &name, &ctype, │
│ ¬null, &dflt, &pk); err != nil { │
│ + continue │
│ + } │
│ + if name == "item_effect" { │
│ + hasItemEffect = true │
│ + break │
│ + } │
│ + } │
│ + │
│ + if hasItemEffect { │
│ + return nil │
│ + } │
│ + │
│ + fmt.Printf("Adding item_effect column to i │
│ tems table...\n") │
│ + _, err = db.Exec(`ALTER TABLE items ADD CO │
│ LUMN item_effect TEXT`) │
│ + if err != nil { │
│ + return fmt.Errorf("add item_effect column │
│ : %w", err) │
│ + } │
│ + │
│ + fmt.Printf("[OK] Added item_effect column │
│ to items table\n") │
│ + return nil │
│ +} │
│ + │
│ +// migrateItemsAddSpellDescription adds spe │
│ ll_description column to items table if miss │
│ ing │
│ +func migrateItemsAddSpellDescription(db *sq │
│ l.DB) error { │
│ + has, err := columnExists(db, "items", "spe │
│ ll_description") │
│ + if err != nil { │
│ + return nil // table doesn't exist yet │
│ + } │
│ + if has { │
│ + return nil │
│ + } │
│ + │
│ + fmt.Printf("Adding spell_description colum │
│ n to items table...\n") │
│ + _, err = db.Exec(`ALTER TABLE items ADD CO │
│ LUMN spell_description TEXT`) │
│ + if err != nil { │
│ + return fmt.Errorf("add spell_description │
│ column: %w", err) │
│ + } │
│ + │
│ + fmt.Printf("[OK] Added spell_description c │
│ olumn to items table\n") │
│ + return nil │
│ +} │
└──────────────────────────────────────────────┘
┌─ nix/pkgs/ookstats/src/internal/database/players.go ───────────────────────┐
│ diff --git a/nix/pkgs/ookstats/src/internal/database/players.go b/nix/pkgs/ookstat │
│ s/src/internal/database/players.go │
│ index cac525a..0498fba 100644 │
│ --- a/nix/pkgs/ookstats/src/internal/database/players.go │
│ +++ b/nix/pkgs/ookstats/src/internal/database/players.go │
│ @@ -4,7 +4,6 @@ import ( │
│ "database/sql" │
│ "fmt" │
│ "ookstats/internal/blizzard" │
│ - "sort" │
│ "strings" │
│ ) │
│ │
│ @@ -167,11 +166,20 @@ func (ds *DatabaseService) insertPlayerDetailsTx(tx *sql.Tx, │
│ playerID int, summa │
│ avatarURL, │
│ timestamp, │
│ ) │
│ + if err != nil { │
│ + return err │
│ + } │
│ │
│ + // always refresh last_updated to prevent re-fetching unchanged profiles │
│ + _, err = tx.Exec( │
│ + `UPDATE player_details SET last_updated = ? WHERE player_id = ?`, │
│ + timestamp, playerID, │
│ + ) │
│ return err │
│ } │
│ │
│ -// insertPlayerEquipmentTx inserts player equipment data within a transaction │
│ +// insertPlayerEquipmentTx upserts player equipment data within a transaction │
│ +// Only stores current gear - one row per (player_id, slot_type) │
│ func (ds *DatabaseService) insertPlayerEquipmentTx(tx *sql.Tx, playerID int, equi │
│ pment *blizzard.CharacterEquipmentResponse, timestamp int64) (int, error) { │
│ if equipment == nil || len(equipment.EquippedItems) == 0 { │
│ return 0, nil │
│ @@ -180,102 +188,18 @@ func (ds *DatabaseService) insertPlayerEquipmentTx(tx *sql. │
│ Tx, playerID int, equ │
│ equipmentCount := 0 │
│ │
│ for _, item := range equipment.EquippedItems { │
│ - // check latest snapshot for this slot; skip writing if unchanged │
│ - var prevID sql.NullInt64 │
│ - var prevItemID sql.NullInt64 │
│ - var prevUpgradeID sql.NullInt64 │
│ - var prevQuality, prevName sql.NullString │
│ - if err := tx.QueryRow( │
│ - `SELECT id, item_id, upgrade_id, quality, item_name │
│ - FROM player_equipment │
│ - WHERE player_id = ? AND slot_type = ? │
│ - ORDER BY snapshot_timestamp DESC │
│ - LIMIT 1`, │
│ - playerID, item.Slot.Type, │
│ - ).Scan(&prevID, &prevItemID, &prevUpgradeID, &prevQuality, &prevName); err != n │
│ il && err != sql.ErrNoRows { │
│ - return 0, fmt.Errorf("failed to query latest equipment: %w", err) │
│ - } │
│ - │
│ - unchanged := false │
│ - if prevID.Valid { │
│ - prevUpg := 0 │
│ - if prevUpgradeID.Valid { │
│ - prevUpg = int(prevUpgradeID.Int64) │
│ - } │
│ - curUpg := 0 │
│ - if item.UpgradeID != nil { │
│ - curUpg = *item.UpgradeID │
│ - } │
│ - sameBasics := prevItemID.Valid && int(prevItemID.Int64) == item.Item.ID && pre │
│ vQuality.Valid && prevQuality.String == item.Quality.Type && prevName.Valid && pre │
│ vName.String == item.Name && prevUpg == curUpg │
│ - │
│ - if sameBasics { │
│ - // compare enchantments as a canonical sorted signature │
│ - dbRows, qerr := tx.Query( │
│ - `SELECT │
│ - COALESCE(enchantment_id, -1) as eid, │
│ - COALESCE(source_item_id, -1) as sid, │
│ - COALESCE(slot_id, -1) as slotId, │
│ - COALESCE(slot_type, '') as slotType, │
│ - COALESCE(spell_id, -1) as spellId, │
│ - COALESCE(display_string, '') as disp │
│ - FROM player_equipment_enchantments │
│ - WHERE equipment_id = ?`, prevID.Int64) │
│ - if qerr != nil { │
│ - return 0, fmt.Errorf("failed to load existing enchantments: %w", qerr) │
│ - } │
│ - var dbSigs []string │
│ - for dbRows.Next() { │
│ - var eid, sid, slotId, spellId int │
│ - var slotType, disp string │
│ - if err := dbRows.Scan(&eid, &sid, &slotId, &slotType, &spellId, &disp); err │
│ != nil { │
│ - dbRows.Close() │
│ - return 0, fmt.Errorf("failed to scan enchantment: %w", err) │
│ - } │
│ - dbSigs = append(dbSigs, fmt.Sprintf("%d|%d|%d|%s|%d|%s", eid, sid, slotId, s │
│ lotType, spellId, disp)) │
│ - } │
│ - dbRows.Close() │
│ - sort.Strings(dbSigs) │
│ - │
│ - var curSigs []string │
│ - for _, ench := range item.Enchantments { │
│ - eid := -1 │
│ - if ench.EnchantmentID != nil { │
│ - eid = *ench.EnchantmentID │
│ - } │
│ - sid := -1 │
│ - if ench.SourceItem != nil { │
│ - sid = ench.SourceItem.ID │
│ - } │
│ - slotId := -1 │
│ - var slotType string │
│ - if ench.EnchantmentSlot != nil { │
│ - slotId = ench.EnchantmentSlot.ID │
│ - slotType = ench.EnchantmentSlot.Type │
│ - } │
│ - spellId := -1 │
│ - if ench.Spell != nil { │
│ - spellId = ench.Spell.Spell.ID │
│ - } │
│ - disp := ench.DisplayString │
│ - curSigs = append(curSigs, fmt.Sprintf("%d|%d|%d|%s|%d|%s", eid, sid, slotId, │
│ slotType, spellId, disp)) │
│ - } │
│ - sort.Strings(curSigs) │
│ - │
│ - if strings.Join(dbSigs, ";") == strings.Join(curSigs, ";") { │
│ - unchanged = true │
│ - } │
│ - } │
│ - } │
│ - │
│ - if unchanged { │
│ - continue │
│ - } │
│ - │
│ + // upsert equipment - replace if exists │
│ result, err := tx.Exec(` │
│ - INSERT INTO player_equipment ( │
│ - player_id, slot_type, item_id, upgrade_id, quality, item_name, sn │
│ apshot_timestamp │
│ - ) VALUES (?, ?, ?, ?, ?, ?, ?) │
│ - `, │
│ + INSERT INTO player_equipment ( │
│ + player_id, slot_type, item_id, upgrade_id, quality, item_name, snapshot_times │
│ tamp │
│ + ) VALUES (?, ?, ?, ?, ?, ?, ?) │
│ + ON CONFLICT(player_id, slot_type) DO UPDATE SET │
│ + item_id = excluded.item_id, │
│ + upgrade_id = excluded.upgrade_id, │
│ + quality = excluded.quality, │
│ + item_name = excluded.item_name, │
│ + snapshot_timestamp = excluded.snapshot_timestamp │
│ + `, │
│ playerID, │
│ item.Slot.Type, │
│ item.Item.ID, │
│ @@ -284,18 +208,32 @@ func (ds *DatabaseService) insertPlayerEquipmentTx(tx *sql.T │
│ x, playerID int, equ │
│ item.Name, │
│ timestamp, │
│ ) │
│ - │
│ if err != nil { │
│ - return 0, fmt.Errorf("failed to insert equipment item: %w", err) │
│ + return 0, fmt.Errorf("failed to upsert equipment item: %w", err) │
│ } │
│ │
│ - equipmentID, err := result.LastInsertId() │
│ + // get the equipment ID (works for both insert and update) │
│ + var equipmentID int64 │
│ + err = tx.QueryRow( │
│ + `SELECT id FROM player_equipment WHERE player_id = ? AND slot_type = ?`, │
│ + playerID, item.Slot.Type, │
│ + ).Scan(&equipmentID) │
│ if err != nil { │
│ return 0, fmt.Errorf("failed to get equipment ID: %w", err) │
│ } │
│ │
│ - equipmentCount++ │
│ + // delete old enchantments for this equipment slot │
│ + _, err = tx.Exec(`DELETE FROM player_equipment_enchantments WHERE equipment_id │
│ = ?`, equipmentID) │
│ + if err != nil { │
│ + return 0, fmt.Errorf("failed to delete old enchantments: %w", err) │
│ + } │
│ │
│ + rowsAffected, _ := result.RowsAffected() │
│ + if rowsAffected > 0 { │
│ + equipmentCount++ │
│ + } │
│ + │
│ + // insert new enchantments │
│ for _, enchant := range item.Enchantments { │
│ var sourceItemID *int │
│ var sourceItemName *string │
│ @@ -331,7 +269,6 @@ func (ds *DatabaseService) insertPlayerEquipmentTx(tx *sql.Tx, │
│ playerID int, equ │
│ sourceItemName, │
│ spellID, │
│ ) │
│ - │
│ if err != nil { │
│ return 0, fmt.Errorf("failed to insert enchantment: %w", err) │
│ } │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ .../internal/database/players.go ───┐
│ diff --git a/nix/pkgs/ookstats/src/internal/ │
│ database/players.go b/nix/pkgs/ookstats/src/ │
│ internal/database/players.go │
│ index cac525a..0498fba 100644 │
│ --- a/nix/pkgs/ookstats/src/internal/databas │
│ e/players.go │
│ +++ b/nix/pkgs/ookstats/src/internal/databas │
│ e/players.go │
│ @@ -4,7 +4,6 @@ import ( │
│ "database/sql" │
│ "fmt" │
│ "ookstats/internal/blizzard" │
│ - "sort" │
│ "strings" │
│ ) │
│ │
│ @@ -167,11 +166,20 @@ func (ds *DatabaseServ │
│ ice) insertPlayerDetailsTx(tx *sql.Tx, playe │
│ rID int, summa │
│ avatarURL, │
│ timestamp, │
│ ) │
│ + if err != nil { │
│ + return err │
│ + } │
│ │
│ + // always refresh last_updated to prevent │
│ re-fetching unchanged profiles │
│ + _, err = tx.Exec( │
│ + `UPDATE player_details SET last_updated = │
│ ? WHERE player_id = ?`, │
│ + timestamp, playerID, │
│ + ) │
│ return err │
│ } │
│ │
│ -// insertPlayerEquipmentTx inserts player e │
│ quipment data within a transaction │
│ +// insertPlayerEquipmentTx upserts player e │
│ quipment data within a transaction │
│ +// Only stores current gear - one row per ( │
│ player_id, slot_type) │
│ func (ds *DatabaseService) insertPlayerEqui │
│ pmentTx(tx *sql.Tx, playerID int, equipment │
│ *blizzard.CharacterEquipmentResponse, timest │
│ amp int64) (int, error) { │
│ if equipment == nil || len(equipment.Equip │
│ pedItems) == 0 { │
│ return 0, nil │
│ @@ -180,102 +188,18 @@ func (ds *DatabaseSer │
│ vice) insertPlayerEquipmentTx(tx *sql.Tx, pl │
│ ayerID int, equ │
│ equipmentCount := 0 │
│ │
│ for _, item := range equipment.EquippedIte │
│ ms { │
│ - // check latest snapshot for this slot; s │
│ kip writing if unchanged │
│ - var prevID sql.NullInt64 │
│ - var prevItemID sql.NullInt64 │
│ - var prevUpgradeID sql.NullInt64 │
│ - var prevQuality, prevName sql.NullString │
│ - if err := tx.QueryRow( │
│ - `SELECT id, item_id, upgrade_id, quality │
│ , item_name │
│ - FROM player_equipment │
│ - WHERE player_id = ? AND slot_t │
│ ype = ? │
│ - ORDER BY snapshot_timestamp DE │
│ SC │
│ - LIMIT 1`, │
│ - playerID, item.Slot.Type, │
│ - ).Scan(&prevID, &prevItemID, &prevUpgrade │
│ ID, &prevQuality, &prevName); err != nil && │
│ err != sql.ErrNoRows { │
│ - return 0, fmt.Errorf("failed to query la │
│ test equipment: %w", err) │
│ - } │
│ - │
│ - unchanged := false │
│ - if prevID.Valid { │
│ - prevUpg := 0 │
│ - if prevUpgradeID.Valid { │
│ - prevUpg = int(prevUpgradeID.Int64) │
│ - } │
│ - curUpg := 0 │
│ - if item.UpgradeID != nil { │
│ - curUpg = *item.UpgradeID │
│ - } │
│ - sameBasics := prevItemID.Valid && int(pr │
│ evItemID.Int64) == item.Item.ID && prevQuali │
│ ty.Valid && prevQuality.String == item.Quali │
│ ty.Type && prevName.Valid && prevName.String │
│ == item.Name && prevUpg == curUpg │
│ - │
│ - if sameBasics { │
│ - // compare enchantments as a canonical │
│ sorted signature │
│ - dbRows, qerr := tx.Query( │
│ - `SELECT │
│ - COALESCE(enchantmen │
│ t_id, -1) as eid, │
│ - COALESCE(source_ite │
│ m_id, -1) as sid, │
│ - COALESCE(slot_id, - │
│ 1) as slotId, │
│ - COALESCE(slot_type, │
│ '') as slotType, │
│ - COALESCE(spell_id, │
│ -1) as spellId, │
│ - COALESCE(display_st │
│ ring, '') as disp │
│ - FROM player_equipment_ │
│ enchantments │
│ - WHERE equipment_id = ? │
│ `, prevID.Int64) │
│ - if qerr != nil { │
│ - return 0, fmt.Errorf("failed to load e │
│ xisting enchantments: %w", qerr) │
│ - } │
│ - var dbSigs []string │
│ - for dbRows.Next() { │
│ - var eid, sid, slotId, spellId int │
│ - var slotType, disp string │
│ - if err := dbRows.Scan(&eid, &sid, &slo │
│ tId, &slotType, &spellId, &disp); err != nil │
│ { │
│ - dbRows.Close() │
│ - return 0, fmt.Errorf("failed to scan │
│ enchantment: %w", err) │
│ - } │
│ - dbSigs = append(dbSigs, fmt.Sprintf("% │
│ d|%d|%d|%s|%d|%s", eid, sid, slotId, slotTyp │
│ e, spellId, disp)) │
│ - } │
│ - dbRows.Close() │
│ - sort.Strings(dbSigs) │
│ - │
│ - var curSigs []string │
│ - for _, ench := range item.Enchantments │
│ { │
│ - eid := -1 │
│ - if ench.EnchantmentID != nil { │
│ - eid = *ench.EnchantmentID │
│ - } │
│ - sid := -1 │
│ - if ench.SourceItem != nil { │
│ - sid = ench.SourceItem.ID │
│ - } │
│ - slotId := -1 │
│ - var slotType string │
│ - if ench.EnchantmentSlot != nil { │
│ - slotId = ench.EnchantmentSlot.ID │
│ - slotType = ench.EnchantmentSlot.Type │
│ - } │
│ - spellId := -1 │
│ - if ench.Spell != nil { │
│ - spellId = ench.Spell.Spell.ID │
│ - } │
│ - disp := ench.DisplayString │
│ - curSigs = append(curSigs, fmt.Sprintf( │
│ "%d|%d|%d|%s|%d|%s", eid, sid, slotId, slotT │
│ ype, spellId, disp)) │
│ - } │
│ - sort.Strings(curSigs) │
│ - │
│ - if strings.Join(dbSigs, ";") == strings │
│ .Join(curSigs, ";") { │
│ - unchanged = true │
│ - } │
│ - } │
│ - } │
│ - │
│ - if unchanged { │
│ - continue │
│ - } │
│ - │
│ + // upsert equipment - replace if exists │
│ result, err := tx.Exec(` │
│ - INSERT INTO player_equipment ( │
│ - player_id, slot_type, item_ │
│ id, upgrade_id, quality, item_name, snapshot │
│ _timestamp │
│ - ) VALUES (?, ?, ?, ?, ?, ?, ?) │
│ - `, │
│ + INSERT INTO player_equipment ( │
│ + player_id, slot_type, item_id, upgrade_ │
│ id, quality, item_name, snapshot_timestamp │
│ + ) VALUES (?, ?, ?, ?, ?, ?, ?) │
│ + ON CONFLICT(player_id, slot_type) DO UPD │
│ ATE SET │
│ + item_id = excluded.item_id, │
│ + upgrade_id = excluded.upgrade_id, │
│ + quality = excluded.quality, │
│ + item_name = excluded.item_name, │
│ + snapshot_timestamp = excluded.snapshot_ │
│ timestamp │
│ + `, │
│ playerID, │
│ item.Slot.Type, │
│ item.Item.ID, │
│ @@ -284,18 +208,32 @@ func (ds *DatabaseServ │
│ ice) insertPlayerEquipmentTx(tx *sql.Tx, pla │
│ yerID int, equ │
│ item.Name, │
│ timestamp, │
│ ) │
│ - │
│ if err != nil { │
│ - return 0, fmt.Errorf("failed to insert e │
│ quipment item: %w", err) │
│ + return 0, fmt.Errorf("failed to upsert e │
│ quipment item: %w", err) │
│ } │
│ │
│ - equipmentID, err := result.LastInsertId() │
│ + // get the equipment ID (works for both i │
│ nsert and update) │
│ + var equipmentID int64 │
│ + err = tx.QueryRow( │
│ + `SELECT id FROM player_equipment WHERE p │
│ layer_id = ? AND slot_type = ?`, │
│ + playerID, item.Slot.Type, │
│ + ).Scan(&equipmentID) │
│ if err != nil { │
│ return 0, fmt.Errorf("failed to get equi │
│ pment ID: %w", err) │
│ } │
│ │
│ - equipmentCount++ │
│ + // delete old enchantments for this equip │
│ ment slot │
│ + _, err = tx.Exec(`DELETE FROM player_equi │
│ pment_enchantments WHERE equipment_id = ?`, │
│ equipmentID) │
│ + if err != nil { │
│ + return 0, fmt.Errorf("failed to delete o │
│ ld enchantments: %w", err) │
│ + } │
│ │
│ + rowsAffected, _ := result.RowsAffected() │
│ + if rowsAffected > 0 { │
│ + equipmentCount++ │
│ + } │
│ + │
│ + // insert new enchantments │
│ for _, enchant := range item.Enchantments │
│ { │
│ var sourceItemID *int │
│ var sourceItemName *string │
│ @@ -331,7 +269,6 @@ func (ds *DatabaseServic │
│ e) insertPlayerEquipmentTx(tx *sql.Tx, playe │
│ rID int, equ │
│ sourceItemName, │
│ spellID, │
│ ) │
│ - │
│ if err != nil { │
│ return 0, fmt.Errorf("failed to insert │
│ enchantment: %w", err) │
│ } │
└──────────────────────────────────────────────┘
┌─ nix/pkgs/ookstats/src/internal/database/schema.go ────────────────────────┐
│ diff --git a/nix/pkgs/ookstats/src/internal/database/schema.go b/nix/pkgs/ookstats │
│ /src/internal/database/schema.go │
│ index 0c809b7..ff93cb8 100644 │
│ --- a/nix/pkgs/ookstats/src/internal/database/schema.go │
│ +++ b/nix/pkgs/ookstats/src/internal/database/schema.go │
│ @@ -241,7 +241,9 @@ func EnsureCompleteSchema(db *sql.DB) error { │
│ icon TEXT, │
│ quality INTEGER, │
│ type INTEGER, │
│ - stats TEXT │
│ + stats TEXT, │
│ + item_effect TEXT, │
│ + spell_description TEXT │
│ )`, │
│ │
│ // Season tables │
│ @@ -292,6 +294,21 @@ func EnsureCompleteSchema(db *sql.DB) error { │
│ return err │
│ } │
│ │
│ + // Migrate equipment to single-row-per-slot model │
│ + if err := migrateEquipmentToUpsert(db); err != nil { │
│ + return err │
│ + } │
│ + │
│ + // Add item_effect column to items table │
│ + if err := migrateItemsAddItemEffect(db); err != nil { │
│ + return err │
│ + } │
│ + │
│ + // Add spell_description column to items table │
│ + if err := migrateItemsAddSpellDescription(db); err != nil { │
│ + return err │
│ + } │
│ + │
│ // Create indexes │
│ return ensureRecommendedIndexes(db) │
│ } │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...c/internal/database/schema.go ───┐
│ diff --git a/nix/pkgs/ookstats/src/internal/ │
│ database/schema.go b/nix/pkgs/ookstats/src/i │
│ nternal/database/schema.go │
│ index 0c809b7..ff93cb8 100644 │
│ --- a/nix/pkgs/ookstats/src/internal/databas │
│ e/schema.go │
│ +++ b/nix/pkgs/ookstats/src/internal/databas │
│ e/schema.go │
│ @@ -241,7 +241,9 @@ func EnsureCompleteSchem │
│ a(db *sql.DB) error { │
│ icon TEXT, │
│ quality INTEGER, │
│ type INTEGER, │
│ - stats TEXT │
│ + stats TEXT, │
│ + item_effect TEXT, │
│ + spell_description TEXT │
│ )`, │
│ │
│ // Season tables │
│ @@ -292,6 +294,21 @@ func EnsureCompleteSche │
│ ma(db *sql.DB) error { │
│ return err │
│ } │
│ │
│ + // Migrate equipment to single-row-per-slo │
│ t model │
│ + if err := migrateEquipmentToUpsert(db); er │
│ r != nil { │
│ + return err │
│ + } │
│ + │
│ + // Add item_effect column to items table │
│ + if err := migrateItemsAddItemEffect(db); e │
│ rr != nil { │
│ + return err │
│ + } │
│ + │
│ + // Add spell_description column to items t │
│ able │
│ + if err := migrateItemsAddSpellDescription( │
│ db); err != nil { │
│ + return err │
│ + } │
│ + │
│ // Create indexes │
│ return ensureRecommendedIndexes(db) │
│ } │
└──────────────────────────────────────────────┘
┌─ nix/pkgs/ookstats/src/internal/generator/players.go ──────────────────────┐
│ diff --git a/nix/pkgs/ookstats/src/internal/generator/players.go b/nix/pkgs/ooksta │
│ ts/src/internal/generator/players.go │
│ index e8ecc2d..95620a2 100644 │
│ --- a/nix/pkgs/ookstats/src/internal/generator/players.go │
│ +++ b/nix/pkgs/ookstats/src/internal/generator/players.go │
│ @@ -2,6 +2,7 @@ package generator │
│ │
│ import ( │
│ "database/sql" │
│ + "encoding/json" │
│ "fmt" │
│ "ookstats/internal/loader" │
│ "ookstats/internal/utils" │
│ @@ -135,7 +136,7 @@ func GeneratePlayers(db *sql.DB, out string, version string) e │
│ rror { │
│ } │
│ │
│ // GeneratePlayerJSONs generates JSON files for all players concurrently │
│ -func GeneratePlayerJSONs(players []loader.PlayerData, playerSeasonsMap map[int64] │
│ []loader.PlayerSeasonData, bestRunsMap map[int64][]loader.BestRunData, teamMembers │
│ Map map[int64][]loader.TeamMemberData, equipmentMap map[int64]map[int64][]loader.E │
│ quipmentData, enchantmentsMap map[int64][]loader.EnchantmentData, out, version str │
│ ing) error { │
│ +func GeneratePlayerJSONs(players []loader.PlayerData, playerSeasonsMap map[int64] │
│ []loader.PlayerSeasonData, bestRunsMap map[int64][]loader.BestRunData, teamMembers │
│ Map map[int64][]loader.TeamMemberData, equipmentMap map[int64][]loader.EquipmentDa │
│ ta, enchantmentsMap map[int64][]loader.EnchantmentData, out, version string) error │
│ { │
│ startTime := time.Now() │
│ const batchSize = 100 │
│ const numWorkers = 10 │
│ @@ -192,7 +193,7 @@ func GeneratePlayerJSONs(players []loader.PlayerData, playerSe │
│ asonsMap map[int64 │
│ } │
│ │
│ // generateSinglePlayerJSON generates a JSON file for a single player │
│ -func generateSinglePlayerJSON(player loader.PlayerData, playerSeasonsMap map[int6 │
│ 4][]loader.PlayerSeasonData, bestRunsMap map[int64][]loader.BestRunData, teamMembe │
│ rsMap map[int64][]loader.TeamMemberData, equipmentMap map[int64]map[int64][]loader │
│ .EquipmentData, enchantmentsMap map[int64][]loader.EnchantmentData, out, version s │
│ tring) error { │
│ +func generateSinglePlayerJSON(player loader.PlayerData, playerSeasonsMap map[int6 │
│ 4][]loader.PlayerSeasonData, bestRunsMap map[int64][]loader.BestRunData, teamMembe │
│ rsMap map[int64][]loader.TeamMemberData, equipmentMap map[int64][]loader.Equipment │
│ Data, enchantmentsMap map[int64][]loader.EnchantmentData, out, version string) err │
│ or { │
│ // Build PlayerJSON with base info │
│ pj := PlayerJSON{ │
│ ID: player.ID, │
│ @@ -319,66 +320,78 @@ func generateSinglePlayerJSON(player loader.PlayerData, play │
│ erSeasonsMap map[int │
│ │
│ // Build equipment │
│ equipment := make(map[string]any) │
│ - for _, eqList := range equipmentMap[player.ID] { │
│ - for _, eq := range eqList { │
│ - eqData := map[string]any{ │
│ - "id": eq.ID, │
│ - "slot_type": eq.SlotType, │
│ - "item_id": nil, │
│ - "upgrade_id": nil, │
│ - "quality": eq.Quality, │
│ - "item_name": eq.ItemName, │
│ - "snapshot_timestamp": eq.SnapshotTs, │
│ - "item_icon_slug": eq.ItemIcon.String, │
│ - "item_type": eq.ItemType.String, │
│ - "enchantments": []map[string]any{}, │
│ - } │
│ + for _, eq := range equipmentMap[player.ID] { │
│ + eqData := map[string]any{ │
│ + "id": eq.ID, │
│ + "slot_type": eq.SlotType, │
│ + "item_id": nil, │
│ + "upgrade_id": nil, │
│ + "quality": eq.Quality, │
│ + "item_name": eq.ItemName, │
│ + "snapshot_timestamp": eq.SnapshotTs, │
│ + "item_icon_slug": eq.ItemIcon.String, │
│ + "item_type": eq.ItemType.String, │
│ + "enchantments": []map[string]any{}, │
│ + } │
│ │
│ - if eq.ItemID.Valid { │
│ - eqData["item_id"] = int(eq.ItemID.Int64) │
│ + if eq.ItemID.Valid { │
│ + eqData["item_id"] = int(eq.ItemID.Int64) │
│ + } │
│ + if eq.UpgradeID.Valid { │
│ + eqData["upgrade_id"] = int(eq.UpgradeID.Int64) │
│ + } │
│ + if eq.ItemStats.Valid && eq.ItemStats.String != "" && eq.ItemStats.String != "{ │
│ }" { │
│ + var scalingOpts any │
│ + if err := json.Unmarshal([]byte(eq.ItemStats.String), &scalingOpts); err == ni │
│ l { │
│ + eqData["scaling_options"] = scalingOpts │
│ } │
│ - if eq.UpgradeID.Valid { │
│ - eqData["upgrade_id"] = int(eq.UpgradeID.Int64) │
│ + } │
│ + if eq.ItemEffect.Valid && eq.ItemEffect.String != "" { │
│ + var itemEffect any │
│ + if err := json.Unmarshal([]byte(eq.ItemEffect.String), &itemEffect); err == ni │
│ l { │
│ + eqData["item_effect"] = itemEffect │
│ } │
│ + } │
│ + if eq.SpellDescription.Valid && eq.SpellDescription.String != "" { │
│ + eqData["spell_description"] = eq.SpellDescription.String │
│ + } │
│ │
│ - // Add enchantments │
│ - for _, ench := range enchantmentsMap[eq.ID] { │
│ - enchData := map[string]any{ │
│ - "enchantment_id": nil, │
│ - "slot_id": nil, │
│ - "slot_type": ench.SlotType.String, │
│ - "display_string": ench.DisplayString.String, │
│ - "source_item_id": nil, │
│ - "source_item_name": ench.SourceItemName.String, │
│ - "spell_id": nil, │
│ - } │
│ - │
│ - if ench.EnchantmentID.Valid { │
│ - enchData["enchantment_id"] = int(ench.EnchantmentID.Int64) │
│ - } │
│ - if ench.SlotID.Valid { │
│ - enchData["slot_id"] = int(ench.SlotID.Int64) │
│ - } │
│ - if ench.SourceItemID.Valid { │
│ - enchData["source_item_id"] = int(ench.SourceItemID.Int64) │
│ - } │
│ - if ench.SpellID.Valid { │
│ - enchData["spell_id"] = int(ench.SpellID.Int64) │
│ - } │
│ - if ench.GemIconSlug.Valid { │
│ - enchData["gem_icon_slug"] = ench.GemIconSlug.String │
│ - } │
│ + // Add enchantments │
│ + for _, ench := range enchantmentsMap[eq.ID] { │
│ + enchData := map[string]any{ │
│ + "enchantment_id": nil, │
│ + "slot_id": nil, │
│ + "slot_type": ench.SlotType.String, │
│ + "display_string": ench.DisplayString.String, │
│ + "source_item_id": nil, │
│ + "source_item_name": ench.SourceItemName.String, │
│ + "spell_id": nil, │
│ + } │
│ │
│ - if arr, ok := eqData["enchantments"].([]map[string]any); ok { │
│ - eqData["enchantments"] = append(arr, enchData) │
│ - } else { │
│ - eqData["enchantments"] = []map[string]any{enchData} │
│ - } │
│ + if ench.EnchantmentID.Valid { │
│ + enchData["enchantment_id"] = int(ench.EnchantmentID.Int64) │
│ + } │
│ + if ench.SlotID.Valid { │
│ + enchData["slot_id"] = int(ench.SlotID.Int64) │
│ + } │
│ + if ench.SourceItemID.Valid { │
│ + enchData["source_item_id"] = int(ench.SourceItemID.Int64) │
│ + } │
│ + if ench.SpellID.Valid { │
│ + enchData["spell_id"] = int(ench.SpellID.Int64) │
│ + } │
│ + if ench.GemIconSlug.Valid { │
│ + enchData["gem_icon_slug"] = ench.GemIconSlug.String │
│ } │
│ │
│ - equipment[eq.SlotType] = eqData │
│ + if arr, ok := eqData["enchantments"].([]map[string]any); ok { │
│ + eqData["enchantments"] = append(arr, enchData) │
│ + } else { │
│ + eqData["enchantments"] = []map[string]any{enchData} │
│ + } │
│ } │
│ - break // Only process the latest timestamp │
│ + │
│ + equipment[eq.SlotType] = eqData │
│ } │
│ │
│ // Create final JSON │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...internal/generator/players.go ───┐
│ diff --git a/nix/pkgs/ookstats/src/internal/ │
│ generator/players.go b/nix/pkgs/ookstats/src │
│ /internal/generator/players.go │
│ index e8ecc2d..95620a2 100644 │
│ --- a/nix/pkgs/ookstats/src/internal/generat │
│ or/players.go │
│ +++ b/nix/pkgs/ookstats/src/internal/generat │
│ or/players.go │
│ @@ -2,6 +2,7 @@ package generator │
│ │
│ import ( │
│ "database/sql" │
│ + "encoding/json" │
│ "fmt" │
│ "ookstats/internal/loader" │
│ "ookstats/internal/utils" │
│ @@ -135,7 +136,7 @@ func GeneratePlayers(db │
│ *sql.DB, out string, version string) error { │
│ } │
│ │
│ // GeneratePlayerJSONs generates JSON files │
│ for all players concurrently │
│ -func GeneratePlayerJSONs(players []loader.P │
│ layerData, playerSeasonsMap map[int64][]load │
│ er.PlayerSeasonData, bestRunsMap map[int64][ │
│ ]loader.BestRunData, teamMembersMap map[int6 │
│ 4][]loader.TeamMemberData, equipmentMap map[ │
│ int64]map[int64][]loader.EquipmentData, ench │
│ antmentsMap map[int64][]loader.EnchantmentDa │
│ ta, out, version string) error { │
│ +func GeneratePlayerJSONs(players []loader.P │
│ layerData, playerSeasonsMap map[int64][]load │
│ er.PlayerSeasonData, bestRunsMap map[int64][ │
│ ]loader.BestRunData, teamMembersMap map[int6 │
│ 4][]loader.TeamMemberData, equipmentMap map[ │
│ int64][]loader.EquipmentData, enchantmentsMa │
│ p map[int64][]loader.EnchantmentData, out, v │
│ ersion string) error { │
│ startTime := time.Now() │
│ const batchSize = 100 │
│ const numWorkers = 10 │
│ @@ -192,7 +193,7 @@ func GeneratePlayerJSONs │
│ (players []loader.PlayerData, playerSeasonsM │
│ ap map[int64 │
│ } │
│ │
│ // generateSinglePlayerJSON generates a JSO │
│ N file for a single player │
│ -func generateSinglePlayerJSON(player loader │
│ .PlayerData, playerSeasonsMap map[int64][]lo │
│ ader.PlayerSeasonData, bestRunsMap map[int64 │
│ ][]loader.BestRunData, teamMembersMap map[in │
│ t64][]loader.TeamMemberData, equipmentMap ma │
│ p[int64]map[int64][]loader.EquipmentData, en │
│ chantmentsMap map[int64][]loader.Enchantment │
│ Data, out, version string) error { │
│ +func generateSinglePlayerJSON(player loader │
│ .PlayerData, playerSeasonsMap map[int64][]lo │
│ ader.PlayerSeasonData, bestRunsMap map[int64 │
│ ][]loader.BestRunData, teamMembersMap map[in │
│ t64][]loader.TeamMemberData, equipmentMap ma │
│ p[int64][]loader.EquipmentData, enchantments │
│ Map map[int64][]loader.EnchantmentData, out, │
│ version string) error { │
│ // Build PlayerJSON with base info │
│ pj := PlayerJSON{ │
│ ID: player.ID, │
│ @@ -319,66 +320,78 @@ func generateSinglePla │
│ yerJSON(player loader.PlayerData, playerSeas │
│ onsMap map[int │
│ │
│ // Build equipment │
│ equipment := make(map[string]any) │
│ - for _, eqList := range equipmentMap[player │
│ .ID] { │
│ - for _, eq := range eqList { │
│ - eqData := map[string]any{ │
│ - "id": eq.ID, │
│ - "slot_type": eq.SlotType, │
│ - "item_id": nil, │
│ - "upgrade_id": nil, │
│ - "quality": eq.Quality, │
│ - "item_name": eq.ItemName, │
│ - "snapshot_timestamp": eq.SnapshotTs, │
│ - "item_icon_slug": eq.ItemIcon.Strin │
│ g, │
│ - "item_type": eq.ItemType.Strin │
│ g, │
│ - "enchantments": []map[string]any{ │
│ }, │
│ - } │
│ + for _, eq := range equipmentMap[player.ID] │
│ { │
│ + eqData := map[string]any{ │
│ + "id": eq.ID, │
│ + "slot_type": eq.SlotType, │
│ + "item_id": nil, │
│ + "upgrade_id": nil, │
│ + "quality": eq.Quality, │
│ + "item_name": eq.ItemName, │
│ + "snapshot_timestamp": eq.SnapshotTs, │
│ + "item_icon_slug": eq.ItemIcon.String │
│ , │
│ + "item_type": eq.ItemType.String │
│ , │
│ + "enchantments": []map[string]any{} │
│ , │
│ + } │
│ │
│ - if eq.ItemID.Valid { │
│ - eqData["item_id"] = int(eq.ItemID.Int64 │
│ ) │
│ + if eq.ItemID.Valid { │
│ + eqData["item_id"] = int(eq.ItemID.Int64) │
│ + } │
│ + if eq.UpgradeID.Valid { │
│ + eqData["upgrade_id"] = int(eq.UpgradeID. │
│ Int64) │
│ + } │
│ + if eq.ItemStats.Valid && eq.ItemStats.Str │
│ ing != "" && eq.ItemStats.String != "{}" { │
│ + var scalingOpts any │
│ + if err := json.Unmarshal([]byte(eq.ItemS │
│ tats.String), &scalingOpts); err == nil { │
│ + eqData["scaling_options"] = scalingOpts │
│ } │
│ - if eq.UpgradeID.Valid { │
│ - eqData["upgrade_id"] = int(eq.UpgradeID │
│ .Int64) │
│ + } │
│ + if eq.ItemEffect.Valid && eq.ItemEffect.S │
│ tring != "" { │
│ + var itemEffect any │
│ + if err := json.Unmarshal([]byte(eq.ItemE │
│ ffect.String), &itemEffect); err == nil { │
│ + eqData["item_effect"] = itemEffect │
│ } │
│ + } │
│ + if eq.SpellDescription.Valid && eq.SpellD │
│ escription.String != "" { │
│ + eqData["spell_description"] = eq.SpellDe │
│ scription.String │
│ + } │
│ │
│ - // Add enchantments │
│ - for _, ench := range enchantmentsMap[eq. │
│ ID] { │
│ - enchData := map[string]any{ │
│ - "enchantment_id": nil, │
│ - "slot_id": nil, │
│ - "slot_type": ench.SlotType.Stri │
│ ng, │
│ - "display_string": ench.DisplayString │
│ .String, │
│ - "source_item_id": nil, │
│ - "source_item_name": ench.SourceItemNam │
│ e.String, │
│ - "spell_id": nil, │
│ - } │
│ - │
│ - if ench.EnchantmentID.Valid { │
│ - enchData["enchantment_id"] = int(ench. │
│ EnchantmentID.Int64) │
│ - } │
│ - if ench.SlotID.Valid { │
│ - enchData["slot_id"] = int(ench.SlotID. │
│ Int64) │
│ - } │
│ - if ench.SourceItemID.Valid { │
│ - enchData["source_item_id"] = int(ench. │
│ SourceItemID.Int64) │
│ - } │
│ - if ench.SpellID.Valid { │
│ - enchData["spell_id"] = int(ench.SpellI │
│ D.Int64) │
│ - } │
│ - if ench.GemIconSlug.Valid { │
│ - enchData["gem_icon_slug"] = ench.GemIc │
│ onSlug.String │
│ - } │
│ + // Add enchantments │
│ + for _, ench := range enchantmentsMap[eq.I │
│ D] { │
│ + enchData := map[string]any{ │
│ + "enchantment_id": nil, │
│ + "slot_id": nil, │
│ + "slot_type": ench.SlotType.Strin │
│ g, │
│ + "display_string": ench.DisplayString. │
│ String, │
│ + "source_item_id": nil, │
│ + "source_item_name": ench.SourceItemName │
│ .String, │
│ + "spell_id": nil, │
│ + } │
│ │
│ - if arr, ok := eqData["enchantments"].([ │
│ ]map[string]any); ok { │
│ - eqData["enchantments"] = append(arr, e │
│ nchData) │
│ - } else { │
│ - eqData["enchantments"] = []map[string] │
│ any{enchData} │
│ - } │
│ + if ench.EnchantmentID.Valid { │
│ + enchData["enchantment_id"] = int(ench.E │
│ nchantmentID.Int64) │
│ + } │
│ + if ench.SlotID.Valid { │
│ + enchData["slot_id"] = int(ench.SlotID.I │
│ nt64) │
│ + } │
│ + if ench.SourceItemID.Valid { │
│ + enchData["source_item_id"] = int(ench.S │
│ ourceItemID.Int64) │
│ + } │
│ + if ench.SpellID.Valid { │
│ + enchData["spell_id"] = int(ench.SpellID │
│ .Int64) │
│ + } │
│ + if ench.GemIconSlug.Valid { │
│ + enchData["gem_icon_slug"] = ench.GemIco │
│ nSlug.String │
│ } │
│ │
│ - equipment[eq.SlotType] = eqData │
│ + if arr, ok := eqData["enchantments"].([] │
│ map[string]any); ok { │
│ + eqData["enchantments"] = append(arr, en │
│ chData) │
│ + } else { │
│ + eqData["enchantments"] = []map[string]a │
│ ny{enchData} │
│ + } │
│ } │
│ - break // Only process the latest timestam │
│ p │
│ + │
│ + equipment[eq.SlotType] = eqData │
│ } │
│ │
│ // Create final JSON │
└──────────────────────────────────────────────┘
┌─ nix/pkgs/ookstats/src/internal/loader/equipment.go ───────────────────────┐
│ diff --git a/nix/pkgs/ookstats/src/internal/loader/equipment.go b/nix/pkgs/ookstat │
│ s/src/internal/loader/equipment.go │
│ index cecf405..042d51b 100644 │
│ --- a/nix/pkgs/ookstats/src/internal/loader/equipment.go │
│ +++ b/nix/pkgs/ookstats/src/internal/loader/equipment.go │
│ @@ -8,15 +8,18 @@ import ( │
│ │
│ // EquipmentData represents a piece of equipment │
│ type EquipmentData struct { │
│ - ID int64 │
│ - SlotType string │
│ - ItemID sql.NullInt64 │
│ - UpgradeID sql.NullInt64 │
│ - Quality string │
│ - ItemName string │
│ - SnapshotTs int64 │
│ - ItemIcon sql.NullString │
│ - ItemType sql.NullString │
│ + ID int64 │
│ + SlotType string │
│ + ItemID sql.NullInt64 │
│ + UpgradeID sql.NullInt64 │
│ + Quality string │
│ + ItemName string │
│ + SnapshotTs int64 │
│ + ItemIcon sql.NullString │
│ + ItemType sql.NullString │
│ + ItemStats sql.NullString │
│ + ItemEffect sql.NullString │
│ + SpellDescription sql.NullString │
│ } │
│ │
│ // EnchantmentData represents an enchantment or gem on equipment │
│ @@ -33,10 +36,10 @@ type EnchantmentData struct { │
│ } │
│ │
│ // LoadAllEquipment loads equipment and enchantments for a set of players │
│ -// Returns: map[playerID]map[timestamp][]EquipmentData, map[equipmentID][]Enchant │
│ mentData, error │
│ -func LoadAllEquipment(db *sql.DB, playerIDs []int64) (map[int64]map[int64][]Equip │
│ mentData, map[int64][]EnchantmentData, error) { │
│ +// Returns: map[playerID][]EquipmentData, map[equipmentID][]EnchantmentData, erro │
│ r │
│ +func LoadAllEquipment(db *sql.DB, playerIDs []int64) (map[int64][]EquipmentData, │
│ map[int64][]EnchantmentData, error) { │
│ if len(playerIDs) == 0 { │
│ - return make(map[int64]map[int64][]EquipmentData), make(map[int64][]EnchantmentD │
│ ata), nil │
│ + return make(map[int64][]EquipmentData), make(map[int64][]EnchantmentData), nil │
│ } │
│ │
│ // Build IN clause │
│ @@ -47,62 +50,20 @@ func LoadAllEquipment(db *sql.DB, playerIDs []int64) (map[int6 │
│ 4]map[int64][]Equi │
│ args[i] = id │
│ } │
│ │
│ - // Get latest timestamp per player first │
│ - latestQuery := fmt.Sprintf(` │
│ - SELECT player_id, MAX(snapshot_timestamp) │
│ - FROM player_equipment │
│ - WHERE player_id IN (%s) │
│ - GROUP BY player_id │
│ - `, strings.Join(placeholders, ",")) │
│ - │
│ - latestRows, err := db.Query(latestQuery, args...) │
│ - if err != nil { │
│ - return nil, nil, err │
│ - } │
│ - defer latestRows.Close() │
│ - │
│ - playerTimestamps := make(map[int64]int64) │
│ - for latestRows.Next() { │
│ - var playerID, timestamp int64 │
│ - if err := latestRows.Scan(&playerID, ×tamp); err != nil { │
│ - return nil, nil, err │
│ - } │
│ - playerTimestamps[playerID] = timestamp │
│ - } │
│ - │
│ - if len(playerTimestamps) == 0 { │
│ - return make(map[int64]map[int64][]EquipmentData), make(map[int64][]EnchantmentD │
│ ata), nil │
│ - } │
│ - │
│ - // Load equipment for latest timestamps in a single batched query │
│ - equipmentMap := make(map[int64]map[int64][]EquipmentData) │
│ + equipmentMap := make(map[int64][]EquipmentData) │
│ var allEquipmentIDs []int64 │
│ │
│ - // Build VALUES clause for all player/timestamp pairs │
│ - valuesBuilder := strings.Builder{} │
│ - eqArgs := make([]any, 0, len(playerTimestamps)*2) │
│ - first := true │
│ - for playerID, timestamp := range playerTimestamps { │
│ - if !first { │
│ - valuesBuilder.WriteString(",") │
│ - } │
│ - valuesBuilder.WriteString("(?,?)") │
│ - eqArgs = append(eqArgs, playerID, timestamp) │
│ - first = false │
│ - } │
│ - │
│ - // Single query to load all equipment │
│ + // Simple query - one row per (player_id, slot_type) │
│ eqQuery := fmt.Sprintf(` │
│ - WITH pairs(player_id, ts) AS (VALUES %s) │
│ SELECT e.player_id, e.id, e.slot_type, e.item_id, e.upgrade_id, e.quality, e.it │
│ em_name, e.snapshot_timestamp, │
│ - i.icon AS item_icon_slug, i.type AS item_type │
│ - FROM pairs │
│ - JOIN player_equipment e ON e.player_id = pairs.player_id AND e.snapshot_timesta │
│ mp = pairs.ts │
│ + i.icon AS item_icon_slug, i.type AS item_type, i.stats AS item_stats, i. │
│ item_effect, i.spell_description │
│ + FROM player_equipment e │
│ LEFT JOIN items i ON e.item_id = i.id │
│ + WHERE e.player_id IN (%s) │
│ ORDER BY e.player_id, e.slot_type │
│ - `, valuesBuilder.String()) │
│ + `, strings.Join(placeholders, ",")) │
│ │
│ - eqRows, err := db.Query(eqQuery, eqArgs...) │
│ + eqRows, err := db.Query(eqQuery, args...) │
│ if err != nil { │
│ return nil, nil, fmt.Errorf("batch equipment query: %w", err) │
│ } │
│ @@ -113,14 +74,11 @@ func LoadAllEquipment(db *sql.DB, playerIDs []int64) (map[int │
│ 64]map[int64][]Equi │
│ var eq EquipmentData │
│ if err := eqRows.Scan( │
│ &playerID, &eq.ID, &eq.SlotType, &eq.ItemID, &eq.UpgradeID, &eq.Quality, &eq.I │
│ temName, &eq.SnapshotTs, │
│ - &eq.ItemIcon, &eq.ItemType); err != nil { │
│ + &eq.ItemIcon, &eq.ItemType, &eq.ItemStats, &eq.ItemEffect, &eq.SpellDescriptio │
│ n); err != nil { │
│ return nil, nil, fmt.Errorf("scan equipment: %w", err) │
│ } │
│ │
│ - if equipmentMap[playerID] == nil { │
│ - equipmentMap[playerID] = make(map[int64][]EquipmentData) │
│ - } │
│ - equipmentMap[playerID][eq.SnapshotTs] = append(equipmentMap[playerID][eq.Snapsh │
│ otTs], eq) │
│ + equipmentMap[playerID] = append(equipmentMap[playerID], eq) │
│ allEquipmentIDs = append(allEquipmentIDs, eq.ID) │
│ } │
│ │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ .../internal/loader/equipment.go ───┐
│ diff --git a/nix/pkgs/ookstats/src/internal/ │
│ loader/equipment.go b/nix/pkgs/ookstats/src/ │
│ internal/loader/equipment.go │
│ index cecf405..042d51b 100644 │
│ --- a/nix/pkgs/ookstats/src/internal/loader/ │
│ equipment.go │
│ +++ b/nix/pkgs/ookstats/src/internal/loader/ │
│ equipment.go │
│ @@ -8,15 +8,18 @@ import ( │
│ │
│ // EquipmentData represents a piece of equi │
│ pment │
│ type EquipmentData struct { │
│ - ID int64 │
│ - SlotType string │
│ - ItemID sql.NullInt64 │
│ - UpgradeID sql.NullInt64 │
│ - Quality string │
│ - ItemName string │
│ - SnapshotTs int64 │
│ - ItemIcon sql.NullString │
│ - ItemType sql.NullString │
│ + ID int64 │
│ + SlotType string │
│ + ItemID sql.NullInt64 │
│ + UpgradeID sql.NullInt64 │
│ + Quality string │
│ + ItemName string │
│ + SnapshotTs int64 │
│ + ItemIcon sql.NullString │
│ + ItemType sql.NullString │
│ + ItemStats sql.NullString │
│ + ItemEffect sql.NullString │
│ + SpellDescription sql.NullString │
│ } │
│ │
│ // EnchantmentData represents an enchantmen │
│ t or gem on equipment │
│ @@ -33,10 +36,10 @@ type EnchantmentData str │
│ uct { │
│ } │
│ │
│ // LoadAllEquipment loads equipment and enc │
│ hantments for a set of players │
│ -// Returns: map[playerID]map[timestamp][]Eq │
│ uipmentData, map[equipmentID][]EnchantmentDa │
│ ta, error │
│ -func LoadAllEquipment(db *sql.DB, playerIDs │
│ []int64) (map[int64]map[int64][]EquipmentDa │
│ ta, map[int64][]EnchantmentData, error) { │
│ +// Returns: map[playerID][]EquipmentData, m │
│ ap[equipmentID][]EnchantmentData, error │
│ +func LoadAllEquipment(db *sql.DB, playerIDs │
│ []int64) (map[int64][]EquipmentData, map[in │
│ t64][]EnchantmentData, error) { │
│ if len(playerIDs) == 0 { │
│ - return make(map[int64]map[int64][]Equipme │
│ ntData), make(map[int64][]EnchantmentData), │
│ nil │
│ + return make(map[int64][]EquipmentData), m │
│ ake(map[int64][]EnchantmentData), nil │
│ } │
│ │
│ // Build IN clause │
│ @@ -47,62 +50,20 @@ func LoadAllEquipment(db │
│ *sql.DB, playerIDs []int64) (map[int64]map[ │
│ int64][]Equi │
│ args[i] = id │
│ } │
│ │
│ - // Get latest timestamp per player first │
│ - latestQuery := fmt.Sprintf(` │
│ - SELECT player_id, MAX(snapshot_time │
│ stamp) │
│ - FROM player_equipment │
│ - WHERE player_id IN (%s) │
│ - GROUP BY player_id │
│ - `, strings.Join(placeholders, ",")) │
│ - │
│ - latestRows, err := db.Query(latestQuery, a │
│ rgs...) │
│ - if err != nil { │
│ - return nil, nil, err │
│ - } │
│ - defer latestRows.Close() │
│ - │
│ - playerTimestamps := make(map[int64]int64) │
│ - for latestRows.Next() { │
│ - var playerID, timestamp int64 │
│ - if err := latestRows.Scan(&playerID, &tim │
│ estamp); err != nil { │
│ - return nil, nil, err │
│ - } │
│ - playerTimestamps[playerID] = timestamp │
│ - } │
│ - │
│ - if len(playerTimestamps) == 0 { │
│ - return make(map[int64]map[int64][]Equipme │
│ ntData), make(map[int64][]EnchantmentData), │
│ nil │
│ - } │
│ - │
│ - // Load equipment for latest timestamps in │
│ a single batched query │
│ - equipmentMap := make(map[int64]map[int64][ │
│ ]EquipmentData) │
│ + equipmentMap := make(map[int64][]Equipment │
│ Data) │
│ var allEquipmentIDs []int64 │
│ │
│ - // Build VALUES clause for all player/time │
│ stamp pairs │
│ - valuesBuilder := strings.Builder{} │
│ - eqArgs := make([]any, 0, len(playerTimesta │
│ mps)*2) │
│ - first := true │
│ - for playerID, timestamp := range playerTim │
│ estamps { │
│ - if !first { │
│ - valuesBuilder.WriteString(",") │
│ - } │
│ - valuesBuilder.WriteString("(?,?)") │
│ - eqArgs = append(eqArgs, playerID, timesta │
│ mp) │
│ - first = false │
│ - } │
│ - │
│ - // Single query to load all equipment │
│ + // Simple query - one row per (player_id, │
│ slot_type) │
│ eqQuery := fmt.Sprintf(` │
│ - WITH pairs(player_id, ts) AS (VALUES %s) │
│ SELECT e.player_id, e.id, e.slot_type, e. │
│ item_id, e.upgrade_id, e.quality, e.item_nam │
│ e, e.snapshot_timestamp, │
│ - i.icon AS item_icon_slug, i.type A │
│ S item_type │
│ - FROM pairs │
│ - JOIN player_equipment e ON e.player_id = │
│ pairs.player_id AND e.snapshot_timestamp = p │
│ airs.ts │
│ + i.icon AS item_icon_slug, i.type A │
│ S item_type, i.stats AS item_stats, i.item_e │
│ ffect, i.spell_description │
│ + FROM player_equipment e │
│ LEFT JOIN items i ON e.item_id = i.id │
│ + WHERE e.player_id IN (%s) │
│ ORDER BY e.player_id, e.slot_type │
│ - `, valuesBuilder.String()) │
│ + `, strings.Join(placeholders, ",")) │
│ │
│ - eqRows, err := db.Query(eqQuery, eqArgs... │
│ ) │
│ + eqRows, err := db.Query(eqQuery, args...) │
│ if err != nil { │
│ return nil, nil, fmt.Errorf("batch equipm │
│ ent query: %w", err) │
│ } │
│ @@ -113,14 +74,11 @@ func LoadAllEquipment(d │
│ b *sql.DB, playerIDs []int64) (map[int64]map │
│ [int64][]Equi │
│ var eq EquipmentData │
│ if err := eqRows.Scan( │
│ &playerID, &eq.ID, &eq.SlotType, &eq.Ite │
│ mID, &eq.UpgradeID, &eq.Quality, &eq.ItemNam │
│ e, &eq.SnapshotTs, │
│ - &eq.ItemIcon, &eq.ItemType); err != nil │
│ { │
│ + &eq.ItemIcon, &eq.ItemType, &eq.ItemStat │
│ s, &eq.ItemEffect, &eq.SpellDescription); er │
│ r != nil { │
│ return nil, nil, fmt.Errorf("scan equipm │
│ ent: %w", err) │
│ } │
│ │
│ - if equipmentMap[playerID] == nil { │
│ - equipmentMap[playerID] = make(map[int64] │
│ []EquipmentData) │
│ - } │
│ - equipmentMap[playerID][eq.SnapshotTs] = a │
│ ppend(equipmentMap[playerID][eq.SnapshotTs], │
│ eq) │
│ + equipmentMap[playerID] = append(equipment │
│ Map[playerID], eq) │
│ allEquipmentIDs = append(allEquipmentIDs, │
│ eq.ID) │
│ } │
│ │
└──────────────────────────────────────────────┘
[ BACK TO LOG ]
──────────────────────────────────────────────────────────────────────────────────────
OOKNET
────────────────────────────────────────────────
OOKNET