OOKNET                             [ /  search the index  ]  
──────────────────────────────────────────────────────────────────────────────────────
══════════════════════════════════════════════════════════════════════════════════════
OOKNET   [ /  search  ]  
────────────────────────────────────────────────
════════════════════════════════════════════════
 
HASH      70050eb88479
DATE      2026-01-13
SUBJECT   ookstats: fix fingerprinting to account for faction changes
FILES     2 CHANGED
HASH      70050eb88479
DATE      2026-01-13
SUBJECT   ookstats: fix fingerprinting to
          account for faction changes
FILES     2 CHANGED
 

diff --git a/nix/pkgs/ookstats/src/internal/database/operations.go b/nix/pkgs/ooks
tats/src/internal/database/operations.go
index 227c093..9b79a32 100644
--- a/nix/pkgs/ookstats/src/internal/database/operations.go
+++ b/nix/pkgs/ookstats/src/internal/database/operations.go
@@ -256,7 +256,30 @@ func (ds *DatabaseService) InsertLeaderboardData(leaderboard 
*blizzard.Leaderboa
 				playerRealmID = realmID // Fallback to run realm
 			}
 
-			// insert or ignore player
+			// check if player with same name+realm already exists (handles faction transf
ers where Blizzard ID changes)
+			existingPlayerID, err := getExistingPlayerIDTx(tx, playerName, playerRealmID)
+			if err != nil {
+				return 0, 0, fmt.Errorf("failed to check existing player: %w", err)
+			}
+
+			// always use the NEW Blizzard ID as canonical
+			effectivePlayerID := playerID
+
+			if existingPlayerID != 0 && existingPlayerID != int64(playerID) {
+				// player exists with different ID (likely faction transfer)
+				// migrate old runs to the new player ID and mark old player invalid
+				_, err := tx.Exec(`UPDATE run_members SET player_id = ? WHERE player_id = ?`,
 playerID, existingPlayerID)
+				if err != nil {
+					return 0, 0, fmt.Errorf("failed to migrate runs to new player ID: %w", err)
+				}
+				// mark old player as invalid
+				_, err = tx.Exec(`UPDATE players SET is_valid = 0 WHERE id = ?`, existingPlay
erID)
+				if err != nil {
+					return 0, 0, fmt.Errorf("failed to invalidate old player: %w", err)
+				}
+			}
+
+			// insert/update player with the new ID
 			playerQuery := `
 				INSERT INTO players (id, blizzard_character_id, name, name_lower, realm_id)
 				VALUES (?, ?, ?, lower(?), ?)
@@ -301,7 +324,7 @@ func (ds *DatabaseService) InsertLeaderboardData(leaderboard *
blizzard.Leaderboa
 				factionPtr = &faction
 			}
 
-			_, err = tx.Exec(memberQuery, runID, playerID, specPtr, factionPtr)
+			_, err = tx.Exec(memberQuery, runID, effectivePlayerID, specPtr, factionPtr)
 			if err != nil {
 				return 0, 0, fmt.Errorf("failed to insert run member: %w", err)
 			}
@@ -722,7 +745,30 @@ func (ds *DatabaseService) insertLeaderboardDataTx(tx *sql.Tx
, leaderboard *bliz
 				playerRealmID = realmID
 			}
 
-			// insert player
+			// check if player with same name+realm already exists (handles faction transf
ers where Blizzard ID changes)
+			existingPlayerID, err := getExistingPlayerIDTx(tx, playerName, playerRealmID)
+			if err != nil {
+				return 0, 0, fmt.Errorf("failed to check existing player: %w", err)
+			}
+
+			// always use the NEW Blizzard ID as canonical
+			effectivePlayerID := playerID
+
+			if existingPlayerID != 0 && existingPlayerID != int64(playerID) {
+				// player exists with different ID (likely faction transfer)
+				// migrate old runs to the new player ID and mark old player invalid
+				_, err := tx.Exec(`UPDATE run_members SET player_id = ? WHERE player_id = ?`,
 playerID, existingPlayerID)
+				if err != nil {
+					return 0, 0, fmt.Errorf("failed to migrate runs to new player ID: %w", err)
+				}
+				// mark old player as invalid
+				_, err = tx.Exec(`UPDATE players SET is_valid = 0 WHERE id = ?`, existingPlay
erID)
+				if err != nil {
+					return 0, 0, fmt.Errorf("failed to invalidate old player: %w", err)
+				}
+			}
+
+			// insert/update player with the new ID
 			playerQuery := `
 				INSERT INTO players (id, name, name_lower, realm_id)
 				VALUES (?, ?, lower(?), ?)
@@ -761,7 +807,7 @@ func (ds *DatabaseService) insertLeaderboardDataTx(tx *sql.Tx,
 leaderboard *bliz
 				factionPtr = &faction
 			}
 
-			_, err = tx.Exec(memberQuery, runID, playerID, specPtr, factionPtr)
+			_, err = tx.Exec(memberQuery, runID, effectivePlayerID, specPtr, factionPtr)
 			if err != nil {
 				return 0, 0, fmt.Errorf("failed to insert run member: %w", err)
 			}
@@ -1875,6 +1921,14 @@ func (ds *DatabaseService) GetAllFingerprintHashes() (map[s
tring]int64, error) {
 	return result, rows.Err()
 }
 
+// DeletePlayerFingerprint removes a player's fingerprint record
+func (ds *DatabaseService) DeletePlayerFingerprint(playerID int64) error {
+	return retryOnBusy(func() error {
+		_, err := ds.db.Exec(`DELETE FROM player_fingerprints WHERE player_id = ?`, pla
yerID)
+		return err
+	})
+}
+
 // MigratePlayerRuns updates all run_members records from one player to another
 func (ds *DatabaseService) MigratePlayerRuns(fromPlayerID, toPlayerID int64) (int
, error) {
 	var rowsAffected int
@@ -1930,3 +1984,22 @@ func (ds *DatabaseService) GetPlayerByNameRealmRegion(name,
 realmSlug, region st
 	}
 	return playerID, nil
 }
+
+// getExistingPlayerIDTx looks up an existing player by name+realm within a trans
action.
+// Returns 0 if no player found (not an error).
+func getExistingPlayerIDTx(tx *sql.Tx, name string, realmID int) (int64, error) {
+	var playerID int64
+	err := tx.QueryRow(`
+		SELECT id FROM players
+		WHERE name_lower = LOWER(?) AND realm_id = ?
+		LIMIT 1
+	`, name, realmID).Scan(&playerID)
+
+	if err == sql.ErrNoRows {
+		return 0, nil
+	}
+	if err != nil {
+		return 0, err
+	}
+	return playerID, nil
+}

diff --git a/nix/pkgs/ookstats/src/internal/
database/operations.go b/nix/pkgs/ookstats/s
rc/internal/database/operations.go
index 227c093..9b79a32 100644
--- a/nix/pkgs/ookstats/src/internal/databas
e/operations.go
+++ b/nix/pkgs/ookstats/src/internal/databas
e/operations.go
@@ -256,7 +256,30 @@ func (ds *DatabaseServi
ce) InsertLeaderboardData(leaderboard *blizz
ard.Leaderboa
 				playerRealmID = realmID // Fallback to 
run realm
 			}
 
-			// insert or ignore player
+			// check if player with same name+realm 
already exists (handles faction transfers wh
ere Blizzard ID changes)
+			existingPlayerID, err := getExistingPlay
erIDTx(tx, playerName, playerRealmID)
+			if err != nil {
+				return 0, 0, fmt.Errorf("failed to chec
k existing player: %w", err)
+			}
+
+			// always use the NEW Blizzard ID as can
onical
+			effectivePlayerID := playerID
+
+			if existingPlayerID != 0 && existingPlay
erID != int64(playerID) {
+				// player exists with different ID (lik
ely faction transfer)
+				// migrate old runs to the new player I
D and mark old player invalid
+				_, err := tx.Exec(`UPDATE run_members S
ET player_id = ? WHERE player_id = ?`, playe
rID, existingPlayerID)
+				if err != nil {
+					return 0, 0, fmt.Errorf("failed to mig
rate runs to new player ID: %w", err)
+				}
+				// mark old player as invalid
+				_, err = tx.Exec(`UPDATE players SET is
_valid = 0 WHERE id = ?`, existingPlayerID)
+				if err != nil {
+					return 0, 0, fmt.Errorf("failed to inv
alidate old player: %w", err)
+				}
+			}
+
+			// insert/update player with the new ID
 			playerQuery := `
 				INSERT INTO players (id, blizzard_chara
cter_id, name, name_lower, realm_id)
 				VALUES (?, ?, ?, lower(?), ?)
@@ -301,7 +324,7 @@ func (ds *DatabaseServic
e) InsertLeaderboardData(leaderboard *blizza
rd.Leaderboa
 				factionPtr = &faction
 			}
 
-			_, err = tx.Exec(memberQuery, runID, pla
yerID, specPtr, factionPtr)
+			_, err = tx.Exec(memberQuery, runID, eff
ectivePlayerID, specPtr, factionPtr)
 			if err != nil {
 				return 0, 0, fmt.Errorf("failed to inse
rt run member: %w", err)
 			}
@@ -722,7 +745,30 @@ func (ds *DatabaseServi
ce) insertLeaderboardDataTx(tx *sql.Tx, lead
erboard *bliz
 				playerRealmID = realmID
 			}
 
-			// insert player
+			// check if player with same name+realm 
already exists (handles faction transfers wh
ere Blizzard ID changes)
+			existingPlayerID, err := getExistingPlay
erIDTx(tx, playerName, playerRealmID)
+			if err != nil {
+				return 0, 0, fmt.Errorf("failed to chec
k existing player: %w", err)
+			}
+
+			// always use the NEW Blizzard ID as can
onical
+			effectivePlayerID := playerID
+
+			if existingPlayerID != 0 && existingPlay
erID != int64(playerID) {
+				// player exists with different ID (lik
ely faction transfer)
+				// migrate old runs to the new player I
D and mark old player invalid
+				_, err := tx.Exec(`UPDATE run_members S
ET player_id = ? WHERE player_id = ?`, playe
rID, existingPlayerID)
+				if err != nil {
+					return 0, 0, fmt.Errorf("failed to mig
rate runs to new player ID: %w", err)
+				}
+				// mark old player as invalid
+				_, err = tx.Exec(`UPDATE players SET is
_valid = 0 WHERE id = ?`, existingPlayerID)
+				if err != nil {
+					return 0, 0, fmt.Errorf("failed to inv
alidate old player: %w", err)
+				}
+			}
+
+			// insert/update player with the new ID
 			playerQuery := `
 				INSERT INTO players (id, name, name_low
er, realm_id)
 				VALUES (?, ?, lower(?), ?)
@@ -761,7 +807,7 @@ func (ds *DatabaseServic
e) insertLeaderboardDataTx(tx *sql.Tx, leade
rboard *bliz
 				factionPtr = &faction
 			}
 
-			_, err = tx.Exec(memberQuery, runID, pla
yerID, specPtr, factionPtr)
+			_, err = tx.Exec(memberQuery, runID, eff
ectivePlayerID, specPtr, factionPtr)
 			if err != nil {
 				return 0, 0, fmt.Errorf("failed to inse
rt run member: %w", err)
 			}
@@ -1875,6 +1921,14 @@ func (ds *DatabaseSer
vice) GetAllFingerprintHashes() (map[string]
int64, error) {
 	return result, rows.Err()
 }
 
+// DeletePlayerFingerprint removes a player
's fingerprint record
+func (ds *DatabaseService) DeletePlayerFing
erprint(playerID int64) error {
+	return retryOnBusy(func() error {
+		_, err := ds.db.Exec(`DELETE FROM player_
fingerprints WHERE player_id = ?`, playerID)
+		return err
+	})
+}
+
 // MigratePlayerRuns updates all run_member
s records from one player to another
 func (ds *DatabaseService) MigratePlayerRun
s(fromPlayerID, toPlayerID int64) (int, erro
r) {
 	var rowsAffected int
@@ -1930,3 +1984,22 @@ func (ds *DatabaseSer
vice) GetPlayerByNameRealmRegion(name, realm
Slug, region st
 	}
 	return playerID, nil
 }
+
+// getExistingPlayerIDTx looks up an existi
ng player by name+realm within a transaction
.
+// Returns 0 if no player found (not an err
or).
+func getExistingPlayerIDTx(tx *sql.Tx, name
 string, realmID int) (int64, error) {
+	var playerID int64
+	err := tx.QueryRow(`
+		SELECT id FROM players
+		WHERE name_lower = LOWER(?) AND realm_id 
= ?
+		LIMIT 1
+	`, name, realmID).Scan(&playerID)
+
+	if err == sql.ErrNoRows {
+		return 0, nil
+	}
+	if err != nil {
+		return 0, err
+	}
+	return playerID, nil
+}
 

diff --git a/nix/pkgs/ookstats/src/internal/pipeline/fingerprints.go b/nix/pkgs/oo
kstats/src/internal/pipeline/fingerprints.go
index f699aab..c71597f 100644
--- a/nix/pkgs/ookstats/src/internal/pipeline/fingerprints.go
+++ b/nix/pkgs/ookstats/src/internal/pipeline/fingerprints.go
@@ -334,27 +334,38 @@ func processFingerprintCandidate(db *database.DatabaseServic
e, client *blizzard.
 
 	existing := collisionMap[hash]
 	if existing != 0 && existing != cand.PlayerID {
-		// Merge: migrate runs from duplicate ? canonical
-		runsMigrated, err := db.MigratePlayerRuns(cand.PlayerID, existing)
+		// 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
 		}
 
-		// Invalidate canonical player's profile for rebuild
-		if err := db.InvalidatePlayerProfile(existing); err != nil {
-			logger.Warn("failed to invalidate profile", "player_id", existing, "error", er
r)
+		// 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)
 		}
 
-		logger.Info("merged player identity",
-			"duplicate_id", cand.PlayerID,
-			"canonical_id", existing,
+		// 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])
 
-		out.invalid = true
-		out.statusValid = false
-		return out
+		// Continue processing to create fingerprint for the new canonical player
 	}
 
 	now := nowMillis()

diff --git a/nix/pkgs/ookstats/src/internal/
pipeline/fingerprints.go b/nix/pkgs/ookstats
/src/internal/pipeline/fingerprints.go
index f699aab..c71597f 100644
--- a/nix/pkgs/ookstats/src/internal/pipelin
e/fingerprints.go
+++ b/nix/pkgs/ookstats/src/internal/pipelin
e/fingerprints.go
@@ -334,27 +334,38 @@ func processFingerprin
tCandidate(db *database.DatabaseService, cli
ent *blizzard.
 
 	existing := collisionMap[hash]
 	if existing != 0 && existing != cand.Playe
rID {
-		// Merge: migrate runs from duplicate ? c
anonical
-		runsMigrated, err := db.MigratePlayerRuns
(cand.PlayerID, existing)
+		// Merge: the candidate (cand) is the new
er player, existing is the old one
+		// Migrate runs FROM old (existing) TO ne
w (cand)
+		runsMigrated, err := db.MigratePlayerRuns
(existing, cand.PlayerID)
 		if err != nil {
 			out.err = fmt.Errorf("failed to migrate 
runs: %w", err)
 			return out
 		}
 
-		// Invalidate canonical player's profile 
for rebuild
-		if err := db.InvalidatePlayerProfile(exis
ting); err != nil {
-			logger.Warn("failed to invalidate profil
e", "player_id", existing, "error", err)
+		// Mark the old player as invalid
+		if err := db.UpdatePlayerStatus(existing,
 false, nowMillis(), nil); err != nil {
+			logger.Warn("failed to invalidate old pl
ayer",
+				"player_id", existing,
+				"error", err)
 		}
 
-		logger.Info("merged player identity",
-			"duplicate_id", cand.PlayerID,
-			"canonical_id", existing,
+		// Delete old player's fingerprint so the
 new one becomes canonical
+		if err := db.DeletePlayerFingerprint(exis
ting); err != nil {
+			logger.Warn("failed to delete old finger
print",
+				"player_id", existing,
+				"error", err)
+		}
+
+		// Update collision map to point to the n
ew 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])
 
-		out.invalid = true
-		out.statusValid = false
-		return out
+		// Continue processing to create fingerpr
int for the new canonical player
 	}
 
 	now := nowMillis()
 
[ BACK TO LOG ]
──────────────────────────────────────────────────────────────────────────────────────
OOKNET
────────────────────────────────────────────────
OOKNET