Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions GenOnlineService/Controllers/Lobby/LobbyController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,17 @@ public async Task<APIResult> 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
Expand Down
65 changes: 62 additions & 3 deletions GenOnlineService/Database/Database.MatchHistory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -518,6 +518,47 @@ public static async Task<ulong> CreatePlaceholderMatchHistory(
}
}

/// <summary>
/// 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.
/// </summary>
public static List<int> BuildFallbackCandidates(
IReadOnlyDictionary<int, MatchdataMemberModel> members,
string strMatchRosterType,
IReadOnlyDictionary<Int64, bool> 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<int> lstActiveSlots = new();
List<int> 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)
Expand Down Expand Up @@ -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<int> 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;
Expand All @@ -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;
Expand Down Expand Up @@ -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.");
Expand Down
24 changes: 24 additions & 0 deletions GenOnlineService/LobbyManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand Down Expand Up @@ -291,6 +295,14 @@ public int MaxPlayers
[JsonIgnore]
public ConcurrentDictionary<Int64, DateTime> 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<Int64, bool> ReportedOutcomes { get; private set; } = new();

/// <summary>
/// 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
Expand Down Expand Up @@ -318,6 +330,18 @@ public void ClearPlayerIngameAbandon(Int64 userId)
}
}

/// <summary>
/// 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.
/// </summary>
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;

Expand Down