Corrige les joueurs en double dans les statistiques
Build and Publish Docker Image / build-and-push-image (push) Successful in 1m43s

Un nom saisi à la main recevait un nouvel id même si le joueur existait
déjà : la même personne apparaissait plusieurs fois dans le classement,
chaque ligne avec une seule partie.

- NewGame : réutilise l'id (et l'avatar) du joueur existant quand le nom
  correspond, sauf si deux joueurs homonymes sont dans la même partie
- Stats : classement agrégé par personne (nom normalisé) et non par id,
  y compris parties, victoires et compteurs Cabo ; les filtres par joueur
  suivent la même logique pour retrouver les anciennes parties

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Zed
2026-08-01 11:50:06 +02:00
parent 13cab31382
commit 68e0f0b90b
2 changed files with 58 additions and 41 deletions
+12 -1
View File
@@ -219,7 +219,18 @@ export default function NewGame() {
.and((pl) => pl.profileId === activeProfileId && !pl.deletedAt) .and((pl) => pl.profileId === activeProfileId && !pl.deletedAt)
.first(); .first();
if (!exists) { if (exists) {
// Reuse the roster entry so a hand-typed name isn't counted as a new
// player in the stats. Guard against the same name twice in one game.
const idTaken = playersToSave.some((o, j) => j !== i && o.id === exists.id);
if (!idTaken) {
playersToSave[i] = {
...p,
id: exists.id,
avatar: p.avatar ?? exists.avatar,
};
}
} else {
let finalAvatar = p.avatar; let finalAvatar = p.avatar;
// Generate a random avatar automatically // Generate a random avatar automatically
if (!finalAvatar) { if (!finalAvatar) {
+39 -33
View File
@@ -66,6 +66,10 @@ export default function Statistics() {
const isDeletedPlayer = (id: string, name: string) => const isDeletedPlayer = (id: string, name: string) =>
deletedIds.has(id) || deletedNames.has(norm(name)); deletedIds.has(id) || deletedNames.has(norm(name));
// The same person can carry several ids across sessions (a hand-typed name
// gets a fresh id), so statistics are keyed on the name, not the id.
const keyOf = (p: { id: string; name: string }) => norm(p.name) || p.id;
const [timeFilter, setTimeFilter] = useState<"all" | "7d" | "30d" | "year">( const [timeFilter, setTimeFilter] = useState<"all" | "7d" | "30d" | "year">(
"all", "all",
); );
@@ -97,10 +101,13 @@ export default function Statistics() {
// Filter by Players (Must include ALL selected players to allow "Head to Head" stats) // Filter by Players (Must include ALL selected players to allow "Head to Head" stats)
if (selectedPlayerIds.length > 0) { if (selectedPlayerIds.length > 0) {
// Compare on the aggregation key: the same person may carry a different
// id from one session to the next.
const selectedKeys = players
.filter((p) => selectedPlayerIds.includes(p.id))
.map(keyOf);
filtered = filtered.filter((s) => filtered = filtered.filter((s) =>
selectedPlayerIds.every((selectedId) => selectedKeys.every((key) => s.players.some((p) => keyOf(p) === key)),
s.players.some((p) => p.id === selectedId),
),
); );
} }
@@ -110,7 +117,7 @@ export default function Statistics() {
} }
return filtered; return filtered;
}, [allSessions, timeFilter, selectedPlayerIds, selectedGameId]); }, [allSessions, timeFilter, selectedPlayerIds, selectedGameId, players]);
// 2. Calculate Global Stats // 2. Calculate Global Stats
const totalGames = sessions.length; const totalGames = sessions.length;
@@ -159,9 +166,7 @@ export default function Statistics() {
} }
> = {}; > = {};
// Initialize with known players const blankStat = (p: { id: string; name: string; avatar?: string }) => ({
players.forEach((p) => {
playerStats[p.id] = {
id: p.id, id: p.id,
name: p.name, name: p.name,
played: 0, played: 0,
@@ -170,30 +175,32 @@ export default function Statistics() {
caboSuccess: 0, caboSuccess: 0,
caboFails: 0, caboFails: 0,
kamikaze: 0, kamikaze: 0,
}; });
// Initialize with known players (the roster entry provides the id + avatar)
players.forEach((p) => {
playerStats[keyOf(p)] = blankStat(p);
}); });
finishedGames.forEach((session) => { finishedGames.forEach((session) => {
// Map this session's player ids to their aggregation key
const keyById = new Map<string, string>();
session.players.forEach((p) => { session.players.forEach((p) => {
if (isDeletedPlayer(p.id, p.name)) return; // joueur supprimé : hors stats if (isDeletedPlayer(p.id, p.name)) return; // joueur supprimé : hors stats
if (!playerStats[p.id]) { const key = keyOf(p);
playerStats[p.id] = { keyById.set(p.id, key);
id: p.id, if (!playerStats[key]) playerStats[key] = blankStat(p);
name: p.name, if (!playerStats[key].avatar && p.avatar) {
played: 0, playerStats[key].avatar = p.avatar;
won: 0,
avatar: p.avatar,
caboSuccess: 0,
caboFails: 0,
kamikaze: 0,
};
} }
playerStats[p.id].played += 1; playerStats[key].played += 1;
}); });
session.winnerIds?.forEach((winnerId) => { session.winnerIds?.forEach((winnerId) => {
if (playerStats[winnerId]) { const key = keyById.get(winnerId);
playerStats[winnerId].won += 1; if (key && playerStats[key]) {
playerStats[key].won += 1;
} }
}); });
@@ -201,15 +208,11 @@ export default function Statistics() {
if (session.gameId === "cabo") { if (session.gameId === "cabo") {
session.rounds.forEach((round) => { session.rounds.forEach((round) => {
round.scores.forEach((score) => { round.scores.forEach((score) => {
if (score.caboCall === "success" && playerStats[score.playerId]) { const key = keyById.get(score.playerId);
playerStats[score.playerId].caboSuccess += 1; if (!key || !playerStats[key]) return;
} if (score.caboCall === "success") playerStats[key].caboSuccess += 1;
if (score.caboCall === "fail" && playerStats[score.playerId]) { if (score.caboCall === "fail") playerStats[key].caboFails += 1;
playerStats[score.playerId].caboFails += 1; if (score.kamikaze) playerStats[key].kamikaze += 1;
}
if (score.kamikaze && playerStats[score.playerId]) {
playerStats[score.playerId].kamikaze += 1;
}
}); });
}); });
} }
@@ -224,9 +227,12 @@ export default function Statistics() {
.sort((a, b) => b.winrate - a.winrate || b.played - a.played); // Sort by winrate, then games played .sort((a, b) => b.winrate - a.winrate || b.played - a.played); // Sort by winrate, then games played
// 4. Hide unselected players from leaderboard if any are selected // 4. Hide unselected players from leaderboard if any are selected
const selectedKeys = players
.filter((p) => selectedPlayerIds.includes(p.id))
.map(keyOf);
const displayLeaderboard = const displayLeaderboard =
selectedPlayerIds.length > 0 selectedKeys.length > 0
? leaderboard.filter((stat) => selectedPlayerIds.includes(stat.id)) ? leaderboard.filter((stat) => selectedKeys.includes(keyOf(stat)))
: leaderboard; : leaderboard;
// 5. Records: best single-game score per game, according to the game's // 5. Records: best single-game score per game, according to the game's