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 b76b8ef..c600b0f 100644 --- a/GenOnlineService/Database/Database.MatchHistory.cs +++ b/GenOnlineService/Database/Database.MatchHistory.cs @@ -518,6 +518,47 @@ public static async Task CreatePlaceholderMatchHistory( } } + /// + /// 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. + /// + /// 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. + /// + public static List BuildFallbackCandidates( + IReadOnlyDictionary members, + string strMatchRosterType, + IReadOnlyDictionary reportedOutcomes) + { + // 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) + continue; + + lstActiveSlots.Add(member.Key); + + if (reportedOutcomes.TryGetValue(member.Value.user_id, out bool bReportedWon) && !bReportedWon) + continue; + + lstCandidateSlots.Add(member.Key); + } + + // 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 lstCandidateSlots.Count > 0 ? lstCandidateSlots : lstActiveSlots; + } + public static async Task DetermineLobbyWinnerIfNotPresent( AppDbContext db, GenOnlineService.Lobby lobby) @@ -613,6 +654,15 @@ 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. 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; + + 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; int lastSlot = -1; @@ -621,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; @@ -651,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;