From d8789ec4902c594d160fd0e9994f9563af8745e4 Mon Sep 17 00:00:00 2001 From: Dennis van 't Hul Date: Wed, 12 Aug 2026 21:16:52 +0200 Subject: [PATCH] fix(matchdata): don't award a 1v1 win to a player who reported a loss When no slot reports won=true, DetermineLobbyWinnerIfNotPresent falls back to "last to leave wins". That inverts the result in a common case: a player whose outcome POST never lands (disconnect at the results screen) is stamped early by RecordPlayerIngameAbandon, while their opponent leaves cleanly from the score screen moments later and is awarded the win - despite having explicitly reported won=false. The server already had the answer in the database and discarded it in favour of guessing on exit order. Add a 1v1 tie-break ahead of the timestamp fallback: when there are exactly two active participants and exactly one of them has an in-game disconnect record, that player wins. They are the only one who could not report, and their opponent has already conceded, so nothing is left to guess. Deliberately narrow - falls through to the existing fallback unchanged when: - the match is not 1v1 (a single disconnect says nothing in teams or FFA) - both players disconnected (genuinely undecidable) - neither disconnected (nothing to infer) - any slot claimed won=true (the conclusive path already handles it) Observers and AI/placeholder slots are excluded using the same filter the timestamp fallback applies. Both the decision and the declines are logged under the existing [WinnerDet] prefix so a future wrong-winner report can be diagnosed from logs. Co-Authored-By: Claude Opus 5 --- .../Database/Database.MatchHistory.cs | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/GenOnlineService/Database/Database.MatchHistory.cs b/GenOnlineService/Database/Database.MatchHistory.cs index b76b8ef..e34199a 100644 --- a/GenOnlineService/Database/Database.MatchHistory.cs +++ b/GenOnlineService/Database/Database.MatchHistory.cs @@ -518,6 +518,76 @@ public static async Task CreatePlaceholderMatchHistory( } } + /// + /// 1v1 tie-break used when no slot reported a win. + /// + /// A player who stayed connected and reported won=false has explicitly conceded, and must not be + /// handed the win merely because they left the lobby last. If exactly one of the two players has an + /// in-game disconnect record, that player is the only one who *could not* report, and their opponent + /// has already declared the loss — so the disconnected player won, and there is nothing to guess. + /// + /// Deliberately 1v1 only: with teams or FFA a single disconnect says nothing about who won, so those + /// shapes fall through to the timestamp fallback unchanged. + /// + /// TRUE when the rule decided the match; FALSE otherwise, with strReason saying why not. + public static bool TryResolveOneVsOneByDisconnect( + IReadOnlyDictionary members, + IEnumerable disconnectedUserIDs, + out int winningSlotIndex, + out Int64 winningUserID, + out Int64 concedingUserID, + out string strReason) + { + winningSlotIndex = -1; + winningUserID = -1; + concedingUserID = -1; + + // Observers and AI/placeholder slots (user_id <= 0) never quit the game, so they are not + // participants for this purpose - the same filter the timestamp fallback applies. + List lstActiveSlots = new(); + foreach (var member in members) + { + if (member.Value.side != Constants.OBSERVER_SIDE_VALUE && member.Value.user_id > 0) + { + lstActiveSlots.Add(member.Key); + } + } + + if (lstActiveSlots.Count != 2) + { + strReason = $"not a 1v1 ({lstActiveSlots.Count} active participants)"; + return false; + } + + // Materialize once - the caller passes ConcurrentDictionary.Keys, tests pass arrays + List lstDisconnectedUserIDs = disconnectedUserIDs.ToList(); + + List lstDisconnectedSlots = new(); + foreach (int slotIndex in lstActiveSlots) + { + if (lstDisconnectedUserIDs.Contains(members[slotIndex].user_id)) + { + lstDisconnectedSlots.Add(slotIndex); + } + } + + if (lstDisconnectedSlots.Count != 1) + { + strReason = $"{lstDisconnectedSlots.Count} of 2 players have an in-game disconnect record (needs exactly 1)"; + return false; + } + + // Exactly two active slots, so the one that is not the winner is the conceding player + winningSlotIndex = lstDisconnectedSlots[0]; + int concedingSlotIndex = (lstActiveSlots[0] == winningSlotIndex) ? lstActiveSlots[1] : lstActiveSlots[0]; + + winningUserID = members[winningSlotIndex].user_id; + concedingUserID = members[concedingSlotIndex].user_id; + strReason = $"user={winningUserID} disconnected in-game, user={concedingUserID} stayed connected and reported won=false"; + + return true; + } + public static async Task DetermineLobbyWinnerIfNotPresent( AppDbContext db, GenOnlineService.Lobby lobby) @@ -613,6 +683,27 @@ public static async Task DetermineLobbyWinnerIfNotPresent( Console.WriteLine($"[WinnerDet] IngameAbandon: user={_kv.Key} at={_kv.Value:O}"); foreach (var _kv in lobby.TimeMemberLeft) Console.WriteLine($"[WinnerDet] MemberLeft: user={_kv.Key} at={_kv.Value:O}"); + // 6a. 1v1 special case - see TryResolveOneVsOneByDisconnect for the reasoning + if (TryResolveOneVsOneByDisconnect(members, lobby.TimePlayerAbandonedIngame.Keys, + out int oneVsOneWinningSlot, out Int64 oneVsOneWinningUserID, out _, out string strOneVsOneReason)) + { + Console.WriteLine($"[WinnerDet] Match={lobby.MatchID}: 1v1 disconnect rule — {strOneVsOneReason} → awarding to user={oneVsOneWinningUserID} slot={oneVsOneWinningSlot} (skipping last-to-leave fallback)."); + + foreach (var kv in members) + { + if (kv.Value.side == Constants.OBSERVER_SIDE_VALUE) + continue; + + bool bIsWinner = kv.Key == oneVsOneWinningSlot; + Console.WriteLine($"[WinnerDet] Match={lobby.MatchID}: marking slot={kv.Key} user={kv.Value.user_id} as {(bIsWinner ? "WINNER" : "loser")}."); + await UpdateMatchHistorySetWinFlag(db, lobby.MatchID, kv.Key, bIsWinner); + } + + return; + } + + Console.WriteLine($"[WinnerDet] Match={lobby.MatchID}: 1v1 disconnect rule declined — {strOneVsOneReason}; using last-to-leave fallback."); + DateTime latestLeave = DateTime.MinValue; MatchdataMemberModel? lastPlayerNullable = null; int lastSlot = -1;