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;