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 1/2] 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; From 3ab751c96b1709923c14a07303ac98505e005eba Mon Sep 17 00:00:00 2001 From: Dennis van 't Hul Date: Sat, 15 Aug 2026 11:53:46 +0200 Subject: [PATCH 2/2] fix(matchdata): use reported outcomes, not disconnects, for the 1v1 tie-break d8789ec inferred who reported from in-game disconnect records. That premise - "disconnected means they couldn't report" - is wrong: the client uploads its outcome mid-match the moment victory is decided, so reporting and then dropping is the normal path. It also made rage-quitting a way to win, where before it lost. Track reports explicitly instead: a ConcurrentDictionary on the Lobby, filled in PostOutcome and cleared in SetMatchID. In a 1v1, players who reported won=false drop out of the last-to-leave candidate list and the one left standing wins. Falls back to comparing every active slot when the roster type isn't 1v1, when nobody conceded, or when everyone did. Only a reported loss drops a player - a reported win that never reached the row is why we're in this fallback at all. Co-Authored-By: Claude Opus 5 --- .../Controllers/Lobby/LobbyController.cs | 11 ++ .../Database/Database.MatchHistory.cs | 120 +++++++----------- GenOnlineService/LobbyManager.cs | 24 ++++ 3 files changed, 79 insertions(+), 76 deletions(-) diff --git a/GenOnlineService/Controllers/Lobby/LobbyController.cs b/GenOnlineService/Controllers/Lobby/LobbyController.cs index f4d004f..845a206 100644 --- a/GenOnlineService/Controllers/Lobby/LobbyController.cs +++ b/GenOnlineService/Controllers/Lobby/LobbyController.cs @@ -330,6 +330,17 @@ public async Task Delete(Int64 lobbyID) return null; } + // Record on the lobby that this player reported, and what they claimed. The lobby + // outlives a disconnected player's session, so this survives to DeleteLobby where the + // winner is determined. MatchID is compared because WasPlayerInMatch accepts any + // historic match of this session - without it a stale report could be attributed to + // whatever lobby the player happens to be in now. + Lobby? outcomeLobby = _lobbyManager.GetLobby(sourceData.currentLobbyID); + if (outcomeLobby != null && outcomeLobby.MatchID == match_id) + { + outcomeLobby.RecordPlayerOutcome(user_id, won); + } + // register with daily stats // NOTE: only once per match - the outcome endpoint can be called repeatedly for the // same match_id, and these counters are unconditional increments diff --git a/GenOnlineService/Database/Database.MatchHistory.cs b/GenOnlineService/Database/Database.MatchHistory.cs index e34199a..c600b0f 100644 --- a/GenOnlineService/Database/Database.MatchHistory.cs +++ b/GenOnlineService/Database/Database.MatchHistory.cs @@ -519,73 +519,44 @@ public static async Task CreatePlaceholderMatchHistory( } /// - /// 1v1 tie-break used when no slot reported a win. + /// The slots allowed to win the timestamp fallback below. A player who reported won=false has + /// conceded, so when they disconnected says nothing about the result - drop them and let the + /// remaining player take it. Only a reported LOSS drops a player: a reported win that never + /// reached the row is why we are in this fallback at all, and must not count against them. /// - /// 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. + /// 1v1 only. In teams or FFA a concession does not identify a winner, so those keep comparing + /// every active slot as before. Same if every player conceded, or if none did - there is either + /// nothing left to choose between or nothing to go on. /// - /// TRUE when the rule decided the match; FALSE otherwise, with strReason saying why not. - public static bool TryResolveOneVsOneByDisconnect( + public static List BuildFallbackCandidates( IReadOnlyDictionary members, - IEnumerable disconnectedUserIDs, - out int winningSlotIndex, - out Int64 winningUserID, - out Int64 concedingUserID, - out string strReason) + string strMatchRosterType, + IReadOnlyDictionary reportedOutcomes) { - 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. + // Observers and AI/placeholder slots (user_id <= 0) never quit the game and must not be + // selected as "last to leave = winner" - the same filter the fallback loop applies. List lstActiveSlots = new(); + List lstCandidateSlots = new(); + foreach (var member in members) { - if (member.Value.side != Constants.OBSERVER_SIDE_VALUE && member.Value.user_id > 0) - { - lstActiveSlots.Add(member.Key); - } - } + if (member.Value.side == Constants.OBSERVER_SIDE_VALUE || member.Value.user_id <= 0) + continue; - if (lstActiveSlots.Count != 2) - { - strReason = $"not a 1v1 ({lstActiveSlots.Count} active participants)"; - return false; - } + lstActiveSlots.Add(member.Key); - // Materialize once - the caller passes ConcurrentDictionary.Keys, tests pass arrays - List lstDisconnectedUserIDs = disconnectedUserIDs.ToList(); + if (reportedOutcomes.TryGetValue(member.Value.user_id, out bool bReportedWon) && !bReportedWon) + continue; - List lstDisconnectedSlots = new(); - foreach (int slotIndex in lstActiveSlots) - { - if (lstDisconnectedUserIDs.Contains(members[slotIndex].user_id)) - { - lstDisconnectedSlots.Add(slotIndex); - } + lstCandidateSlots.Add(member.Key); } - 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"; + // Roster type was computed back at match start, so leaving early cannot change it. The slot + // count is checked too, in case the stored value and the actual slots disagree. + if (strMatchRosterType != "1v1" || lstActiveSlots.Count != 2) + return lstActiveSlots; - return true; + return lstCandidateSlots.Count > 0 ? lstCandidateSlots : lstActiveSlots; } public static async Task DetermineLobbyWinnerIfNotPresent( @@ -683,26 +654,14 @@ 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; - } + // 6a. In a 1v1, drop anyone who reported a loss - see BuildFallbackCandidates. + string strRosterType = await db.MatchHistory + .Where(m => m.MatchId == (long)lobby.MatchID) + .Select(m => m.MatchRosterType) + .FirstOrDefaultAsync() ?? String.Empty; - Console.WriteLine($"[WinnerDet] Match={lobby.MatchID}: 1v1 disconnect rule declined — {strOneVsOneReason}; using last-to-leave fallback."); + List lstCandidateSlots = BuildFallbackCandidates(members, strRosterType, lobby.ReportedOutcomes); + Console.WriteLine($"[WinnerDet] Match={lobby.MatchID}: rosterType='{strRosterType}' reported={lobby.ReportedOutcomes.Count} candidates=[{String.Join(",", lstCandidateSlots)}]"); DateTime latestLeave = DateTime.MinValue; MatchdataMemberModel? lastPlayerNullable = null; @@ -712,9 +671,9 @@ public static async Task DetermineLobbyWinnerIfNotPresent( { var model = kv.Value; - // Skip observer slots and AI/placeholder slots (user_id <= 0); - // they never quit the game and must not be selected as "last to leave = winner". - if (model.side == Constants.OBSERVER_SIDE_VALUE || model.user_id <= 0) + // Skips observer slots, AI/placeholder slots (user_id <= 0) and players who conceded; + // none of them may be selected as "last to leave = winner". + if (!lstCandidateSlots.Contains(kv.Key)) continue; DateTime abandonTime = DateTime.MinValue; @@ -742,6 +701,15 @@ public static async Task DetermineLobbyWinnerIfNotPresent( } } + // A player who exited cleanly can have no timestamp at all, and MinValue never beats the + // MinValue seed above. If conceding left exactly one candidate they win regardless. + if (lastPlayerNullable == null && lstCandidateSlots.Count == 1) + { + lastSlot = lstCandidateSlots[0]; + lastPlayerNullable = members[lastSlot]; + Console.WriteLine($"[WinnerDet] Match={lobby.MatchID}: sole remaining candidate slot={lastSlot} user={lastPlayerNullable.Value.user_id} has no abandon timestamp — awarding anyway."); + } + if (lastPlayerNullable == null) { Console.WriteLine($"[WinnerDet] Match={lobby.MatchID}: no valid abandon timestamps found — fully inconclusive, clearing won flags."); diff --git a/GenOnlineService/LobbyManager.cs b/GenOnlineService/LobbyManager.cs index e19c6ce..c771664 100644 --- a/GenOnlineService/LobbyManager.cs +++ b/GenOnlineService/LobbyManager.cs @@ -198,6 +198,10 @@ public async Task SetMatchID(UInt64 a_matchID) MatchID = a_matchID; #endif + // Reported outcomes are per-match, not per-lobby. Nothing guards a lobby from entering INGAME + // more than once, so drop anything carried over from a previous match on this lobby. + ReportedOutcomes.Clear(); + // store on each player foreach (LobbyMember member in Members) { @@ -291,6 +295,14 @@ public int MaxPlayers [JsonIgnore] public ConcurrentDictionary TimePlayerAbandonedIngame { get; private set; } = new(); + // Records which players submitted their own /Outcome for this match, and what they claimed. + // Kept here rather than on UserSession because a disconnected player's session is destroyed before + // the lobby closes - and that player is exactly the one the winner determination needs to reason + // about. Used by DetermineLobbyWinnerIfNotPresent to tell "reported a loss" apart from "never + // reported", which the stored won flag alone cannot express (it defaults to false). + [JsonIgnore] + public ConcurrentDictionary ReportedOutcomes { get; private set; } = new(); + /// /// Records the moment a player's WebSocket dropped while the lobby was in INGAME state. /// Only the FIRST disconnect is stored; subsequent reconnect/disconnect cycles are ignored @@ -318,6 +330,18 @@ public void ClearPlayerIngameAbandon(Int64 userId) } } + /// + /// Records that a player reported their own match outcome. Only the FIRST report is kept: the + /// outcome endpoint can be called repeatedly for the same match, and a later call must not be + /// able to flip a result that was already reported. + /// + public void RecordPlayerOutcome(Int64 userId, bool bWon) + { + if (ReportedOutcomes.TryAdd(userId, bWon)) + { + Console.WriteLine("[Lobby {0}] Recorded reported outcome for user {1}: won={2}", LobbyID, userId, bWon); + } + } private bool m_bIsDirty = false;