┌─ GO ───────────────────────────────────────────────────────────────────────┐
│ package pipeline │
│ │
│ import ( │
│ "encoding/json" │
│ "fmt" │
│ "sort" │
│ "strings" │
│ "sync" │
│ "time" │
│ │
│ "github.com/charmbracelet/log" │
│ "ookstats/internal/blizzard" │
│ "ookstats/internal/database" │
│ "ookstats/internal/playerid" │
│ "ookstats/internal/wow" │
│ ) │
│ │
│ // FingerprintOptions controls the fingerprint fetcher. │
│ type FingerprintOptions struct { │
│ Verbose bool │
│ BatchSize int │
│ MaxPlayers int │
│ } │
│ │
│ // FingerprintResult summarizes processing totals. │
│ type FingerprintResult struct { │
│ Processed int │
│ Created int │
│ Skipped int │
│ MarkedInvalid int │
│ Duration time.Duration │
│ } │
│ │
│ // BuildPlayerFingerprints fetches achievements for players missing fingerprints a │
│ nd stores them. │
│ func BuildPlayerFingerprints(db *database.DatabaseService, client *blizzard.Client │
│ , opts FingerprintOptions) (*FingerprintResult, error) { │
│ start := time.Now() │
│ batchSize := opts.BatchSize │
│ if batchSize <= 0 { │
│ batchSize = 25 │
│ } │
│ │
│ result := &FingerprintResult{} │
│ logger := log.With("component", "fingerprint") │
│ seen := make(map[int64]bool) │
│ totalRemaining, err := db.CountPlayersMissingFingerprints() │
│ var tracker *BatchTracker │
│ if err != nil { │
│ logger.Warn("unable to count players needing fingerprints", "error", err) │
│ tracker = NewBatchTracker(start, -1, opts.MaxPlayers) │
│ } else { │
│ tracker = NewBatchTracker(start, totalRemaining, opts.MaxPlayers) │
│ logger.Info("found players awaiting fingerprints", │
│ "count", totalRemaining, │
│ "target", tracker.DescribeTarget()) │
│ } │
│ │
│ logger.Info("loading existing fingerprints for collision detection") │
│ collisionMap, err := db.GetAllFingerprintHashes() │
│ if err != nil { │
│ return nil, fmt.Errorf("failed to load fingerprint collision map: %w", err) │
│ } │
│ logger.Info("loaded fingerprint collision map", "existing_fingerprints", len(coll │
│ isionMap)) │
│ │
│ batchNumber := 0 │
│ for { │
│ if tracker.ShouldStop(result.Processed) { │
│ break │
│ } │
│ │
│ currentBatchSize := tracker.AdjustBatchSize(batchSize, result.Processed) │
│ if currentBatchSize == 0 { │
│ break │
│ } │
│ │
│ candidates, err := db.GetPlayersNeedingFingerprints(currentBatchSize) │
│ if err != nil { │
│ return nil, err │
│ } │
│ if len(candidates) == 0 { │
│ break │
│ } │
│ │
│ batchCandidates := make([]database.PlayerFingerprintCandidate, 0, len(candidates │
│ )) │
│ scheduled := 0 │
│ for _, cand := range candidates { │
│ if seen[cand.PlayerID] { │
│ continue │
│ } │
│ if tracker.ShouldStop(result.Processed + scheduled) { │
│ break │
│ } │
│ seen[cand.PlayerID] = true │
│ batchCandidates = append(batchCandidates, cand) │
│ scheduled++ │
│ } │
│ if len(batchCandidates) == 0 { │
│ continue │
│ } │
│ │
│ logger.Info("processing fingerprint candidates", "batch", batchNumber+1, "batch_ │
│ size", len(batchCandidates)) │
│ batchNumber++ │
│ │
│ outcomes := make(chan fingerprintOutcome, len(batchCandidates)) │
│ var wg sync.WaitGroup │
│ for _, cand := range batchCandidates { │
│ wg.Add(1) │
│ go func(c database.PlayerFingerprintCandidate) { │
│ defer wg.Done() │
│ outcomes <- processFingerprintCandidate(db, client, c, collisionMap, logger) │
│ }(cand) │
│ } │
│ │
│ go func() { │
│ wg.Wait() │
│ close(outcomes) │
│ }() │
│ │
│ for oc := range outcomes { │
│ result.Processed++ │
│ if oc.err != nil { │
│ return nil, oc.err │
│ } │
│ if oc.statusChecked > 0 { │
│ if err := db.UpdatePlayerStatus(oc.playerID, oc.statusValid, oc.statusChecked, │
│ oc.charID); err != nil { │
│ return nil, fmt.Errorf("update player status %d: %w", oc.playerID, err) │
│ } │
│ } │
│ if oc.fingerprint != nil { │
│ if err := db.UpsertPlayerFingerprint(*oc.fingerprint); err != nil { │
│ return nil, err │
│ } │
│ collisionMap[oc.fingerprint.FingerprintHash] = oc.fingerprint.PlayerID │
│ } │
│ if len(oc.accountTuples) > 0 { │
│ tuplesJSON, err := json.Marshal(oc.accountTuples) │
│ if err != nil { │
│ return nil, fmt.Errorf("marshal account tuples for player %d: %w", oc.playerI │
│ D, err) │
│ } │
│ if err := db.UpsertPlayerAccountFingerprint(oc.playerID, oc.accountTuplesRegio │
│ n, string(tuplesJSON), nowMillis()); err != nil { │
│ return nil, fmt.Errorf("upsert account fingerprint for player %d: %w", oc.pla │
│ yerID, err) │
│ } │
│ } │
│ if oc.created { │
│ result.Created++ │
│ } │
│ if oc.skipped { │
│ result.Skipped++ │
│ } │
│ if oc.invalid { │
│ result.MarkedInvalid++ │
│ } │
│ } │
│ │
│ logBatchProgress(tracker, logger, result.Processed, batchNumber) │
│ } │
│ │
│ result.Duration = time.Since(start) │
│ return result, nil │
│ } │
│ │
│ func deriveClassID(c database.PlayerFingerprintCandidate) (int, bool) { │
│ if c.DetailsClassID.Valid && c.DetailsClassID.Int64 > 0 { │
│ return int(c.DetailsClassID.Int64), true │
│ } │
│ if c.LatestSpecID.Valid && c.LatestSpecID.Int64 > 0 { │
│ return wow.GetClassIDForSpec(int(c.LatestSpecID.Int64)) │
│ } │
│ return 0, false │
│ } │
│ │
│ func extractKeyTimestamps(resp *blizzard.CharacterAchievementsResponse) (int64, in │
│ t64, int64, error) { │
│ var level85, level90, earliestHeroic int64 │
│ │
│ for _, ach := range resp.Achievements { │
│ if ach.CompletedTimestamp == nil || !ach.Criteria.IsCompleted { │
│ continue │
│ } │
│ ts := *ach.CompletedTimestamp │
│ switch ach.ID { │
│ case playerid.Level85AchievementID: │
│ if level85 == 0 || ts < level85 { │
│ level85 = ts │
│ } │
│ case playerid.Level90AchievementID: │
│ if level90 == 0 || ts < level90 { │
│ level90 = ts │
│ } │
│ default: │
│ if isHeroicAchievement(ach.ID) { │
│ if earliestHeroic == 0 || ts < earliestHeroic { │
│ earliestHeroic = ts │
│ } │
│ } │
│ } │
│ } │
│ │
│ var missing []string │
│ if level85 == 0 { │
│ missing = append(missing, "level85") │
│ } │
│ if level90 == 0 { │
│ missing = append(missing, "level90") │
│ } │
│ if earliestHeroic == 0 { │
│ missing = append(missing, "heroic") │
│ } │
│ if len(missing) > 0 { │
│ return 0, 0, 0, fmt.Errorf("missing achievements: %s", strings.Join(missing, ", │
│ ")) │
│ } │
│ │
│ return level85, level90, earliestHeroic, nil │
│ } │
│ │
│ func isHeroicAchievement(id int) bool { │
│ idx := sort.SearchInts(playerid.HeroicDungeonAchievementIDs, id) │
│ return idx < len(playerid.HeroicDungeonAchievementIDs) && playerid.HeroicDungeonA │
│ chievementIDs[idx] == id │
│ } │
│ │
│ type fingerprintOutcome struct { │
│ playerID int64 │
│ fingerprint *database.PlayerFingerprint │
│ statusValid bool │
│ statusChecked int64 │
│ charID *int │
│ created bool │
│ skipped bool │
│ invalid bool │
│ err error │
│ // Trusted (id, ts) tuples extracted from the same achievements response │
│ // used for the character fingerprint. Persisted to player_account_fingerprint │
│ // alongside the fingerprint so a later `process accounts` pass can group │
│ // characters into accounts without re-fetching. │
│ accountTuples []playerid.TrustedTuple │
│ accountTuplesRegion string │
│ } │
│ │
│ func processFingerprintCandidate(db *database.DatabaseService, client *blizzard.Cl │
│ ient, cand database.PlayerFingerprintCandidate, collisionMap map[string]int64, log │
│ ger *log.Logger) fingerprintOutcome { │
│ out := fingerprintOutcome{playerID: cand.PlayerID} │
│ logger.Info("fingerprinting player", │
│ "player_id", cand.PlayerID, │
│ "name", cand.Name, │
│ "region", strings.ToUpper(cand.Region), │
│ "realm", cand.RealmSlug) │
│ classID, ok := deriveClassID(cand) │
│ if !ok { │
│ logger.Warn("skipping candidate - no class data, marking invalid", │
│ "player_id", cand.PlayerID) │
│ out.invalid = true │
│ out.statusValid = false │
│ out.statusChecked = nowMillis() │
│ return out │
│ } │
│ │
│ canonicalRealm := blizzard.NormalizeRealmSlug(cand.Region, cand.RealmSlug) │
│ statusResp, err := client.FetchCharacterStatus(cand.Name, canonicalRealm, cand.Re │
│ gion) │
│ if err != nil { │
│ if isNotFoundError(err) { │
│ logger.Warn("status 404, marking invalid", │
│ "player_id", cand.PlayerID, │
│ "name", cand.Name, │
│ "region", cand.Region, │
│ "realm", cand.RealmSlug) │
│ out.invalid = true │
│ out.statusValid = false │
│ out.statusChecked = nowMillis() │
│ return out │
│ } │
│ logger.Warn("status fetch error", │
│ "player_id", cand.PlayerID, │
│ "name", cand.Name, │
│ "region", cand.Region, │
│ "realm", cand.RealmSlug, │
│ "error", err) │
│ out.skipped = true │
│ return out │
│ } │
│ │
│ out.statusValid = statusResp.IsValid │
│ out.statusChecked = nowMillis() │
│ out.charID = extractCharacterID(statusResp) │
│ │
│ if !statusResp.IsValid { │
│ reason := statusResp.Reason │
│ if reason == "" { │
│ reason = "invalid" │
│ } │
│ logger.Warn("status invalid", │
│ "player_id", cand.PlayerID, │
│ "name", cand.Name, │
│ "region", cand.Region, │
│ "realm", cand.RealmSlug, │
│ "reason", reason) │
│ out.invalid = true │
│ return out │
│ } │
│ │
│ if slug := statusResp.Character.Realm.Slug; slug != "" { │
│ canonicalRealm = blizzard.NormalizeRealmSlug(cand.Region, slug) │
│ } │
│ if name := statusResp.Character.Name; strings.TrimSpace(name) != "" { │
│ cand.Name = name │
│ } │
│ │
│ resp, err := client.FetchCharacterAchievements(cand.Name, canonicalRealm, cand.Re │
│ gion) │
│ if err != nil { │
│ if isNotFoundError(err) { │
│ logger.Warn("achievements 404, marking invalid", │
│ "player_id", cand.PlayerID, │
│ "name", cand.Name, │
│ "region", cand.Region, │
│ "realm", cand.RealmSlug) │
│ out.invalid = true │
│ out.statusValid = false │
│ out.statusChecked = nowMillis() │
│ return out │
│ } │
│ logger.Warn("achievements fetch error", │
│ "player_id", cand.PlayerID, │
│ "name", cand.Name, │
│ "region", cand.Region, │
│ "realm", cand.RealmSlug, │
│ "error", err) │
│ out.skipped = true │
│ return out │
│ } │
│ │
│ level85, level90, heroic, err := extractKeyTimestamps(resp) │
│ if err != nil { │
│ logger.Warn("missing required achievements, marking invalid", │
│ "player_id", cand.PlayerID, │
│ "error", err) │
│ out.invalid = true │
│ out.statusValid = false │
│ out.statusChecked = nowMillis() │
│ return out │
│ } │
│ │
│ fpInput := playerid.FingerprintInput{ │
│ ClassID: classID, │
│ Level85Timestamp: level85, │
│ Level90Timestamp: level90, │
│ EarliestHeroicTimestamp: heroic, │
│ } │
│ hash, err := playerid.ComputeHash(fpInput) │
│ if err != nil { │
│ logger.Warn("failed to compute fingerprint", "player_id", cand.PlayerID, "error" │
│ , err) │
│ out.skipped = true │
│ return out │
│ } │
│ │
│ existing := collisionMap[hash] │
│ if existing != 0 && existing != cand.PlayerID { │
│ // Merge: the candidate (cand) is the newer player, existing is the old one │
│ // Migrate runs FROM old (existing) TO new (cand) │
│ runsMigrated, err := db.MigratePlayerRuns(existing, cand.PlayerID) │
│ if err != nil { │
│ out.err = fmt.Errorf("failed to migrate runs: %w", err) │
│ return out │
│ } │
│ │
│ // Mark the old player as invalid │
│ if err := db.UpdatePlayerStatus(existing, false, nowMillis(), nil); err != nil { │
│ logger.Warn("failed to invalidate old player", │
│ "player_id", existing, │
│ "error", err) │
│ } │
│ │
│ // Delete old player's fingerprint so the new one becomes canonical │
│ if err := db.DeletePlayerFingerprint(existing); err != nil { │
│ logger.Warn("failed to delete old fingerprint", │
│ "player_id", existing, │
│ "error", err) │
│ } │
│ │
│ // Update collision map to point to the new canonical player │
│ collisionMap[hash] = cand.PlayerID │
│ │
│ logger.Info("merged player identity (old ? new)", │
│ "old_id", existing, │
│ "new_id", cand.PlayerID, │
│ "runs_migrated", runsMigrated, │
│ "hash", hash[:16]) │
│ │
│ // Continue processing to create fingerprint for the new canonical player │
│ } │
│ │
│ now := nowMillis() │
│ firstRun := cand.FirstRunTimestamp.Int64 │
│ if !cand.FirstRunTimestamp.Valid || firstRun == 0 { │
│ firstRun = now │
│ } │
│ lastRun := cand.LastRunTimestamp.Int64 │
│ if !cand.LastRunTimestamp.Valid || lastRun == 0 { │
│ lastRun = now │
│ } │
│ │
│ lastSeenName := resp.Character.Name │
│ if lastSeenName == "" { │
│ lastSeenName = cand.Name │
│ } │
│ lastSeenRealm := resp.Character.Realm.Slug │
│ if lastSeenRealm == "" { │
│ lastSeenRealm = canonicalRealm │
│ } │
│ │
│ out.fingerprint = &database.PlayerFingerprint{ │
│ PlayerID: cand.PlayerID, │
│ FingerprintHash: hash, │
│ ClassID: classID, │
│ Level85Timestamp: level85, │
│ Level90Timestamp: level90, │
│ EarliestHeroicTimestamp: heroic, │
│ LastSeenName: lastSeenName, │
│ LastSeenRealmSlug: lastSeenRealm, │
│ LastSeenTimestamp: lastRun, │
│ FirstRunTimestamp: firstRun, │
│ CreatedAt: now, │
│ } │
│ out.accountTuples = playerid.ExtractTrustedTuples(resp) │
│ out.accountTuplesRegion = cand.Region │
│ out.created = true │
│ │
│ logger.Info("fingerprint ready", │
│ "player_id", cand.PlayerID, │
│ "hash", hash[:12], │
│ "class_id", classID, │
│ "heroic_ts", heroic) │
│ return out │
│ } │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ GO ─────────────────────────────────┐
│ package pipeline │
│ │
│ import ( │
│ "encoding/json" │
│ "fmt" │
│ "sort" │
│ "strings" │
│ "sync" │
│ "time" │
│ │
│ "github.com/charmbracelet/log" │
│ "ookstats/internal/blizzard" │
│ "ookstats/internal/database" │
│ "ookstats/internal/playerid" │
│ "ookstats/internal/wow" │
│ ) │
│ │
│ // FingerprintOptions controls the fingerpri │
│ nt fetcher. │
│ type FingerprintOptions struct { │
│ Verbose bool │
│ BatchSize int │
│ MaxPlayers int │
│ } │
│ │
│ // FingerprintResult summarizes processing t │
│ otals. │
│ type FingerprintResult struct { │
│ Processed int │
│ Created int │
│ Skipped int │
│ MarkedInvalid int │
│ Duration time.Duration │
│ } │
│ │
│ // BuildPlayerFingerprints fetches achieveme │
│ nts for players missing fingerprints and sto │
│ res them. │
│ func BuildPlayerFingerprints(db *database.Da │
│ tabaseService, client *blizzard.Client, opts │
│ FingerprintOptions) (*FingerprintResult, er │
│ ror) { │
│ start := time.Now() │
│ batchSize := opts.BatchSize │
│ if batchSize <= 0 { │
│ batchSize = 25 │
│ } │
│ │
│ result := &FingerprintResult{} │
│ logger := log.With("component", "fingerprin │
│ t") │
│ seen := make(map[int64]bool) │
│ totalRemaining, err := db.CountPlayersMissi │
│ ngFingerprints() │
│ var tracker *BatchTracker │
│ if err != nil { │
│ logger.Warn("unable to count players needi │
│ ng fingerprints", "error", err) │
│ tracker = NewBatchTracker(start, -1, opts. │
│ MaxPlayers) │
│ } else { │
│ tracker = NewBatchTracker(start, totalRema │
│ ining, opts.MaxPlayers) │
│ logger.Info("found players awaiting finger │
│ prints", │
│ "count", totalRemaining, │
│ "target", tracker.DescribeTarget()) │
│ } │
│ │
│ logger.Info("loading existing fingerprints │
│ for collision detection") │
│ collisionMap, err := db.GetAllFingerprintHa │
│ shes() │
│ if err != nil { │
│ return nil, fmt.Errorf("failed to load fin │
│ gerprint collision map: %w", err) │
│ } │
│ logger.Info("loaded fingerprint collision m │
│ ap", "existing_fingerprints", len(collisionM │
│ ap)) │
│ │
│ batchNumber := 0 │
│ for { │
│ if tracker.ShouldStop(result.Processed) { │
│ break │
│ } │
│ │
│ currentBatchSize := tracker.AdjustBatchSiz │
│ e(batchSize, result.Processed) │
│ if currentBatchSize == 0 { │
│ break │
│ } │
│ │
│ candidates, err := db.GetPlayersNeedingFin │
│ gerprints(currentBatchSize) │
│ if err != nil { │
│ return nil, err │
│ } │
│ if len(candidates) == 0 { │
│ break │
│ } │
│ │
│ batchCandidates := make([]database.PlayerF │
│ ingerprintCandidate, 0, len(candidates)) │
│ scheduled := 0 │
│ for _, cand := range candidates { │
│ if seen[cand.PlayerID] { │
│ continue │
│ } │
│ if tracker.ShouldStop(result.Processed + │
│ scheduled) { │
│ break │
│ } │
│ seen[cand.PlayerID] = true │
│ batchCandidates = append(batchCandidates, │
│ cand) │
│ scheduled++ │
│ } │
│ if len(batchCandidates) == 0 { │
│ continue │
│ } │
│ │
│ logger.Info("processing fingerprint candid │
│ ates", "batch", batchNumber+1, "batch_size", │
│ len(batchCandidates)) │
│ batchNumber++ │
│ │
│ outcomes := make(chan fingerprintOutcome, │
│ len(batchCandidates)) │
│ var wg sync.WaitGroup │
│ for _, cand := range batchCandidates { │
│ wg.Add(1) │
│ go func(c database.PlayerFingerprintCandi │
│ date) { │
│ defer wg.Done() │
│ outcomes <- processFingerprintCandidate( │
│ db, client, c, collisionMap, logger) │
│ }(cand) │
│ } │
│ │
│ go func() { │
│ wg.Wait() │
│ close(outcomes) │
│ }() │
│ │
│ for oc := range outcomes { │
│ result.Processed++ │
│ if oc.err != nil { │
│ return nil, oc.err │
│ } │
│ if oc.statusChecked > 0 { │
│ if err := db.UpdatePlayerStatus(oc.playe │
│ rID, oc.statusValid, oc.statusChecked, oc.ch │
│ arID); err != nil { │
│ return nil, fmt.Errorf("update player s │
│ tatus %d: %w", oc.playerID, err) │
│ } │
│ } │
│ if oc.fingerprint != nil { │
│ if err := db.UpsertPlayerFingerprint(*oc │
│ .fingerprint); err != nil { │
│ return nil, err │
│ } │
│ collisionMap[oc.fingerprint.FingerprintH │
│ ash] = oc.fingerprint.PlayerID │
│ } │
│ if len(oc.accountTuples) > 0 { │
│ tuplesJSON, err := json.Marshal(oc.accou │
│ ntTuples) │
│ if err != nil { │
│ return nil, fmt.Errorf("marshal account │
│ tuples for player %d: %w", oc.playerID, err │
│ ) │
│ } │
│ if err := db.UpsertPlayerAccountFingerpr │
│ int(oc.playerID, oc.accountTuplesRegion, str │
│ ing(tuplesJSON), nowMillis()); err != nil { │
│ return nil, fmt.Errorf("upsert account │
│ fingerprint for player %d: %w", oc.playerID, │
│ err) │
│ } │
│ } │
│ if oc.created { │
│ result.Created++ │
│ } │
│ if oc.skipped { │
│ result.Skipped++ │
│ } │
│ if oc.invalid { │
│ result.MarkedInvalid++ │
│ } │
│ } │
│ │
│ logBatchProgress(tracker, logger, result.P │
│ rocessed, batchNumber) │
│ } │
│ │
│ result.Duration = time.Since(start) │
│ return result, nil │
│ } │
│ │
│ func deriveClassID(c database.PlayerFingerpr │
│ intCandidate) (int, bool) { │
│ if c.DetailsClassID.Valid && c.DetailsClass │
│ ID.Int64 > 0 { │
│ return int(c.DetailsClassID.Int64), true │
│ } │
│ if c.LatestSpecID.Valid && c.LatestSpecID.I │
│ nt64 > 0 { │
│ return wow.GetClassIDForSpec(int(c.LatestS │
│ pecID.Int64)) │
│ } │
│ return 0, false │
│ } │
│ │
│ func extractKeyTimestamps(resp *blizzard.Cha │
│ racterAchievementsResponse) (int64, int64, i │
│ nt64, error) { │
│ var level85, level90, earliestHeroic int64 │
│ │
│ for _, ach := range resp.Achievements { │
│ if ach.CompletedTimestamp == nil || !ach.C │
│ riteria.IsCompleted { │
│ continue │
│ } │
│ ts := *ach.CompletedTimestamp │
│ switch ach.ID { │
│ case playerid.Level85AchievementID: │
│ if level85 == 0 || ts < level85 { │
│ level85 = ts │
│ } │
│ case playerid.Level90AchievementID: │
│ if level90 == 0 || ts < level90 { │
│ level90 = ts │
│ } │
│ default: │
│ if isHeroicAchievement(ach.ID) { │
│ if earliestHeroic == 0 || ts < earliestH │
│ eroic { │
│ earliestHeroic = ts │
│ } │
│ } │
│ } │
│ } │
│ │
│ var missing []string │
│ if level85 == 0 { │
│ missing = append(missing, "level85") │
│ } │
│ if level90 == 0 { │
│ missing = append(missing, "level90") │
│ } │
│ if earliestHeroic == 0 { │
│ missing = append(missing, "heroic") │
│ } │
│ if len(missing) > 0 { │
│ return 0, 0, 0, fmt.Errorf("missing achiev │
│ ements: %s", strings.Join(missing, ", ")) │
│ } │
│ │
│ return level85, level90, earliestHeroic, ni │
│ l │
│ } │
│ │
│ func isHeroicAchievement(id int) bool { │
│ idx := sort.SearchInts(playerid.HeroicDunge │
│ onAchievementIDs, id) │
│ return idx < len(playerid.HeroicDungeonAchi │
│ evementIDs) && playerid.HeroicDungeonAchieve │
│ mentIDs[idx] == id │
│ } │
│ │
│ type fingerprintOutcome struct { │
│ playerID int64 │
│ fingerprint *database.PlayerFingerprint │
│ statusValid bool │
│ statusChecked int64 │
│ charID *int │
│ created bool │
│ skipped bool │
│ invalid bool │
│ err error │
│ // Trusted (id, ts) tuples extracted from t │
│ he same achievements response │
│ // used for the character fingerprint. Pers │
│ isted to player_account_fingerprint │
│ // alongside the fingerprint so a later `pr │
│ ocess accounts` pass can group │
│ // characters into accounts without re-fetc │
│ hing. │
│ accountTuples []playerid.TrustedTuple │
│ accountTuplesRegion string │
│ } │
│ │
│ func processFingerprintCandidate(db *databas │
│ e.DatabaseService, client *blizzard.Client, │
│ cand database.PlayerFingerprintCandidate, co │
│ llisionMap map[string]int64, logger *log.Log │
│ ger) fingerprintOutcome { │
│ out := fingerprintOutcome{playerID: cand.Pl │
│ ayerID} │
│ logger.Info("fingerprinting player", │
│ "player_id", cand.PlayerID, │
│ "name", cand.Name, │
│ "region", strings.ToUpper(cand.Region), │
│ "realm", cand.RealmSlug) │
│ classID, ok := deriveClassID(cand) │
│ if !ok { │
│ logger.Warn("skipping candidate - no class │
│ data, marking invalid", │
│ "player_id", cand.PlayerID) │
│ out.invalid = true │
│ out.statusValid = false │
│ out.statusChecked = nowMillis() │
│ return out │
│ } │
│ │
│ canonicalRealm := blizzard.NormalizeRealmSl │
│ ug(cand.Region, cand.RealmSlug) │
│ statusResp, err := client.FetchCharacterSta │
│ tus(cand.Name, canonicalRealm, cand.Region) │
│ if err != nil { │
│ if isNotFoundError(err) { │
│ logger.Warn("status 404, marking invalid" │
│ , │
│ "player_id", cand.PlayerID, │
│ "name", cand.Name, │
│ "region", cand.Region, │
│ "realm", cand.RealmSlug) │
│ out.invalid = true │
│ out.statusValid = false │
│ out.statusChecked = nowMillis() │
│ return out │
│ } │
│ logger.Warn("status fetch error", │
│ "player_id", cand.PlayerID, │
│ "name", cand.Name, │
│ "region", cand.Region, │
│ "realm", cand.RealmSlug, │
│ "error", err) │
│ out.skipped = true │
│ return out │
│ } │
│ │
│ out.statusValid = statusResp.IsValid │
│ out.statusChecked = nowMillis() │
│ out.charID = extractCharacterID(statusResp) │
│ │
│ if !statusResp.IsValid { │
│ reason := statusResp.Reason │
│ if reason == "" { │
│ reason = "invalid" │
│ } │
│ logger.Warn("status invalid", │
│ "player_id", cand.PlayerID, │
│ "name", cand.Name, │
│ "region", cand.Region, │
│ "realm", cand.RealmSlug, │
│ "reason", reason) │
│ out.invalid = true │
│ return out │
│ } │
│ │
│ if slug := statusResp.Character.Realm.Slug; │
│ slug != "" { │
│ canonicalRealm = blizzard.NormalizeRealmSl │
│ ug(cand.Region, slug) │
│ } │
│ if name := statusResp.Character.Name; strin │
│ gs.TrimSpace(name) != "" { │
│ cand.Name = name │
│ } │
│ │
│ resp, err := client.FetchCharacterAchieveme │
│ nts(cand.Name, canonicalRealm, cand.Region) │
│ if err != nil { │
│ if isNotFoundError(err) { │
│ logger.Warn("achievements 404, marking in │
│ valid", │
│ "player_id", cand.PlayerID, │
│ "name", cand.Name, │
│ "region", cand.Region, │
│ "realm", cand.RealmSlug) │
│ out.invalid = true │
│ out.statusValid = false │
│ out.statusChecked = nowMillis() │
│ return out │
│ } │
│ logger.Warn("achievements fetch error", │
│ "player_id", cand.PlayerID, │
│ "name", cand.Name, │
│ "region", cand.Region, │
│ "realm", cand.RealmSlug, │
│ "error", err) │
│ out.skipped = true │
│ return out │
│ } │
│ │
│ level85, level90, heroic, err := extractKey │
│ Timestamps(resp) │
│ if err != nil { │
│ logger.Warn("missing required achievements │
│ , marking invalid", │
│ "player_id", cand.PlayerID, │
│ "error", err) │
│ out.invalid = true │
│ out.statusValid = false │
│ out.statusChecked = nowMillis() │
│ return out │
│ } │
│ │
│ fpInput := playerid.FingerprintInput{ │
│ ClassID: classID, │
│ Level85Timestamp: level85, │
│ Level90Timestamp: level90, │
│ EarliestHeroicTimestamp: heroic, │
│ } │
│ hash, err := playerid.ComputeHash(fpInput) │
│ if err != nil { │
│ logger.Warn("failed to compute fingerprint │
│ ", "player_id", cand.PlayerID, "error", err) │
│ out.skipped = true │
│ return out │
│ } │
│ │
│ existing := collisionMap[hash] │
│ if existing != 0 && existing != cand.Player │
│ ID { │
│ // Merge: the candidate (cand) is the newe │
│ r player, existing is the old one │
│ // Migrate runs FROM old (existing) TO new │
│ (cand) │
│ runsMigrated, err := db.MigratePlayerRuns( │
│ existing, cand.PlayerID) │
│ if err != nil { │
│ out.err = fmt.Errorf("failed to migrate r │
│ uns: %w", err) │
│ return out │
│ } │
│ │
│ // Mark the old player as invalid │
│ if err := db.UpdatePlayerStatus(existing, │
│ false, nowMillis(), nil); err != nil { │
│ logger.Warn("failed to invalidate old pla │
│ yer", │
│ "player_id", existing, │
│ "error", err) │
│ } │
│ │
│ // Delete old player's fingerprint so the │
│ new one becomes canonical │
│ if err := db.DeletePlayerFingerprint(exist │
│ ing); err != nil { │
│ logger.Warn("failed to delete old fingerp │
│ rint", │
│ "player_id", existing, │
│ "error", err) │
│ } │
│ │
│ // Update collision map to point to the ne │
│ w canonical player │
│ collisionMap[hash] = cand.PlayerID │
│ │
│ logger.Info("merged player identity (old ? │
│ new)", │
│ "old_id", existing, │
│ "new_id", cand.PlayerID, │
│ "runs_migrated", runsMigrated, │
│ "hash", hash[:16]) │
│ │
│ // Continue processing to create fingerpri │
│ nt for the new canonical player │
│ } │
│ │
│ now := nowMillis() │
│ firstRun := cand.FirstRunTimestamp.Int64 │
│ if !cand.FirstRunTimestamp.Valid || firstRu │
│ n == 0 { │
│ firstRun = now │
│ } │
│ lastRun := cand.LastRunTimestamp.Int64 │
│ if !cand.LastRunTimestamp.Valid || lastRun │
│ == 0 { │
│ lastRun = now │
│ } │
│ │
│ lastSeenName := resp.Character.Name │
│ if lastSeenName == "" { │
│ lastSeenName = cand.Name │
│ } │
│ lastSeenRealm := resp.Character.Realm.Slug │
│ if lastSeenRealm == "" { │
│ lastSeenRealm = canonicalRealm │
│ } │
│ │
│ out.fingerprint = &database.PlayerFingerpri │
│ nt{ │
│ PlayerID: cand.PlayerID, │
│ FingerprintHash: hash, │
│ ClassID: classID, │
│ Level85Timestamp: level85, │
│ Level90Timestamp: level90, │
│ EarliestHeroicTimestamp: heroic, │
│ LastSeenName: lastSeenName, │
│ LastSeenRealmSlug: lastSeenRealm, │
│ LastSeenTimestamp: lastRun, │
│ FirstRunTimestamp: firstRun, │
│ CreatedAt: now, │
│ } │
│ out.accountTuples = playerid.ExtractTrusted │
│ Tuples(resp) │
│ out.accountTuplesRegion = cand.Region │
│ out.created = true │
│ │
│ logger.Info("fingerprint ready", │
│ "player_id", cand.PlayerID, │
│ "hash", hash[:12], │
│ "class_id", classID, │
│ "heroic_ts", heroic) │
│ return out │
│ } │
└──────────────────────────────────────────────┘