HASH 617f443321dd
DATE 2026-05-06
SUBJECT ookstats: comment + format pass
FILES 4 CHANGED
HASH 617f443321dd
DATE 2026-05-06
SUBJECT ookstats: comment + format pass
FILES 4 CHANGED
┌─ nix/pkgs/ookstats/src/internal/generator/gear.go ─────────────────────────┐│ diff --git a/nix/pkgs/ookstats/src/internal/generator/gear.go b/nix/pkgs/ookstats/ ││ src/internal/generator/gear.go ││ index ec1ddac..c7acfe2 100644 ││ --- a/nix/pkgs/ookstats/src/internal/generator/gear.go ││ +++ b/nix/pkgs/ookstats/src/internal/generator/gear.go ││ @@ -11,37 +11,28 @@ import ( ││ "ookstats/internal/writer" ││ ) ││ ││ -// GearJSON is the top-level shape of gear.json. ││ type GearJSON struct { ││ - GeneratedAt int64 `json:"generated_at"` ││ - // Outer key: season key ("season_1" | "season_2"). Inner key: spec_id (string). ││ + GeneratedAt int64 `json:"generated_at"` ││ + // outer key: season ("season_1" | "season_2"); inner key: spec_id as string ││ Scopes map[string]map[string]GearSpecBucket `json:"scopes"` ││ } ││ ││ -// GearSpecBucket - gear popularity for a single (season, spec) pool. ││ -// `total_players` is the count of qualifying players (after spec-stat ││ -// validation) whose gear was tallied. `slots` is keyed by canonical slot name ││ -// (HEAD, NECK, FINGER, TRINKET, etc.). ││ type GearSpecBucket struct { ││ - TotalPlayers int `json:"total_players"` ││ - Slots map[string]GearSlotBucket `json:"slots"` ││ + TotalPlayers int `json:"total_players"` ││ + Slots map[string]GearSlotBucket `json:"slots"` ││ } ││ ││ -// GearSlotBucket - items equipped in a slot, sorted by frequency. ││ type GearSlotBucket struct { ││ - // PlayersWithSlot is the number of qualifying players who had any item ││ - // equipped in this slot (denominator for share %). For paired slots ││ - // (FINGER, TRINKET) a player can contribute up to 2 to the slot tally so ││ - // PlayersWithSlot * 2 is the entry-count ceiling. ││ + // denominator for share %; paired slots can contribute up to 2 per player ││ + // so the entry-count ceiling is PlayersWithSlot * 2 ││ PlayersWithSlot int `json:"players_with_slot"` ││ Items []GearItemEntry `json:"items"` ││ } ││ ││ -// GearItemEntry - one item's tally within a (spec, slot) bucket. ││ -// Aggregation key is `name`, not `item_id`: gear has multiple item ids for the ││ -// same piece at different ilvls (LFR / normal / heroic / upgraded) and CM ││ -// players treat them as the same gear. `item_id` is the most-equipped variant, ││ -// used for the wowhead link and the icon. ││ +// aggregation key is `name`, not `item_id`: gear has multiple item ids for the ││ +// same piece at different ilvls (LFR/normal/heroic/upgraded) and CM players ││ +// treat them as the same gear; ItemID is the most-equipped variant for the ││ +// wowhead link and icon ││ type GearItemEntry struct { ││ ItemID int `json:"item_id"` ││ Name string `json:"name"` ││ @@ -50,12 +41,10 @@ type GearItemEntry struct { ││ Count int `json:"count"` ││ } ││ ││ -// Pool size - how many top players per spec to consider. ││ const gearTopPlayers = 100 ││ ││ -// Slots we tally. Cosmetic slots (SHIRT, TABARD) excluded. Paired slots ││ -// (FINGER_1+FINGER_2, TRINKET_1+TRINKET_2) get merged in the output to a ││ -// single "FINGER" / "TRINKET" bucket since the suffix has no meaning. ││ +// cosmetic slots (SHIRT, TABARD) excluded; FINGER_1/_2 and TRINKET_1/_2 get ││ +// merged in the output to a single "FINGER"/"TRINKET" bucket ││ var gearSlots = []string{ ││ "HEAD", "NECK", "SHOULDER", "BACK", "CHEST", "WRIST", ││ "HANDS", "WAIST", "LEGS", "FEET", ││ @@ -64,7 +53,6 @@ var gearSlots = []string{ ││ "MAIN_HAND", "OFF_HAND", ││ } ││ ││ -// gearSlotOutputName collapses paired slots to their merged output name. ││ func gearSlotOutputName(rawSlot string) string { ││ switch rawSlot { ││ case "FINGER_1", "FINGER_2": ││ @@ -75,15 +63,14 @@ func gearSlotOutputName(rawSlot string) string { ││ return rawSlot ││ } ││ ││ -// Stat IDs in items.stats JSON - derived from inspecting known items. ││ +// stat IDs in items.stats JSON, derived from inspecting known items ││ const ( ││ statKeyStr = "0" ││ statKeyAgi = "1" ││ statKeyInt = "3" ││ ) ││ ││ -// itemStatProfile holds the primary stat we extracted from an item's stats JSON. ││ -// `primary` is "str"/"agi"/"int"/"" (none - typically secondary-stat-only items) ││ . ││ +// `primary` is "str"/"agi"/"int"/"" (empty for secondary-stat-only items) ││ type itemStatProfile struct { ││ primary wow.PrimaryStat ││ name string ││ @@ -98,9 +85,8 @@ func GenerateGear(db *sql.DB, outDir string) error { ││ Scopes: make(map[string]map[string]GearSpecBucket), ││ } ││ ││ - // Load every item's stats once into memory - there are ~12k items in the ││ - // DB and we'll touch every one of them across the spec loop, so a single ││ - // scan beats per-spec joins. ││ + // load all item stats once: ~12k items touched across the spec loop, so ││ + // a single scan beats per-spec joins ││ itemProfiles, err := loadItemProfiles(db) ││ if err != nil { ││ return fmt.Errorf("load item profiles: %w", err) ││ @@ -156,8 +142,7 @@ func gatherSpecIDs() map[int]struct{} { ││ return out ││ } ││ ││ -// loadItemProfiles preloads a (item_id -> profile) map. We pull every item ││ -// rather than per-spec since the same item may appear across many specs. ││ +// every item, not per-spec: same item often appears across many specs ││ func loadItemProfiles(db *sql.DB) (map[int]itemStatProfile, error) { ││ rows, err := db.Query(`SELECT id, name, icon, quality, stats FROM items`) ││ if err != nil { ││ @@ -184,14 +169,9 @@ func loadItemProfiles(db *sql.DB) (map[int]itemStatProfile, e ││ rror) { ││ return out, rows.Err() ││ } ││ ││ -// deriveItemPrimaryStat parses an item's stats JSON and returns its primary ││ -// stat, or "" if the item carries none (e.g. a tabard, blank cosmetic, or a ││ -// piece with only secondary stats). ││ -// ││ -// Stats JSON shape: {"<ilvl_offset>": {"stats": {"<stat_id>": <value>, ...}, ... ││ }, ...} ││ -// We only need to look at one ilvl entry - primary stat IDs don't change per ││ -// upgrade tier. Picking the highest ilvl entry is the safest (lowest entries ││ -// can be sparse for some items). ││ +// returns "" for items with no primary (tabards, cosmetics, secondary-only). ││ +// JSON shape: {"<ilvl_offset>": {"stats": {"<stat_id>": <value>}}}. ││ +// any one ilvl entry is fine - primary stat IDs don't change per upgrade tier ││ func deriveItemPrimaryStat(statsJSON string) wow.PrimaryStat { ││ if statsJSON == "" { ││ return "" ││ @@ -202,8 +182,7 @@ func deriveItemPrimaryStat(statsJSON string) wow.PrimaryStat { ││ if err := json.Unmarshal([]byte(statsJSON), &byIlvl); err != nil { ││ return "" ││ } ││ - // Walk all entries and check for known primary stat keys. Multiple entries ││ - // will have the same primary stat - first hit wins. ││ + // first hit wins - all entries share the same primary ││ for _, entry := range byIlvl { ││ if _, ok := entry.Stats[statKeyStr]; ok { ││ return wow.PrimaryStatStr ││ @@ -218,9 +197,8 @@ func deriveItemPrimaryStat(statsJSON string) wow.PrimaryStat { ││ return "" ││ } ││ ││ -// buildSpecGear walks the top-N players for a (season, spec) pair, validates ││ -// each one's set against the spec's expected primary stat, and tallies surviving ││ -// items per slot. ││ +// validates each top-N set against the spec's expected primary stat, then ││ +// tallies surviving items per slot ││ func buildSpecGear(db *sql.DB, season, specID int, expected wow.PrimaryStat, item ││ Profiles map[int]itemStatProfile) (GearSpecBucket, error) { ││ playerIDs, err := topPlayersForSpec(db, season, specID, gearTopPlayers) ││ if err != nil { ││ @@ -230,24 +208,21 @@ func buildSpecGear(db *sql.DB, season, specID int, expected ││ wow.PrimaryStat, ite ││ return GearSpecBucket{}, nil ││ } ││ ││ - // Equipment for the candidate pool, indexed by player. ││ playerEquipment, err := loadPlayerEquipment(db, playerIDs) ││ if err != nil { ││ return GearSpecBucket{}, fmt.Errorf("equipment: %w", err) ││ } ││ ││ - // (slot -> name -> aggregate). Aggregating by item NAME so that variants ││ - // of the same piece at different ilvls collapse into a single bar. ││ + // aggregating by item name so ilvl variants of the same piece collapse ││ + // into one bar; byID tracks counts per variant so we can pick the ││ + // most-equipped one for the wowhead link ││ type nameAgg struct { ││ count int ││ quality int ││ icon string ││ - // variant id -> count, used to pick the most-equipped variant id as ││ - // the representative for the wowhead link. ││ - byID map[int]int ││ + byID map[int]int ││ } ││ slotItemCounts := make(map[string]map[string]*nameAgg) ││ - // (slot -> count of qualifying players who had any item in that slot). ││ slotPlayerCounts := make(map[string]int) ││ totalPlayers := 0 ││ ││ @@ -261,8 +236,8 @@ func buildSpecGear(db *sql.DB, season, specID int, expected wo ││ w.PrimaryStat, ite ││ } ││ totalPlayers++ ││ ││ - // Track which output-slots this player contributed to (so we don't ││ - // double-count e.g. FINGER if both FINGER_1 and FINGER_2 are equipped). ││ + // avoid double-counting FINGER when both FINGER_1 and FINGER_2 land ││ + // in the same output slot ││ seenSlot := make(map[string]bool) ││ ││ for _, item := range eq { ││ @@ -271,13 +246,10 @@ func buildSpecGear(db *sql.DB, season, specID int, expected ││ wow.PrimaryStat, ite ││ continue ││ } ││ profile, hasProfile := itemProfiles[item.ItemID] ││ - // Drop items whose primary stat clearly doesn't match the spec. ││ - // Items with no primary stat (necks, some trinkets) pass through. ││ + // items with no primary (necks, some trinkets) pass through ││ if hasProfile && profile.primary != "" && profile.primary != expected { ││ continue ││ } ││ - // Skip unknown items (no profile) - name-based aggregation needs a ││ - // name, and a missing profile means we have no name either. ││ if !hasProfile || profile.name == "" { ││ continue ││ } ││ @@ -311,7 +283,7 @@ func buildSpecGear(db *sql.DB, season, specID int, expected wo ││ w.PrimaryStat, ite ││ for slot, byName := range slotItemCounts { ││ entries := make([]GearItemEntry, 0, len(byName)) ││ for name, agg := range byName { ││ - // Pick the most-equipped variant id as the representative. ││ + // most-equipped variant id is the representative ││ var repID, bestCount int ││ for id, c := range agg.byID { ││ if c > bestCount { ││ @@ -327,7 +299,7 @@ func buildSpecGear(db *sql.DB, season, specID int, expected wo ││ w.PrimaryStat, ite ││ Count: agg.count, ││ }) ││ } ││ - // Sort desc by count (insertion sort - slot has at most ~50 distinct items). ││ + // insertion sort desc - slots top out at ~50 distinct items ││ for i := 1; i < len(entries); i++ { ││ for j := i; j > 0 && entries[j].Count > entries[j-1].Count; j-- { ││ entries[j], entries[j-1] = entries[j-1], entries[j] ││ @@ -350,15 +322,12 @@ func isTrackedSlot(rawSlot string) bool { ││ return false ││ } ││ ││ -// equipmentRow mirrors the player_equipment columns we care about. ││ type equipmentRow struct { ││ PlayerID int64 ││ SlotType string ││ ItemID int ││ } ││ ││ -// topPlayersForSpec returns the player_ids of the top-N best-coverage players ││ -// in the given (season, spec) pair, ordered by combined_best_time ascending. ││ func topPlayersForSpec(db *sql.DB, season, specID, limit int) ([]int64, error) { ││ rows, err := db.Query(` ││ SELECT player_id ││ @@ -385,13 +354,10 @@ func topPlayersForSpec(db *sql.DB, season, specID, limit int ││ ) ([]int64, error) { ││ return out, rows.Err() ││ } ││ ││ -// loadPlayerEquipment fetches every tracked-slot item for the given player ids ││ -// in a single query. Result is keyed by player_id. ││ func loadPlayerEquipment(db *sql.DB, playerIDs []int64) (map[int64][]equipmentRow ││ , error) { ││ if len(playerIDs) == 0 { ││ return map[int64][]equipmentRow{}, nil ││ } ││ - // Build IN clause with placeholders. ││ placeholders := "" ││ args := make([]any, 0, len(playerIDs)) ││ for i, id := range playerIDs { ││ @@ -423,9 +389,8 @@ func loadPlayerEquipment(db *sql.DB, playerIDs []int64) (map[i ││ nt64][]equipmentRo ││ return out, rows.Err() ││ } ││ ││ -// playerSetMatchesSpec checks if the player's gear majority-aligns with the ││ -// spec's expected primary stat. Used to drop "logged out in their offspec set" ││ -// entries before tallying their items. ││ +// drops players who logged out in their offspec set: their gear majority ││ +// has to match the spec's expected primary ││ func playerSetMatchesSpec(eq []equipmentRow, expected wow.PrimaryStat, itemProfil ││ es map[int]itemStatProfile) bool { ││ counts := map[wow.PrimaryStat]int{ ││ wow.PrimaryStatStr: 0, ││ @@ -439,13 +404,11 @@ func playerSetMatchesSpec(eq []equipmentRow, expected wow.Pr ││ imaryStat, itemProfi ││ } ││ counts[profile.primary]++ ││ } ││ - // If we found no primary-stat items at all, fall through and keep - not ││ - // enough signal to exclude. ││ + // no primary-stat items at all: not enough signal, keep them ││ total := counts[wow.PrimaryStatStr] + counts[wow.PrimaryStatAgi] + counts[wow.Pr ││ imaryStatInt] ││ if total == 0 { ││ return true ││ } ││ - // Find the modal stat. ││ var modal wow.PrimaryStat ││ var modalCount int ││ for stat, c := range counts { │└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...rc/internal/generator/gear.go ───┐│ diff --git a/nix/pkgs/ookstats/src/internal/ ││ generator/gear.go b/nix/pkgs/ookstats/src/in ││ ternal/generator/gear.go ││ index ec1ddac..c7acfe2 100644 ││ --- a/nix/pkgs/ookstats/src/internal/generat ││ or/gear.go ││ +++ b/nix/pkgs/ookstats/src/internal/generat ││ or/gear.go ││ @@ -11,37 +11,28 @@ import ( ││ "ookstats/internal/writer" ││ ) ││ ││ -// GearJSON is the top-level shape of gear. ││ json. ││ type GearJSON struct { ││ - GeneratedAt int64 ││ `json:"generated_at"` ││ - // Outer key: season key ("season_1" | "se ││ ason_2"). Inner key: spec_id (string). ││ + GeneratedAt int64 `json:"generated_at"` ││ + // outer key: season ("season_1" | "season ││ _2"); inner key: spec_id as string ││ Scopes map[string]map[string]GearSpecBucke ││ t `json:"scopes"` ││ } ││ ││ -// GearSpecBucket - gear popularity for a s ││ ingle (season, spec) pool. ││ -// `total_players` is the count of qualifyi ││ ng players (after spec-stat ││ -// validation) whose gear was tallied. `slo ││ ts` is keyed by canonical slot name ││ -// (HEAD, NECK, FINGER, TRINKET, etc.). ││ type GearSpecBucket struct { ││ - TotalPlayers int ││ `json:"total_players"` ││ - Slots map[string]GearSlotBucket ││ `json:"slots"` ││ + TotalPlayers int `js ││ on:"total_players"` ││ + Slots map[string]GearSlotBucket `js ││ on:"slots"` ││ } ││ ││ -// GearSlotBucket - items equipped in a slo ││ t, sorted by frequency. ││ type GearSlotBucket struct { ││ - // PlayersWithSlot is the number of qualif ││ ying players who had any item ││ - // equipped in this slot (denominator for ││ share %). For paired slots ││ - // (FINGER, TRINKET) a player can contribu ││ te up to 2 to the slot tally so ││ - // PlayersWithSlot * 2 is the entry-count ││ ceiling. ││ + // denominator for share %; paired slots c ││ an contribute up to 2 per player ││ + // so the entry-count ceiling is PlayersWi ││ thSlot * 2 ││ PlayersWithSlot int `json:"pla ││ yers_with_slot"` ││ Items []GearItemEntry `json:"ite ││ ms"` ││ } ││ ││ -// GearItemEntry - one item's tally within ││ a (spec, slot) bucket. ││ -// Aggregation key is `name`, not `item_id` ││ : gear has multiple item ids for the ││ -// same piece at different ilvls (LFR / nor ││ mal / heroic / upgraded) and CM ││ -// players treat them as the same gear. `it ││ em_id` is the most-equipped variant, ││ -// used for the wowhead link and the icon. ││ +// aggregation key is `name`, not `item_id` ││ : gear has multiple item ids for the ││ +// same piece at different ilvls (LFR/norma ││ l/heroic/upgraded) and CM players ││ +// treat them as the same gear; ItemID is t ││ he most-equipped variant for the ││ +// wowhead link and icon ││ type GearItemEntry struct { ││ ItemID int `json:"item_id"` ││ Name string `json:"name"` ││ @@ -50,12 +41,10 @@ type GearItemEntry struc ││ t { ││ Count int `json:"count"` ││ } ││ ││ -// Pool size - how many top players per spe ││ c to consider. ││ const gearTopPlayers = 100 ││ ││ -// Slots we tally. Cosmetic slots (SHIRT, T ││ ABARD) excluded. Paired slots ││ -// (FINGER_1+FINGER_2, TRINKET_1+TRINKET_2) ││ get merged in the output to a ││ -// single "FINGER" / "TRINKET" bucket since ││ the suffix has no meaning. ││ +// cosmetic slots (SHIRT, TABARD) excluded; ││ FINGER_1/_2 and TRINKET_1/_2 get ││ +// merged in the output to a single "FINGER ││ "/"TRINKET" bucket ││ var gearSlots = []string{ ││ "HEAD", "NECK", "SHOULDER", "BACK", "CHEST ││ ", "WRIST", ││ "HANDS", "WAIST", "LEGS", "FEET", ││ @@ -64,7 +53,6 @@ var gearSlots = []string{ ││ "MAIN_HAND", "OFF_HAND", ││ } ││ ││ -// gearSlotOutputName collapses paired slot ││ s to their merged output name. ││ func gearSlotOutputName(rawSlot string) str ││ ing { ││ switch rawSlot { ││ case "FINGER_1", "FINGER_2": ││ @@ -75,15 +63,14 @@ func gearSlotOutputName( ││ rawSlot string) string { ││ return rawSlot ││ } ││ ││ -// Stat IDs in items.stats JSON - derived f ││ rom inspecting known items. ││ +// stat IDs in items.stats JSON, derived fr ││ om inspecting known items ││ const ( ││ statKeyStr = "0" ││ statKeyAgi = "1" ││ statKeyInt = "3" ││ ) ││ ││ -// itemStatProfile holds the primary stat w ││ e extracted from an item's stats JSON. ││ -// `primary` is "str"/"agi"/"int"/"" (none ││ - typically secondary-stat-only items). ││ +// `primary` is "str"/"agi"/"int"/"" (empty ││ for secondary-stat-only items) ││ type itemStatProfile struct { ││ primary wow.PrimaryStat ││ name string ││ @@ -98,9 +85,8 @@ func GenerateGear(db *sql. ││ DB, outDir string) error { ││ Scopes: make(map[string]map[string]G ││ earSpecBucket), ││ } ││ ││ - // Load every item's stats once into memor ││ y - there are ~12k items in the ││ - // DB and we'll touch every one of them ac ││ ross the spec loop, so a single ││ - // scan beats per-spec joins. ││ + // load all item stats once: ~12k items to ││ uched across the spec loop, so ││ + // a single scan beats per-spec joins ││ itemProfiles, err := loadItemProfiles(db) ││ if err != nil { ││ return fmt.Errorf("load item profiles: %w ││ ", err) ││ @@ -156,8 +142,7 @@ func gatherSpecIDs() map ││ [int]struct{} { ││ return out ││ } ││ ││ -// loadItemProfiles preloads a (item_id -> ││ profile) map. We pull every item ││ -// rather than per-spec since the same item ││ may appear across many specs. ││ +// every item, not per-spec: same item ofte ││ n appears across many specs ││ func loadItemProfiles(db *sql.DB) (map[int] ││ itemStatProfile, error) { ││ rows, err := db.Query(`SELECT id, name, ic ││ on, quality, stats FROM items`) ││ if err != nil { ││ @@ -184,14 +169,9 @@ func loadItemProfiles(d ││ b *sql.DB) (map[int]itemStatProfile, error) ││ { ││ return out, rows.Err() ││ } ││ ││ -// deriveItemPrimaryStat parses an item's s ││ tats JSON and returns its primary ││ -// stat, or "" if the item carries none (e. ││ g. a tabard, blank cosmetic, or a ││ -// piece with only secondary stats). ││ -// ││ -// Stats JSON shape: {"<ilvl_offset>": {"st ││ ats": {"<stat_id>": <value>, ...}, ...}, ... ││ } ││ -// We only need to look at one ilvl entry - ││ primary stat IDs don't change per ││ -// upgrade tier. Picking the highest ilvl e ││ ntry is the safest (lowest entries ││ -// can be sparse for some items). ││ +// returns "" for items with no primary (ta ││ bards, cosmetics, secondary-only). ││ +// JSON shape: {"<ilvl_offset>": {"stats": ││ {"<stat_id>": <value>}}}. ││ +// any one ilvl entry is fine - primary sta ││ t IDs don't change per upgrade tier ││ func deriveItemPrimaryStat(statsJSON string ││ ) wow.PrimaryStat { ││ if statsJSON == "" { ││ return "" ││ @@ -202,8 +182,7 @@ func deriveItemPrimarySt ││ at(statsJSON string) wow.PrimaryStat { ││ if err := json.Unmarshal([]byte(statsJSON) ││ , &byIlvl); err != nil { ││ return "" ││ } ││ - // Walk all entries and check for known pr ││ imary stat keys. Multiple entries ││ - // will have the same primary stat - first ││ hit wins. ││ + // first hit wins - all entries share the ││ same primary ││ for _, entry := range byIlvl { ││ if _, ok := entry.Stats[statKeyStr]; ok { ││ return wow.PrimaryStatStr ││ @@ -218,9 +197,8 @@ func deriveItemPrimarySt ││ at(statsJSON string) wow.PrimaryStat { ││ return "" ││ } ││ ││ -// buildSpecGear walks the top-N players fo ││ r a (season, spec) pair, validates ││ -// each one's set against the spec's expect ││ ed primary stat, and tallies surviving ││ -// items per slot. ││ +// validates each top-N set against the spe ││ c's expected primary stat, then ││ +// tallies surviving items per slot ││ func buildSpecGear(db *sql.DB, season, spec ││ ID int, expected wow.PrimaryStat, itemProfil ││ es map[int]itemStatProfile) (GearSpecBucket, ││ error) { ││ playerIDs, err := topPlayersForSpec(db, se ││ ason, specID, gearTopPlayers) ││ if err != nil { ││ @@ -230,24 +208,21 @@ func buildSpecGear(db ││ *sql.DB, season, specID int, expected wow.Pr ││ imaryStat, ite ││ return GearSpecBucket{}, nil ││ } ││ ││ - // Equipment for the candidate pool, index ││ ed by player. ││ playerEquipment, err := loadPlayerEquipmen ││ t(db, playerIDs) ││ if err != nil { ││ return GearSpecBucket{}, fmt.Errorf("equi ││ pment: %w", err) ││ } ││ ││ - // (slot -> name -> aggregate). Aggregatin ││ g by item NAME so that variants ││ - // of the same piece at different ilvls co ││ llapse into a single bar. ││ + // aggregating by item name so ilvl varian ││ ts of the same piece collapse ││ + // into one bar; byID tracks counts per va ││ riant so we can pick the ││ + // most-equipped one for the wowhead link ││ type nameAgg struct { ││ count int ││ quality int ││ icon string ││ - // variant id -> count, used to pick the ││ most-equipped variant id as ││ - // the representative for the wowhead lin ││ k. ││ - byID map[int]int ││ + byID map[int]int ││ } ││ slotItemCounts := make(map[string]map[stri ││ ng]*nameAgg) ││ - // (slot -> count of qualifying players wh ││ o had any item in that slot). ││ slotPlayerCounts := make(map[string]int) ││ totalPlayers := 0 ││ ││ @@ -261,8 +236,8 @@ func buildSpecGear(db *s ││ ql.DB, season, specID int, expected wow.Prim ││ aryStat, ite ││ } ││ totalPlayers++ ││ ││ - // Track which output-slots this player c ││ ontributed to (so we don't ││ - // double-count e.g. FINGER if both FINGE ││ R_1 and FINGER_2 are equipped). ││ + // avoid double-counting FINGER when both ││ FINGER_1 and FINGER_2 land ││ + // in the same output slot ││ seenSlot := make(map[string]bool) ││ ││ for _, item := range eq { ││ @@ -271,13 +246,10 @@ func buildSpecGear(db ││ *sql.DB, season, specID int, expected wow.Pr ││ imaryStat, ite ││ continue ││ } ││ profile, hasProfile := itemProfiles[item ││ .ItemID] ││ - // Drop items whose primary stat clearly ││ doesn't match the spec. ││ - // Items with no primary stat (necks, so ││ me trinkets) pass through. ││ + // items with no primary (necks, some tr ││ inkets) pass through ││ if hasProfile && profile.primary != "" & ││ & profile.primary != expected { ││ continue ││ } ││ - // Skip unknown items (no profile) - nam ││ e-based aggregation needs a ││ - // name, and a missing profile means we ││ have no name either. ││ if !hasProfile || profile.name == "" { ││ continue ││ } ││ @@ -311,7 +283,7 @@ func buildSpecGear(db *s ││ ql.DB, season, specID int, expected wow.Prim ││ aryStat, ite ││ for slot, byName := range slotItemCounts { ││ entries := make([]GearItemEntry, 0, len(b ││ yName)) ││ for name, agg := range byName { ││ - // Pick the most-equipped variant id as ││ the representative. ││ + // most-equipped variant id is the repre ││ sentative ││ var repID, bestCount int ││ for id, c := range agg.byID { ││ if c > bestCount { ││ @@ -327,7 +299,7 @@ func buildSpecGear(db *s ││ ql.DB, season, specID int, expected wow.Prim ││ aryStat, ite ││ Count: agg.count, ││ }) ││ } ││ - // Sort desc by count (insertion sort - s ││ lot has at most ~50 distinct items). ││ + // insertion sort desc - slots top out at ││ ~50 distinct items ││ for i := 1; i < len(entries); i++ { ││ for j := i; j > 0 && entries[j].Count > ││ entries[j-1].Count; j-- { ││ entries[j], entries[j-1] = entries[j-1] ││ , entries[j] ││ @@ -350,15 +322,12 @@ func isTrackedSlot(raw ││ Slot string) bool { ││ return false ││ } ││ ││ -// equipmentRow mirrors the player_equipmen ││ t columns we care about. ││ type equipmentRow struct { ││ PlayerID int64 ││ SlotType string ││ ItemID int ││ } ││ ││ -// topPlayersForSpec returns the player_ids ││ of the top-N best-coverage players ││ -// in the given (season, spec) pair, ordere ││ d by combined_best_time ascending. ││ func topPlayersForSpec(db *sql.DB, season, ││ specID, limit int) ([]int64, error) { ││ rows, err := db.Query(` ││ SELECT player_id ││ @@ -385,13 +354,10 @@ func topPlayersForSpec ││ (db *sql.DB, season, specID, limit int) ([]i ││ nt64, error) { ││ return out, rows.Err() ││ } ││ ││ -// loadPlayerEquipment fetches every tracke ││ d-slot item for the given player ids ││ -// in a single query. Result is keyed by pl ││ ayer_id. ││ func loadPlayerEquipment(db *sql.DB, player ││ IDs []int64) (map[int64][]equipmentRow, erro ││ r) { ││ if len(playerIDs) == 0 { ││ return map[int64][]equipmentRow{}, nil ││ } ││ - // Build IN clause with placeholders. ││ placeholders := "" ││ args := make([]any, 0, len(playerIDs)) ││ for i, id := range playerIDs { ││ @@ -423,9 +389,8 @@ func loadPlayerEquipment ││ (db *sql.DB, playerIDs []int64) (map[int64][ ││ ]equipmentRo ││ return out, rows.Err() ││ } ││ ││ -// playerSetMatchesSpec checks if the playe ││ r's gear majority-aligns with the ││ -// spec's expected primary stat. Used to dr ││ op "logged out in their offspec set" ││ -// entries before tallying their items. ││ +// drops players who logged out in their of ││ fspec set: their gear majority ││ +// has to match the spec's expected primary ││ func playerSetMatchesSpec(eq []equipmentRow ││ , expected wow.PrimaryStat, itemProfiles map ││ [int]itemStatProfile) bool { ││ counts := map[wow.PrimaryStat]int{ ││ wow.PrimaryStatStr: 0, ││ @@ -439,13 +404,11 @@ func playerSetMatchesS ││ pec(eq []equipmentRow, expected wow.PrimaryS ││ tat, itemProfi ││ } ││ counts[profile.primary]++ ││ } ││ - // If we found no primary-stat items at al ││ l, fall through and keep - not ││ - // enough signal to exclude. ││ + // no primary-stat items at all: not enoug ││ h signal, keep them ││ total := counts[wow.PrimaryStatStr] + coun ││ ts[wow.PrimaryStatAgi] + counts[wow.PrimaryS ││ tatInt] ││ if total == 0 { ││ return true ││ } ││ - // Find the modal stat. ││ var modal wow.PrimaryStat ││ var modalCount int ││ for stat, c := range counts { │└──────────────────────────────────────────────┘
┌─ nix/pkgs/ookstats/src/internal/generator/home.go ─────────────────────────┐│ diff --git a/nix/pkgs/ookstats/src/internal/generator/home.go b/nix/pkgs/ookstats/ ││ src/internal/generator/home.go ││ index ca2153a..9bee76f 100644 ││ --- a/nix/pkgs/ookstats/src/internal/generator/home.go ││ +++ b/nix/pkgs/ookstats/src/internal/generator/home.go ││ @@ -390,16 +390,16 @@ func queryTopPlayers(db *sql.DB, q string, args ...any) ([]H ││ omePlayerEntry, erro ││ var out []HomePlayerEntry ││ for rows.Next() { ││ var ( ││ - e HomePlayerEntry ││ - className sql.NullString ││ - mainSpecID sql.NullInt64 ││ - globalRank sql.NullInt64 ││ - globalBracket sql.NullString ││ - regionalRank sql.NullInt64 ││ + e HomePlayerEntry ││ + className sql.NullString ││ + mainSpecID sql.NullInt64 ││ + globalRank sql.NullInt64 ││ + globalBracket sql.NullString ││ + regionalRank sql.NullInt64 ││ regionalBracket sql.NullString ││ - combinedBest sql.NullInt64 ││ - realmName sql.NullString ││ - avatarURL sql.NullString ││ + combinedBest sql.NullInt64 ││ + realmName sql.NullString ││ + avatarURL sql.NullString ││ ) ││ if err := rows.Scan( ││ &e.PlayerID, &e.Name, &className, &mainSpecID, ││ @@ -457,9 +457,7 @@ func queryTopPlayers(db *sql.DB, q string, args ...any) ([]Hom ││ ePlayerEntry, erro ││ return out, rows.Err() ││ } ││ ││ -// isRegionalQuery is a tiny heuristic on the SELECT/ORDER text so queryTopPlayer ││ s can ││ -// decide which rank value to surface as `rank`. Cheap and safe - keeps queryTopP ││ layers ││ -// reusable for both global and regional list queries. ││ +// heuristic on ORDER text so queryTopPlayers can pick global vs regional rank ││ func isRegionalQuery(q string) bool { ││ return strings.Contains(q, "ORDER BY pp.regional_ranking") ││ } │└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...rc/internal/generator/home.go ───┐│ diff --git a/nix/pkgs/ookstats/src/internal/ ││ generator/home.go b/nix/pkgs/ookstats/src/in ││ ternal/generator/home.go ││ index ca2153a..9bee76f 100644 ││ --- a/nix/pkgs/ookstats/src/internal/generat ││ or/home.go ││ +++ b/nix/pkgs/ookstats/src/internal/generat ││ or/home.go ││ @@ -390,16 +390,16 @@ func queryTopPlayers(d ││ b *sql.DB, q string, args ...any) ([]HomePla ││ yerEntry, erro ││ var out []HomePlayerEntry ││ for rows.Next() { ││ var ( ││ - e HomePlayerEntry ││ - className sql.NullString ││ - mainSpecID sql.NullInt64 ││ - globalRank sql.NullInt64 ││ - globalBracket sql.NullString ││ - regionalRank sql.NullInt64 ││ + e HomePlayerEntry ││ + className sql.NullString ││ + mainSpecID sql.NullInt64 ││ + globalRank sql.NullInt64 ││ + globalBracket sql.NullString ││ + regionalRank sql.NullInt64 ││ regionalBracket sql.NullString ││ - combinedBest sql.NullInt64 ││ - realmName sql.NullString ││ - avatarURL sql.NullString ││ + combinedBest sql.NullInt64 ││ + realmName sql.NullString ││ + avatarURL sql.NullString ││ ) ││ if err := rows.Scan( ││ &e.PlayerID, &e.Name, &className, &mainS ││ pecID, ││ @@ -457,9 +457,7 @@ func queryTopPlayers(db ││ *sql.DB, q string, args ...any) ([]HomePlaye ││ rEntry, erro ││ return out, rows.Err() ││ } ││ ││ -// isRegionalQuery is a tiny heuristic on t ││ he SELECT/ORDER text so queryTopPlayers can ││ -// decide which rank value to surface as `r ││ ank`. Cheap and safe - keeps queryTopPlayers ││ -// reusable for both global and regional li ││ st queries. ││ +// heuristic on ORDER text so queryTopPlaye ││ rs can pick global vs regional rank ││ func isRegionalQuery(q string) bool { ││ return strings.Contains(q, "ORDER BY pp.re ││ gional_ranking") ││ } │└──────────────────────────────────────────────┘
┌─ nix/pkgs/ookstats/src/internal/generator/stats.go ────────────────────────┐│ diff --git a/nix/pkgs/ookstats/src/internal/generator/stats.go b/nix/pkgs/ookstats ││ /src/internal/generator/stats.go ││ index d727a2a..3f0a88b 100644 ││ --- a/nix/pkgs/ookstats/src/internal/generator/stats.go ││ +++ b/nix/pkgs/ookstats/src/internal/generator/stats.go ││ @@ -13,7 +13,7 @@ import ( ││ // StatsJSON is the top-level shape for web/public/api/stats.json. ││ // Three scopes are emitted: cross-season "all_time" plus one entry per season nu ││ mber. ││ type StatsJSON struct { ││ - GeneratedAt int64 `json:"generated_at"` ││ + GeneratedAt int64 `json:"generated_at"` ││ // Scopes is keyed by region (global/us/eu/kr/tw), then by season key ││ // (all_time/season_1/season_2). Region "global" includes all regions. ││ Scopes map[string]map[string]StatsScope `json:"scopes"` ││ @@ -21,41 +21,37 @@ type StatsJSON struct { ││ ││ // StatsScope holds aggregated stats for a single scope (all-time or one season). ││ type StatsScope struct { ││ - TotalRuns int64 `json:"total_runs"` ││ - TotalPlayers int64 `json:"total_players"` ││ - NineOfNinePlayers int64 `json:"nine_of_nine_players"` ││ - CompletionTiers CompletionTiers `json:"completion_tiers"` ││ + TotalRuns int64 `json:"total_runs"` ││ + TotalPlayers int64 `json:"total_players"` ││ + NineOfNinePlayers int64 `json:"nine_of_nine_players"` ││ + CompletionTiers CompletionTiers `json:"completion_tiers"` ││ SpecCounts map[string]SpecCountBucket `json:"spec_counts"` ││ - WeeklyActivity []WeeklyActivityEntry `json:"weekly_activity"` ││ + WeeklyActivity []WeeklyActivityEntry `json:"weekly_activity"` ││ } ││ ││ -// SpecCountBucket - entries plus the bucket's distinct-run denominator, so the ││ -// chart can switch between "total slot count" and "% of runs containing the ││ -// spec" without recomputing. `ByDungeon` is the same shape grouped per-dungeon, ││ -// for the spec chart's dungeon filter and the dungeon-distribution chart. ││ +// TotalRuns is the distinct-run denominator so the chart can switch between ││ +// "total slot count" and "% of runs containing the spec" without recomputing ││ type SpecCountBucket struct { ││ - TotalRuns int64 `json:"total_runs"` ││ - Entries []SpecCountEntry `json:"entries"` ││ - ByDungeon map[int]DungeonSpecBucket `json:"by_dungeon"` ││ + TotalRuns int64 `json:"total_runs"` ││ + Entries []SpecCountEntry `json:"entries"` ││ + ByDungeon map[int]DungeonSpecBucket `json:"by_dungeon"` ││ } ││ ││ -// DungeonSpecBucket - sub-bucket for one dungeon within a SpecCountBucket. ││ type DungeonSpecBucket struct { ││ TotalRuns int64 `json:"total_runs"` ││ Entries []SpecCountEntry `json:"entries"` ││ } ││ ││ -// CompletionTiers groups the 9-of-9 medal-tier counts. ││ type CompletionTiers struct { ││ NineOfNineGold CompletionTier `json:"9_of_9_gold"` ││ NineOfNinePlatinum CompletionTier `json:"9_of_9_platinum"` ││ NineOfNineTitle CompletionTier `json:"9_of_9_title"` ││ } ││ ││ -// CompletionTier - count plus three percentile denominators. ││ -// `percentile_of_all_players` = count / scope.total_players (rarity vs scoped po ││ ol) ││ -// `percentile_of_completed_players` = count / players_with_9_dungeon_bests (rari ││ ty among finishers) ││ -// `percentile_of_all_time_players` = count / all_time.total_players (stable cros ││ s-scope denominator) ││ +// percentile denominators: ││ +// of_all_players = scoped pool ││ +// of_completed_players = finishers (players_with_9_dungeon_bests) ││ +// of_all_time_players = stable cross-scope denominator ││ type CompletionTier struct { ││ Count int64 `json:"count"` ││ PercentileOfAllPlayers float64 `json:"percentile_of_all_players"` ││ @@ -63,11 +59,9 @@ type CompletionTier struct { ││ PercentileOfAllTimePlayers float64 `json:"percentile_of_all_time_players"` ││ } ││ ││ -// SpecCountEntry - one row of the spec-distribution charts. ││ -// `count` is the total number of run_member spec slots (a run with two of the ││ -// same spec contributes 2). `runs_with_spec` is the count of distinct runs ││ -// containing the spec (the same run with two of the spec contributes 1 - capped ││ -// at the bucket's total_runs). ││ +// `count` is total run_member spec slots (a run with two of the same spec ││ +// contributes 2); `runs_with_spec` is distinct runs containing the spec ││ +// (same run with two of the spec contributes 1, capped at total_runs) ││ type SpecCountEntry struct { ││ SpecID int `json:"spec_id"` ││ ClassName string `json:"class_name"` ││ @@ -76,15 +70,13 @@ type SpecCountEntry struct { ││ RunsWithSpec int64 `json:"runs_with_spec"` ││ } ││ ││ -// WeeklyActivityEntry - one bucket on the activity timeline (Monday-start week). ││ type WeeklyActivityEntry struct { ││ WeekStart string `json:"week_start"` // YYYY-MM-DD ││ RunCount int64 `json:"run_count"` ││ } ││ ││ -// dungeonThresholds - gold / platinum / title timer thresholds in milliseconds. ││ -// Gold = the achievement par-time (the "beat the timer" goal). ││ -// Platinum / title are tighter community-defined cutoffs (see /docs or original ││ source). ││ +// gold/platinum/title timer thresholds in ms; gold is the achievement par ││ +// time, platinum and title are tighter community-defined cutoffs ││ type dungeonThresholds struct { ││ GoldMs int64 ││ PlatinumMs int64 ││ @@ -92,32 +84,30 @@ type dungeonThresholds struct { ││ } ││ ││ var dungeonTimerThresholds = map[int]dungeonThresholds{ ││ - 2: {GoldMs: 900000, PlatinumMs: 615000, TitleMs: 510000}, // Temple of the Ja ││ de Serpent (15:00 / 10:15 / 8:30) ││ - 56: {GoldMs: 720000, PlatinumMs: 495000, TitleMs: 390000}, // Stormstout Brewe ││ ry (12:00 / 8:15 / 6:30) ││ - 57: {GoldMs: 780000, PlatinumMs: 480000, TitleMs: 330000}, // Gate of the Sett ││ ing Sun (13:00 / 8:00 / 5:30) ││ - 58: {GoldMs: 1260000, PlatinumMs: 840000, TitleMs: 630000}, // Shado-Pan Monast ││ ery (21:00 / 14:00 / 10:30) ││ - 59: {GoldMs: 1050000, PlatinumMs: 735000, TitleMs: 615000}, // Siege of Niuzao ││ Temple (17:30 / 12:15 / 10:15) ││ - 60: {GoldMs: 720000, PlatinumMs: 495000, TitleMs: 405000}, // Mogu'shan Palace ││ (12:00 / 8:15 / 6:45) ││ - 76: {GoldMs: 1140000, PlatinumMs: 615000, TitleMs: 435000}, // Scholomance ││ (19:00 / 10:15 / 7:15) ││ - 77: {GoldMs: 780000, PlatinumMs: 480000, TitleMs: 255000}, // Scarlet Halls ││ (13:00 / 8:00 / 4:15) ││ - 78: {GoldMs: 780000, PlatinumMs: 540000, TitleMs: 330000}, // Scarlet Monaster ││ y (13:00 / 9:00 / 5:30) ││ -} ││ - ││ -// totalDungeons in the season - a player must clear all of them to qualify for 9 ││ /9. ││ + 2: {GoldMs: 900000, PlatinumMs: 615000, TitleMs: 510000}, // Temple of the Jad ││ e Serpent (15:00 / 10:15 / 8:30) ││ + 56: {GoldMs: 720000, PlatinumMs: 495000, TitleMs: 390000}, // Stormstout Brewer ││ y (12:00 / 8:15 / 6:30) ││ + 57: {GoldMs: 780000, PlatinumMs: 480000, TitleMs: 330000}, // Gate of the Setti ││ ng Sun (13:00 / 8:00 / 5:30) ││ + 58: {GoldMs: 1260000, PlatinumMs: 840000, TitleMs: 630000}, // Shado-Pan Monaste ││ ry (21:00 / 14:00 / 10:30) ││ + 59: {GoldMs: 1050000, PlatinumMs: 735000, TitleMs: 615000}, // Siege of Niuzao T ││ emple (17:30 / 12:15 / 10:15) ││ + 60: {GoldMs: 720000, PlatinumMs: 495000, TitleMs: 405000}, // Mogu'shan Palace ││ (12:00 / 8:15 / 6:45) ││ + 76: {GoldMs: 1140000, PlatinumMs: 615000, TitleMs: 435000}, // Scholomance ││ (19:00 / 10:15 / 7:15) ││ + 77: {GoldMs: 780000, PlatinumMs: 480000, TitleMs: 255000}, // Scarlet Halls ││ (13:00 / 8:00 / 4:15) ││ + 78: {GoldMs: 780000, PlatinumMs: 540000, TitleMs: 330000}, // Scarlet Monastery ││ (13:00 / 9:00 / 5:30) ││ +} ││ + ││ +// must clear all of them to qualify for 9/9 ││ var totalDungeons = len(dungeonTimerThresholds) ││ ││ -// In-game cutoffs are lenient: the displayed cutoff is the first second that ││ -// still earns the medal. e.g. a 5:30.800 run beats a "5:30" title cutoff, ││ -// because the timer reads "5:30" until it ticks over to 5:31. So the actual ││ -// pass condition is `duration < threshold + leniencyMs`. ││ +// in-game cutoffs are lenient: a 5:30.800 run beats a "5:30" title cutoff ││ +// because the timer reads "5:30" until it ticks to 5:31, so pass condition ││ +// is `duration < threshold + leniencyMs` ││ const leniencyMs int64 = 1000 ││ ││ -// beatsTier reports whether duration earns at least the given threshold. ││ func beatsTier(duration, thresholdMs int64) bool { ││ return duration < thresholdMs+leniencyMs ││ } ││ ││ -// Scopes emitted in the output. Order matters for JSON readability. ││ +// order matters for JSON readability ││ type statsScopeDef struct { ││ Key string ││ SeasonNum int // 0 = all-time ││ @@ -277,13 +267,10 @@ func countTotalPlayers(db *sql.DB, seasonNum int, region str ││ ing) (int64, error) ││ return n, nil ││ } ││ ││ -// computeCompletionTiers loads each player's best time per (season, dungeon) and ││ -// classifies them against the gold / platinum / title thresholds. ││ -// Tiers are achievements earned *within a single season*, so for the all-time sc ││ ope ││ -// we take the union of per-season qualifiers - a player counts if they hit 9/9 o ││ f a ││ -// tier in any single season, not by combining best times across seasons. ││ -// Returns the tier counts and the count of players who completed all 9 dungeons ││ in ││ -// at least one season (the "completed" denominator). ││ +// tiers are achievements earned within a single season; for all-time scope ││ +// we take the union of per-season qualifiers (hit 9/9 of a tier in any one ││ +// season counts), not combine best times across seasons. returns tier ││ +// counts plus the "completed" denominator (any-season 9-dungeon finishers). ││ func computeCompletionTiers(db *sql.DB, seasonNum int, region string) (Completion ││ Tiers, int64, error) { ││ q := ` ││ SELECT rm.player_id, cr.season_id, cr.dungeon_id, MIN(cr.duration) AS best_dura ││ tion ││ @@ -322,7 +309,7 @@ func computeCompletionTiers(db *sql.DB, seasonNum int, region ││ string) (Completio ││ } ││ threshold, ok := dungeonTimerThresholds[dungeonID] ││ if !ok { ││ - continue // unknown dungeon - skip silently ││ + continue // unknown dungeon - skip silently ││ } ││ k := key{playerID, seasonID} ││ c := per[k] ││ @@ -401,12 +388,14 @@ func withPercentiles(t CompletionTier, totalPlayers, complet ││ edPlayers, allTimePl ││ } ││ ││ // computeSpecCounts returns the spec-distribution charts: ││ -// all_runs: every run_member row in scope ││ -// gold_runs: run_members where the run beat the gold threshold for its dun ││ geon ││ -// platinum_runs: same for platinum ││ -// title_runs: same for title ││ -// top_50_runs: run_members where the run was top-50 in scope (globally team- ││ filtered for ││ -// region=="global", regionally team-filtered for a specific reg ││ ion) ││ +// ││ +// all_runs: every run_member row in scope ││ +// gold_runs: run_members where the run beat the gold threshold for its dunge ││ on ││ +// platinum_runs: same for platinum ││ +// title_runs: same for title ││ +// top_50_runs: run_members where the run was top-50 in scope (globally team-fi ││ ltered for ││ +// region=="global", regionally team-filtered for a specific regio ││ n) ││ +// ││ // Each bucket carries both `count` (slot occurrences, includes spec stacking) ││ // and `runs_with_spec` (distinct run count, capped at the bucket's total_runs). ││ func computeSpecCounts(db *sql.DB, seasonNum int, region string) (map[string]Spec ││ CountBucket, error) { ││ @@ -454,9 +443,8 @@ func querySpecCountsForRuns(db *sql.DB, seasonNum int, region ││ string) (SpecCount ││ rClause, rArgs := regionClauseAndArg(region, "r") ││ args := append(sArgs, rArgs...) ││ ││ - // One query grouped by (dungeon, spec) - we derive both the per-dungeon ││ - // breakdown and the overall totals from the same result set, since each ││ - // challenge_run has a single dungeon_id (no double-counting across dungeons). ││ + // each challenge_run has a single dungeon_id, so one (dungeon, spec) ││ + // grouped query gives us both the per-dungeon and overall totals ││ q := ` ││ SELECT cr.dungeon_id, rm.spec_id, ││ COUNT(*) AS slot_count, ││ @@ -501,7 +489,7 @@ func querySpecCountsForRuns(db *sql.DB, seasonNum int, region ││ string) (SpecCount ││ return SpecCountBucket{}, err ││ } ││ ││ - // Per-dungeon distinct run totals AND overall - one query, no scan loop tax. ││ + // per-dungeon and overall in one query ││ perDungeonTotals, overallTotal, err := queryRunCountsForRuns(db, seasonNum, regi ││ on) ││ if err != nil { ││ return SpecCountBucket{}, err ││ @@ -805,16 +793,12 @@ func querySpecCountsForTop50(db *sql.DB, seasonNum int, regi ││ on string) (SpecCoun ││ return bucket, nil ││ } ││ ││ -// specAgg holds in-memory aggregation state when we can't count via SQL - ││ -// see querySpecCountsForTier. ││ +// in-memory aggregation when SQL counting is awkward (see querySpecCountsForTier ││ ) ││ type specAgg struct { ││ count int64 ││ runs map[int64]struct{} ││ } ││ ││ -// specAggsToSlice converts a Go-side aggregation (slot count + run-id set per ││ -// spec) into a sorted slice - used by querySpecCountsForTier where the ││ -// per-dungeon threshold makes a SQL-only aggregation awkward. ││ func specAggsToSlice(aggs map[int]*specAgg) []SpecCountEntry { ││ out := make([]SpecCountEntry, 0, len(aggs)) ││ for specID, a := range aggs { │└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...c/internal/generator/stats.go ───┐│ diff --git a/nix/pkgs/ookstats/src/internal/ ││ generator/stats.go b/nix/pkgs/ookstats/src/i ││ nternal/generator/stats.go ││ index d727a2a..3f0a88b 100644 ││ --- a/nix/pkgs/ookstats/src/internal/generat ││ or/stats.go ││ +++ b/nix/pkgs/ookstats/src/internal/generat ││ or/stats.go ││ @@ -13,7 +13,7 @@ import ( ││ // StatsJSON is the top-level shape for web ││ /public/api/stats.json. ││ // Three scopes are emitted: cross-season " ││ all_time" plus one entry per season number. ││ type StatsJSON struct { ││ - GeneratedAt int64 ││ `json:"generated_at"` ││ + GeneratedAt int64 `json:"generated_at"` ││ // Scopes is keyed by region (global/us/eu ││ /kr/tw), then by season key ││ // (all_time/season_1/season_2). Region "g ││ lobal" includes all regions. ││ Scopes map[string]map[string]StatsScope `j ││ son:"scopes"` ││ @@ -21,41 +21,37 @@ type StatsJSON struct { ││ ││ // StatsScope holds aggregated stats for a ││ single scope (all-time or one season). ││ type StatsScope struct { ││ - TotalRuns int64 ││ `json:"total_runs"` ││ - TotalPlayers int64 ││ `json:"total_players"` ││ - NineOfNinePlayers int64 ││ `json:"nine_of_nine_players"` ││ - CompletionTiers CompletionTiers ││ `json:"completion_tiers"` ││ + TotalRuns int64 ││ `json:"total_runs"` ││ + TotalPlayers int64 ││ `json:"total_players"` ││ + NineOfNinePlayers int64 ││ `json:"nine_of_nine_players"` ││ + CompletionTiers CompletionTiers ││ `json:"completion_tiers"` ││ SpecCounts map[string]SpecCountBuck ││ et `json:"spec_counts"` ││ - WeeklyActivity []WeeklyActivityEntry ││ `json:"weekly_activity"` ││ + WeeklyActivity []WeeklyActivityEntry ││ `json:"weekly_activity"` ││ } ││ ││ -// SpecCountBucket - entries plus the bucke ││ t's distinct-run denominator, so the ││ -// chart can switch between "total slot cou ││ nt" and "% of runs containing the ││ -// spec" without recomputing. `ByDungeon` i ││ s the same shape grouped per-dungeon, ││ -// for the spec chart's dungeon filter and ││ the dungeon-distribution chart. ││ +// TotalRuns is the distinct-run denominato ││ r so the chart can switch between ││ +// "total slot count" and "% of runs contai ││ ning the spec" without recomputing ││ type SpecCountBucket struct { ││ - TotalRuns int64 `jso ││ n:"total_runs"` ││ - Entries []SpecCountEntry `jso ││ n:"entries"` ││ - ByDungeon map[int]DungeonSpecBucket `jso ││ n:"by_dungeon"` ││ + TotalRuns int64 `json: ││ "total_runs"` ││ + Entries []SpecCountEntry `json: ││ "entries"` ││ + ByDungeon map[int]DungeonSpecBucket `json: ││ "by_dungeon"` ││ } ││ ││ -// DungeonSpecBucket - sub-bucket for one d ││ ungeon within a SpecCountBucket. ││ type DungeonSpecBucket struct { ││ TotalRuns int64 `json:"total_ru ││ ns"` ││ Entries []SpecCountEntry `json:"entries" ││ ` ││ } ││ ││ -// CompletionTiers groups the 9-of-9 medal- ││ tier counts. ││ type CompletionTiers struct { ││ NineOfNineGold CompletionTier `json:"9 ││ _of_9_gold"` ││ NineOfNinePlatinum CompletionTier `json:"9 ││ _of_9_platinum"` ││ NineOfNineTitle CompletionTier `json:"9 ││ _of_9_title"` ││ } ││ ││ -// CompletionTier - count plus three percen ││ tile denominators. ││ -// `percentile_of_all_players` = count / sc ││ ope.total_players (rarity vs scoped pool) ││ -// `percentile_of_completed_players` = coun ││ t / players_with_9_dungeon_bests (rarity amo ││ ng finishers) ││ -// `percentile_of_all_time_players` = count ││ / all_time.total_players (stable cross-scop ││ e denominator) ││ +// percentile denominators: ││ +// of_all_players = scoped pool ││ +// of_completed_players = finishers (player ││ s_with_9_dungeon_bests) ││ +// of_all_time_players = stable cross-scop ││ e denominator ││ type CompletionTier struct { ││ Count int64 `json ││ :"count"` ││ PercentileOfAllPlayers float64 `json ││ :"percentile_of_all_players"` ││ @@ -63,11 +59,9 @@ type CompletionTier struc ││ t { ││ PercentileOfAllTimePlayers float64 `json ││ :"percentile_of_all_time_players"` ││ } ││ ││ -// SpecCountEntry - one row of the spec-dis ││ tribution charts. ││ -// `count` is the total number of run_membe ││ r spec slots (a run with two of the ││ -// same spec contributes 2). `runs_with_spe ││ c` is the count of distinct runs ││ -// containing the spec (the same run with t ││ wo of the spec contributes 1 - capped ││ -// at the bucket's total_runs). ││ +// `count` is total run_member spec slots ( ││ a run with two of the same spec ││ +// contributes 2); `runs_with_spec` is dist ││ inct runs containing the spec ││ +// (same run with two of the spec contribut ││ es 1, capped at total_runs) ││ type SpecCountEntry struct { ││ SpecID int `json:"spec_id"` ││ ClassName string `json:"class_name"` ││ @@ -76,15 +70,13 @@ type SpecCountEntry stru ││ ct { ││ RunsWithSpec int64 `json:"runs_with_spec" ││ ` ││ } ││ ││ -// WeeklyActivityEntry - one bucket on the ││ activity timeline (Monday-start week). ││ type WeeklyActivityEntry struct { ││ WeekStart string `json:"week_start"` // YY ││ YY-MM-DD ││ RunCount int64 `json:"run_count"` ││ } ││ ││ -// dungeonThresholds - gold / platinum / ti ││ tle timer thresholds in milliseconds. ││ -// Gold = the achievement par-time (the "be ││ at the timer" goal). ││ -// Platinum / title are tighter community-d ││ efined cutoffs (see /docs or original source ││ ). ││ +// gold/platinum/title timer thresholds in ││ ms; gold is the achievement par ││ +// time, platinum and title are tighter com ││ munity-defined cutoffs ││ type dungeonThresholds struct { ││ GoldMs int64 ││ PlatinumMs int64 ││ @@ -92,32 +84,30 @@ type dungeonThresholds s ││ truct { ││ } ││ ││ var dungeonTimerThresholds = map[int]dungeo ││ nThresholds{ ││ - 2: {GoldMs: 900000, PlatinumMs: 615000, T ││ itleMs: 510000}, // Temple of the Jade Ser ││ pent (15:00 / 10:15 / 8:30) ││ - 56: {GoldMs: 720000, PlatinumMs: 495000, T ││ itleMs: 390000}, // Stormstout Brewery ││ (12:00 / 8:15 / 6:30) ││ - 57: {GoldMs: 780000, PlatinumMs: 480000, T ││ itleMs: 330000}, // Gate of the Setting Su ││ n (13:00 / 8:00 / 5:30) ││ - 58: {GoldMs: 1260000, PlatinumMs: 840000, ││ TitleMs: 630000}, // Shado-Pan Monastery ││ (21:00 / 14:00 / 10:30) ││ - 59: {GoldMs: 1050000, PlatinumMs: 735000, ││ TitleMs: 615000}, // Siege of Niuzao Temple ││ (17:30 / 12:15 / 10:15) ││ - 60: {GoldMs: 720000, PlatinumMs: 495000, T ││ itleMs: 405000}, // Mogu'shan Palace ││ (12:00 / 8:15 / 6:45) ││ - 76: {GoldMs: 1140000, PlatinumMs: 615000, ││ TitleMs: 435000}, // Scholomance ││ (19:00 / 10:15 / 7:15) ││ - 77: {GoldMs: 780000, PlatinumMs: 480000, T ││ itleMs: 255000}, // Scarlet Halls ││ (13:00 / 8:00 / 4:15) ││ - 78: {GoldMs: 780000, PlatinumMs: 540000, T ││ itleMs: 330000}, // Scarlet Monastery ││ (13:00 / 9:00 / 5:30) ││ -} ││ - ││ -// totalDungeons in the season - a player m ││ ust clear all of them to qualify for 9/9. ││ + 2: {GoldMs: 900000, PlatinumMs: 615000, T ││ itleMs: 510000}, // Temple of the Jade Serp ││ ent (15:00 / 10:15 / 8:30) ││ + 56: {GoldMs: 720000, PlatinumMs: 495000, T ││ itleMs: 390000}, // Stormstout Brewery ││ (12:00 / 8:15 / 6:30) ││ + 57: {GoldMs: 780000, PlatinumMs: 480000, T ││ itleMs: 330000}, // Gate of the Setting Sun ││ (13:00 / 8:00 / 5:30) ││ + 58: {GoldMs: 1260000, PlatinumMs: 840000, ││ TitleMs: 630000}, // Shado-Pan Monastery ││ (21:00 / 14:00 / 10:30) ││ + 59: {GoldMs: 1050000, PlatinumMs: 735000, ││ TitleMs: 615000}, // Siege of Niuzao Temple ││ (17:30 / 12:15 / 10:15) ││ + 60: {GoldMs: 720000, PlatinumMs: 495000, T ││ itleMs: 405000}, // Mogu'shan Palace ││ (12:00 / 8:15 / 6:45) ││ + 76: {GoldMs: 1140000, PlatinumMs: 615000, ││ TitleMs: 435000}, // Scholomance ││ (19:00 / 10:15 / 7:15) ││ + 77: {GoldMs: 780000, PlatinumMs: 480000, T ││ itleMs: 255000}, // Scarlet Halls ││ (13:00 / 8:00 / 4:15) ││ + 78: {GoldMs: 780000, PlatinumMs: 540000, T ││ itleMs: 330000}, // Scarlet Monastery ││ (13:00 / 9:00 / 5:30) ││ +} ││ + ││ +// must clear all of them to qualify for 9/ ││ 9 ││ var totalDungeons = len(dungeonTimerThresho ││ lds) ││ ││ -// In-game cutoffs are lenient: the display ││ ed cutoff is the first second that ││ -// still earns the medal. e.g. a 5:30.800 r ││ un beats a "5:30" title cutoff, ││ -// because the timer reads "5:30" until it ││ ticks over to 5:31. So the actual ││ -// pass condition is `duration < threshold ││ + leniencyMs`. ││ +// in-game cutoffs are lenient: a 5:30.800 ││ run beats a "5:30" title cutoff ││ +// because the timer reads "5:30" until it ││ ticks to 5:31, so pass condition ││ +// is `duration < threshold + leniencyMs` ││ const leniencyMs int64 = 1000 ││ ││ -// beatsTier reports whether duration earns ││ at least the given threshold. ││ func beatsTier(duration, thresholdMs int64) ││ bool { ││ return duration < thresholdMs+leniencyMs ││ } ││ ││ -// Scopes emitted in the output. Order matt ││ ers for JSON readability. ││ +// order matters for JSON readability ││ type statsScopeDef struct { ││ Key string ││ SeasonNum int // 0 = all-time ││ @@ -277,13 +267,10 @@ func countTotalPlayers ││ (db *sql.DB, seasonNum int, region string) ( ││ int64, error) ││ return n, nil ││ } ││ ││ -// computeCompletionTiers loads each player ││ 's best time per (season, dungeon) and ││ -// classifies them against the gold / plati ││ num / title thresholds. ││ -// Tiers are achievements earned *within a ││ single season*, so for the all-time scope ││ -// we take the union of per-season qualifie ││ rs - a player counts if they hit 9/9 of a ││ -// tier in any single season, not by combin ││ ing best times across seasons. ││ -// Returns the tier counts and the count of ││ players who completed all 9 dungeons in ││ -// at least one season (the "completed" den ││ ominator). ││ +// tiers are achievements earned within a s ││ ingle season; for all-time scope ││ +// we take the union of per-season qualifie ││ rs (hit 9/9 of a tier in any one ││ +// season counts), not combine best times a ││ cross seasons. returns tier ││ +// counts plus the "completed" denominator ││ (any-season 9-dungeon finishers). ││ func computeCompletionTiers(db *sql.DB, sea ││ sonNum int, region string) (CompletionTiers, ││ int64, error) { ││ q := ` ││ SELECT rm.player_id, cr.season_id, cr.dun ││ geon_id, MIN(cr.duration) AS best_duration ││ @@ -322,7 +309,7 @@ func computeCompletionTi ││ ers(db *sql.DB, seasonNum int, region string ││ ) (Completio ││ } ││ threshold, ok := dungeonTimerThresholds[d ││ ungeonID] ││ if !ok { ││ - continue // unknown dungeon - skip silen ││ tly ││ + continue // unknown dungeon - skip silen ││ tly ││ } ││ k := key{playerID, seasonID} ││ c := per[k] ││ @@ -401,12 +388,14 @@ func withPercentiles(t ││ CompletionTier, totalPlayers, completedPlay ││ ers, allTimePl ││ } ││ ││ // computeSpecCounts returns the spec-distr ││ ibution charts: ││ -// all_runs: every run_member row in ││ scope ││ -// gold_runs: run_members where the r ││ un beat the gold threshold for its dungeon ││ -// platinum_runs: same for platinum ││ -// title_runs: same for title ││ -// top_50_runs: run_members where the r ││ un was top-50 in scope (globally team-filter ││ ed for ││ -// region=="global", regio ││ nally team-filtered for a specific region) ││ +// ││ +// all_runs: every run_member row in s ││ cope ││ +// gold_runs: run_members where the run ││ beat the gold threshold for its dungeon ││ +// platinum_runs: same for platinum ││ +// title_runs: same for title ││ +// top_50_runs: run_members where the run ││ was top-50 in scope (globally team-filtered ││ for ││ +// region=="global", regiona ││ lly team-filtered for a specific region) ││ +// ││ // Each bucket carries both `count` (slot o ││ ccurrences, includes spec stacking) ││ // and `runs_with_spec` (distinct run count ││ , capped at the bucket's total_runs). ││ func computeSpecCounts(db *sql.DB, seasonNu ││ m int, region string) (map[string]SpecCountB ││ ucket, error) { ││ @@ -454,9 +443,8 @@ func querySpecCountsForR ││ uns(db *sql.DB, seasonNum int, region string ││ ) (SpecCount ││ rClause, rArgs := regionClauseAndArg(regio ││ n, "r") ││ args := append(sArgs, rArgs...) ││ ││ - // One query grouped by (dungeon, spec) - ││ we derive both the per-dungeon ││ - // breakdown and the overall totals from t ││ he same result set, since each ││ - // challenge_run has a single dungeon_id ( ││ no double-counting across dungeons). ││ + // each challenge_run has a single dungeon ││ _id, so one (dungeon, spec) ││ + // grouped query gives us both the per-dun ││ geon and overall totals ││ q := ` ││ SELECT cr.dungeon_id, rm.spec_id, ││ COUNT(*) AS slot_count, ││ @@ -501,7 +489,7 @@ func querySpecCountsForR ││ uns(db *sql.DB, seasonNum int, region string ││ ) (SpecCount ││ return SpecCountBucket{}, err ││ } ││ ││ - // Per-dungeon distinct run totals AND ove ││ rall - one query, no scan loop tax. ││ + // per-dungeon and overall in one query ││ perDungeonTotals, overallTotal, err := que ││ ryRunCountsForRuns(db, seasonNum, region) ││ if err != nil { ││ return SpecCountBucket{}, err ││ @@ -805,16 +793,12 @@ func querySpecCountsFo ││ rTop50(db *sql.DB, seasonNum int, region str ││ ing) (SpecCoun ││ return bucket, nil ││ } ││ ││ -// specAgg holds in-memory aggregation stat ││ e when we can't count via SQL - ││ -// see querySpecCountsForTier. ││ +// in-memory aggregation when SQL counting ││ is awkward (see querySpecCountsForTier) ││ type specAgg struct { ││ count int64 ││ runs map[int64]struct{} ││ } ││ ││ -// specAggsToSlice converts a Go-side aggre ││ gation (slot count + run-id set per ││ -// spec) into a sorted slice - used by quer ││ ySpecCountsForTier where the ││ -// per-dungeon threshold makes a SQL-only a ││ ggregation awkward. ││ func specAggsToSlice(aggs map[int]*specAgg) ││ []SpecCountEntry { ││ out := make([]SpecCountEntry, 0, len(aggs) ││ ) ││ for specID, a := range aggs { │└──────────────────────────────────────────────┘
┌─ nix/pkgs/ookstats/src/internal/wow/specs.go ──────────────────────────────┐│ diff --git a/nix/pkgs/ookstats/src/internal/wow/specs.go b/nix/pkgs/ookstats/src/i ││ nternal/wow/specs.go ││ index 76cb7d4..60d3300 100644 ││ --- a/nix/pkgs/ookstats/src/internal/wow/specs.go ││ +++ b/nix/pkgs/ookstats/src/internal/wow/specs.go ││ @@ -141,10 +141,9 @@ func GetClassIDForSpec(specID int) (int, bool) { ││ return classID, ok ││ } ││ ││ -// PrimaryStat - the primary stat each spec gears for in MoP. Used by the gear ││ -// generator to (a) drop players whose equipped set's primary stat doesn't ││ -// match the spec they're tagged as, and (b) drop individual items whose ││ -// primary stat doesn't match. ││ +// the primary stat each spec gears for in MoP, used by the gear generator ││ +// to drop players (and individual items) whose primary stat doesn't match ││ +// the spec they're tagged as ││ type PrimaryStat string ││ ││ const ( ││ @@ -154,27 +153,27 @@ const ( ││ ) ││ ││ var specPrimaryStat = map[int]PrimaryStat{ ││ - // Death Knight - all str (Blood tank, Frost/Unholy dps) ││ + // death knight - all str ││ 250: PrimaryStatStr, 251: PrimaryStatStr, 252: PrimaryStatStr, ││ - // Druid - Balance/Resto int, Feral/Guardian agi ││ + // druid - Balance/Resto int, Feral/Guardian agi ││ 102: PrimaryStatInt, 103: PrimaryStatAgi, 104: PrimaryStatAgi, 105: PrimaryStatI ││ nt, ││ - // Hunter - all agi ││ + // hunter - all agi ││ 253: PrimaryStatAgi, 254: PrimaryStatAgi, 255: PrimaryStatAgi, ││ - // Mage - all int ││ + // mage - all int ││ 62: PrimaryStatInt, 63: PrimaryStatInt, 64: PrimaryStatInt, ││ - // Monk - Brewmaster/Windwalker agi, Mistweaver int ││ + // monk - Brewmaster/Windwalker agi, Mistweaver int ││ 268: PrimaryStatAgi, 269: PrimaryStatAgi, 270: PrimaryStatInt, ││ - // Paladin - Holy int, Prot/Ret str ││ + // paladin - Holy int, Prot/Ret str ││ 65: PrimaryStatInt, 66: PrimaryStatStr, 70: PrimaryStatStr, ││ - // Priest - all int ││ + // priest - all int ││ 256: PrimaryStatInt, 257: PrimaryStatInt, 258: PrimaryStatInt, ││ - // Rogue - all agi ││ + // rogue - all agi ││ 259: PrimaryStatAgi, 260: PrimaryStatAgi, 261: PrimaryStatAgi, ││ - // Shaman - Resto/Ele int, Enh agi ││ + // shaman - Resto/Ele int, Enh agi ││ 262: PrimaryStatInt, 263: PrimaryStatAgi, 264: PrimaryStatInt, ││ - // Warlock - all int ││ + // warlock - all int ││ 265: PrimaryStatInt, 266: PrimaryStatInt, 267: PrimaryStatInt, ││ - // Warrior - all str ││ + // warrior - all str ││ 71: PrimaryStatStr, 72: PrimaryStatStr, 73: PrimaryStatStr, ││ } ││ │└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...ats/src/internal/wow/specs.go ───┐│ diff --git a/nix/pkgs/ookstats/src/internal/ ││ wow/specs.go b/nix/pkgs/ookstats/src/interna ││ l/wow/specs.go ││ index 76cb7d4..60d3300 100644 ││ --- a/nix/pkgs/ookstats/src/internal/wow/spe ││ cs.go ││ +++ b/nix/pkgs/ookstats/src/internal/wow/spe ││ cs.go ││ @@ -141,10 +141,9 @@ func GetClassIDForSpec( ││ specID int) (int, bool) { ││ return classID, ok ││ } ││ ││ -// PrimaryStat - the primary stat each spec ││ gears for in MoP. Used by the gear ││ -// generator to (a) drop players whose equi ││ pped set's primary stat doesn't ││ -// match the spec they're tagged as, and (b ││ ) drop individual items whose ││ -// primary stat doesn't match. ││ +// the primary stat each spec gears for in ││ MoP, used by the gear generator ││ +// to drop players (and individual items) w ││ hose primary stat doesn't match ││ +// the spec they're tagged as ││ type PrimaryStat string ││ ││ const ( ││ @@ -154,27 +153,27 @@ const ( ││ ) ││ ││ var specPrimaryStat = map[int]PrimaryStat{ ││ - // Death Knight - all str (Blood tank, Fro ││ st/Unholy dps) ││ + // death knight - all str ││ 250: PrimaryStatStr, 251: PrimaryStatStr, ││ 252: PrimaryStatStr, ││ - // Druid - Balance/Resto int, Feral/Guardi ││ an agi ││ + // druid - Balance/Resto int, Feral/Guardi ││ an agi ││ 102: PrimaryStatInt, 103: PrimaryStatAgi, ││ 104: PrimaryStatAgi, 105: PrimaryStatInt, ││ - // Hunter - all agi ││ + // hunter - all agi ││ 253: PrimaryStatAgi, 254: PrimaryStatAgi, ││ 255: PrimaryStatAgi, ││ - // Mage - all int ││ + // mage - all int ││ 62: PrimaryStatInt, 63: PrimaryStatInt, 64 ││ : PrimaryStatInt, ││ - // Monk - Brewmaster/Windwalker agi, Mistw ││ eaver int ││ + // monk - Brewmaster/Windwalker agi, Mistw ││ eaver int ││ 268: PrimaryStatAgi, 269: PrimaryStatAgi, ││ 270: PrimaryStatInt, ││ - // Paladin - Holy int, Prot/Ret str ││ + // paladin - Holy int, Prot/Ret str ││ 65: PrimaryStatInt, 66: PrimaryStatStr, 70 ││ : PrimaryStatStr, ││ - // Priest - all int ││ + // priest - all int ││ 256: PrimaryStatInt, 257: PrimaryStatInt, ││ 258: PrimaryStatInt, ││ - // Rogue - all agi ││ + // rogue - all agi ││ 259: PrimaryStatAgi, 260: PrimaryStatAgi, ││ 261: PrimaryStatAgi, ││ - // Shaman - Resto/Ele int, Enh agi ││ + // shaman - Resto/Ele int, Enh agi ││ 262: PrimaryStatInt, 263: PrimaryStatAgi, ││ 264: PrimaryStatInt, ││ - // Warlock - all int ││ + // warlock - all int ││ 265: PrimaryStatInt, 266: PrimaryStatInt, ││ 267: PrimaryStatInt, ││ - // Warrior - all str ││ + // warrior - all str ││ 71: PrimaryStatStr, 72: PrimaryStatStr, 73 ││ : PrimaryStatStr, ││ } ││ │└──────────────────────────────────────────────┘