mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
Refactor leaderboard logic: incorporate room context into leaderboard stats and channel metadata handling
This commit is contained in:
@@ -78,13 +78,15 @@ public class LeaderboardController(
|
||||
public async Task<IActionResult> SetStat([FromBody] SetStatRequest req)
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var roomId = ResolveRoomId(req.RoomId, pid);
|
||||
var row = await db.LeaderboardStats.FirstOrDefaultAsync(s =>
|
||||
s.PlayerId == pid && s.StatChannel == req.StatChannel);
|
||||
s.RoomId == roomId && s.PlayerId == pid && s.StatChannel == req.StatChannel);
|
||||
if (row is null)
|
||||
{
|
||||
row = new LeaderboardStatEntity
|
||||
{
|
||||
PlayerId = pid,
|
||||
RoomId = roomId,
|
||||
StatChannel = req.StatChannel,
|
||||
Value = req.StatValue,
|
||||
};
|
||||
@@ -103,11 +105,10 @@ public class LeaderboardController(
|
||||
// when it's zero (rare — direct stat report outside a room
|
||||
// context) fall back to the player's current presence so a
|
||||
// stray score still gets bucketed somewhere visible.
|
||||
var roomId = req.RoomId > 0 ? req.RoomId : (playerPresence.GetRoom(pid)?.RoomId ?? 0);
|
||||
if (roomId > 0)
|
||||
{
|
||||
var meta = await db.LeaderboardChannelMeta
|
||||
.FirstOrDefaultAsync(c => c.Channel == req.StatChannel);
|
||||
.FirstOrDefaultAsync(c => c.RoomId == roomId && c.Channel == req.StatChannel);
|
||||
if (meta is null)
|
||||
{
|
||||
// First time this channel reports. Stamp a meta row so
|
||||
@@ -124,17 +125,6 @@ public class LeaderboardController(
|
||||
ValueFormat = "count",
|
||||
});
|
||||
}
|
||||
else if (meta.RoomId == 0)
|
||||
{
|
||||
// Channel registered globally (e.g. from the hardcoded
|
||||
// KnownStatChannels fallback or an unscoped admin
|
||||
// entry). Bind it to this room now that we know which
|
||||
// room it came from. Don't re-bind if it already
|
||||
// points at a different room — the admin must be the
|
||||
// one to reassign once a channel has a home.
|
||||
meta.RoomId = roomId;
|
||||
meta.UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
@@ -161,18 +151,22 @@ public class LeaderboardController(
|
||||
if (pid == 0)
|
||||
return Ok(new { playerId = 0, score = 0L, rank = 0 });
|
||||
|
||||
var roomId = ResolveRoomId(req.RoomId, pid);
|
||||
var row = await db.LeaderboardStats.FirstOrDefaultAsync(s =>
|
||||
s.PlayerId == pid && s.StatChannel == req.StatChannel);
|
||||
s.RoomId == roomId && s.PlayerId == pid && s.StatChannel == req.StatChannel);
|
||||
if (row is null)
|
||||
return Ok(new { playerId = (int)pid, score = 0L, rank = 0 });
|
||||
|
||||
var higherCount = await db.LeaderboardStats
|
||||
.CountAsync(s => s.StatChannel == req.StatChannel && s.Value > row.Value);
|
||||
var lowerIsBetter = await IsLowerIsBetterAsync(roomId, req.StatChannel);
|
||||
var betterCount = await db.LeaderboardStats
|
||||
.CountAsync(s => s.RoomId == roomId
|
||||
&& s.StatChannel == req.StatChannel
|
||||
&& (lowerIsBetter ? s.Value < row.Value : s.Value > row.Value));
|
||||
return Ok(new
|
||||
{
|
||||
playerId = (int)pid,
|
||||
score = row.Value,
|
||||
rank = higherCount + 1,
|
||||
rank = betterCount + 1,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -194,12 +188,13 @@ public class LeaderboardController(
|
||||
{
|
||||
var pid = req.PlayerId > 0 ? (long)req.PlayerId : this.CurrentPlayerId() ?? 0;
|
||||
var window = Math.Clamp(req.WindowSize, 1, 50);
|
||||
var roomId = ResolveRoomId(req.RoomId, pid);
|
||||
var lowerIsBetter = await IsLowerIsBetterAsync(roomId, req.StatChannel);
|
||||
|
||||
var all = await db.LeaderboardStats
|
||||
.Where(s => s.StatChannel == req.StatChannel)
|
||||
.OrderByDescending(s => s.Value)
|
||||
.Select(s => new { s.PlayerId, s.Value })
|
||||
.ToListAsync();
|
||||
var query = db.LeaderboardStats
|
||||
.Where(s => s.RoomId == roomId && s.StatChannel == req.StatChannel);
|
||||
query = lowerIsBetter ? query.OrderBy(s => s.Value) : query.OrderByDescending(s => s.Value);
|
||||
var all = await query.Select(s => new { s.PlayerId, s.Value }).ToListAsync();
|
||||
if (all.Count == 0)
|
||||
{
|
||||
// Empty case must still come back as a SingleLeaderboard
|
||||
@@ -251,12 +246,14 @@ public class LeaderboardController(
|
||||
var end = Math.Max(start, req.RankEnd);
|
||||
var skip = start - 1;
|
||||
var take = Math.Clamp(end - skip, 1, 200);
|
||||
var pid = req.PlayerId > 0 ? (long)req.PlayerId : this.CurrentPlayerId() ?? 0;
|
||||
var roomId = ResolveRoomId(req.RoomId, pid);
|
||||
var lowerIsBetter = await IsLowerIsBetterAsync(roomId, req.StatChannel);
|
||||
|
||||
var rows = await db.LeaderboardStats
|
||||
.Where(s => s.StatChannel == req.StatChannel)
|
||||
.OrderByDescending(s => s.Value)
|
||||
.Skip(skip).Take(take)
|
||||
.ToListAsync();
|
||||
var query = db.LeaderboardStats
|
||||
.Where(s => s.RoomId == roomId && s.StatChannel == req.StatChannel);
|
||||
query = lowerIsBetter ? query.OrderBy(s => s.Value) : query.OrderByDescending(s => s.Value);
|
||||
var rows = await query.Skip(skip).Take(take).ToListAsync();
|
||||
var entries = rows.Select((s, i) => new
|
||||
{
|
||||
playerId = (int)s.PlayerId,
|
||||
@@ -271,14 +268,15 @@ public class LeaderboardController(
|
||||
/// by the watch's full-leaderboard scoreboard view.</summary>
|
||||
[HttpGet("/leaderboard/Top")]
|
||||
[HttpGet("/api/leaderboard/Top")]
|
||||
public async Task<IActionResult> Top([FromQuery] int channel, [FromQuery] int take = 50)
|
||||
public async Task<IActionResult> Top([FromQuery] int channel, [FromQuery] long roomId = 0, [FromQuery] int take = 50)
|
||||
{
|
||||
take = Math.Clamp(take, 1, 200);
|
||||
var rows = await db.LeaderboardStats
|
||||
.Where(s => s.StatChannel == channel)
|
||||
.OrderByDescending(s => s.Value)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
var resolvedRoomId = ResolveRoomId(roomId, this.CurrentPlayerId() ?? 0);
|
||||
var lowerIsBetter = await IsLowerIsBetterAsync(resolvedRoomId, channel);
|
||||
var query = db.LeaderboardStats
|
||||
.Where(s => s.RoomId == resolvedRoomId && s.StatChannel == channel);
|
||||
query = lowerIsBetter ? query.OrderBy(s => s.Value) : query.OrderByDescending(s => s.Value);
|
||||
var rows = await query.Take(take).ToListAsync();
|
||||
// FullLeaderboard wire shape: GlobalOverall / GlobalPeriodic /
|
||||
// FriendsOverall / FriendsPeriodic + NextResetUTC. Use the
|
||||
// same Entry list for all four buckets — friends-vs-global
|
||||
@@ -298,4 +296,20 @@ public class LeaderboardController(
|
||||
NextResetUTC = DateTime.UtcNow.AddDays(7),
|
||||
});
|
||||
}
|
||||
|
||||
private long ResolveRoomId(long requestedRoomId, long playerId)
|
||||
{
|
||||
if (requestedRoomId > 0) return requestedRoomId;
|
||||
if (playerId > 0) return playerPresence.GetRoom(playerId)?.RoomId ?? 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
private async Task<bool> IsLowerIsBetterAsync(long roomId, int channel)
|
||||
{
|
||||
var live = await db.LeaderboardChannelMeta
|
||||
.Where(c => c.RoomId == roomId && c.Channel == channel)
|
||||
.Select(c => (bool?)c.LowerIsBetter)
|
||||
.FirstOrDefaultAsync();
|
||||
return live ?? false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -699,28 +699,31 @@ public class AdminController(
|
||||
public async Task<ActionResult> ListChannels()
|
||||
{
|
||||
var rows = await db.LeaderboardStats
|
||||
.GroupBy(s => s.StatChannel)
|
||||
.GroupBy(s => new { s.RoomId, s.StatChannel })
|
||||
.Select(g => new
|
||||
{
|
||||
Channel = g.Key,
|
||||
g.Key.RoomId,
|
||||
Channel = g.Key.StatChannel,
|
||||
EntryCount = g.Count(),
|
||||
MinValue = g.Min(x => x.Value),
|
||||
MaxValue = g.Max(x => x.Value),
|
||||
})
|
||||
.OrderBy(r => r.Channel)
|
||||
.OrderBy(r => r.RoomId)
|
||||
.ThenBy(r => r.Channel)
|
||||
.ToListAsync();
|
||||
// DB-defined meta beats hardcoded KnownStatChannels so admins
|
||||
// can rename / reassign channels live without a code change.
|
||||
var dbMeta = await db.LeaderboardChannelMeta.ToDictionaryAsync(c => c.Channel);
|
||||
var dbMeta = await db.LeaderboardChannelMeta
|
||||
.ToDictionaryAsync(c => new { c.RoomId, c.Channel });
|
||||
var labelled = rows.Select(r =>
|
||||
{
|
||||
dbMeta.TryGetValue(r.Channel, out var live);
|
||||
dbMeta.TryGetValue(new { r.RoomId, r.Channel }, out var live);
|
||||
KnownStatChannels.TryGetValue(r.Channel, out var fallback);
|
||||
return new
|
||||
{
|
||||
r.Channel,
|
||||
Name = live?.Name is { Length: > 0 } n ? n : (fallback?.Name ?? $"Channel {r.Channel}"),
|
||||
RoomId = live?.RoomId ?? 0,
|
||||
RoomId = r.RoomId,
|
||||
LowerIsBetter = live?.LowerIsBetter ?? fallback?.LowerIsBetter ?? false,
|
||||
ValueFormat = live?.ValueFormat ?? fallback?.ValueFormat ?? "count",
|
||||
Verified = live is not null || fallback is not null,
|
||||
@@ -750,16 +753,16 @@ public class AdminController(
|
||||
/// the channel's natural direction (lower-is-better channels
|
||||
/// sorted ascending, everything else descending).</summary>
|
||||
[HttpGet("leaderboards/{channel:int}")]
|
||||
public async Task<ActionResult> ChannelDetail(int channel, [FromQuery] int take = 100)
|
||||
public async Task<ActionResult> ChannelDetail(int channel, [FromQuery] long roomId = 0, [FromQuery] int take = 100)
|
||||
{
|
||||
take = Math.Clamp(take, 1, 1000);
|
||||
var liveMeta = await db.LeaderboardChannelMeta.FirstOrDefaultAsync(c => c.Channel == channel);
|
||||
var liveMeta = await db.LeaderboardChannelMeta.FirstOrDefaultAsync(c => c.RoomId == roomId && c.Channel == channel);
|
||||
KnownStatChannels.TryGetValue(channel, out var fallback);
|
||||
var name = liveMeta?.Name is { Length: > 0 } ln ? ln : (fallback?.Name ?? $"Channel {channel}");
|
||||
var lowerIsBetter = liveMeta?.LowerIsBetter ?? fallback?.LowerIsBetter ?? false;
|
||||
var valueFormat = liveMeta?.ValueFormat ?? fallback?.ValueFormat ?? "count";
|
||||
var query = db.LeaderboardStats
|
||||
.Where(s => s.StatChannel == channel);
|
||||
.Where(s => s.RoomId == roomId && s.StatChannel == channel);
|
||||
query = lowerIsBetter
|
||||
? query.OrderBy(s => s.Value)
|
||||
: query.OrderByDescending(s => s.Value);
|
||||
@@ -780,7 +783,7 @@ public class AdminController(
|
||||
{
|
||||
Channel = channel,
|
||||
Name = name,
|
||||
RoomId = liveMeta?.RoomId ?? 0,
|
||||
RoomId = roomId,
|
||||
LowerIsBetter = lowerIsBetter,
|
||||
ValueFormat = valueFormat,
|
||||
Verified = liveMeta is not null || fallback is not null,
|
||||
@@ -797,7 +800,7 @@ public class AdminController(
|
||||
});
|
||||
}
|
||||
|
||||
public sealed record SetLeaderboardScoreRequest(long PlayerId, int Value);
|
||||
public sealed record SetLeaderboardScoreRequest(long PlayerId, int Value, long RoomId = 0);
|
||||
|
||||
/// <summary>POST <c>api/admin/v1/leaderboards/{channel}/score</c>
|
||||
/// — upsert a single player's score in a channel.</summary>
|
||||
@@ -805,12 +808,13 @@ public class AdminController(
|
||||
public async Task<ActionResult> SetScore(int channel, [FromBody] SetLeaderboardScoreRequest body)
|
||||
{
|
||||
var row = await db.LeaderboardStats.FirstOrDefaultAsync(s =>
|
||||
s.PlayerId == body.PlayerId && s.StatChannel == channel);
|
||||
s.RoomId == body.RoomId && s.PlayerId == body.PlayerId && s.StatChannel == channel);
|
||||
if (row is null)
|
||||
{
|
||||
row = new LeaderboardStatEntity
|
||||
{
|
||||
PlayerId = body.PlayerId,
|
||||
RoomId = body.RoomId,
|
||||
StatChannel = channel,
|
||||
Value = body.Value,
|
||||
};
|
||||
@@ -821,35 +825,37 @@ public class AdminController(
|
||||
row.Value = body.Value;
|
||||
row.UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
await LogAsync("set_leaderboard_score", "player", body.PlayerId, $"channel={channel} value={body.Value}");
|
||||
await LogAsync("set_leaderboard_score", "player", body.PlayerId, $"room={body.RoomId} channel={channel} value={body.Value}");
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { row.PlayerId, row.StatChannel, row.Value });
|
||||
return Ok(new { row.PlayerId, row.RoomId, row.StatChannel, row.Value });
|
||||
}
|
||||
|
||||
[HttpDelete("leaderboards/{channel:int}/score/{playerId:long}")]
|
||||
public async Task<ActionResult> DeleteScore(int channel, long playerId)
|
||||
public async Task<ActionResult> DeleteScore(int channel, long playerId, [FromQuery] long roomId = 0)
|
||||
{
|
||||
var row = await db.LeaderboardStats.FirstOrDefaultAsync(s =>
|
||||
s.PlayerId == playerId && s.StatChannel == channel);
|
||||
s.RoomId == roomId && s.PlayerId == playerId && s.StatChannel == channel);
|
||||
if (row is null) return NotFound();
|
||||
db.LeaderboardStats.Remove(row);
|
||||
await LogAsync("delete_leaderboard_score", "player", playerId, $"channel={channel}");
|
||||
await LogAsync("delete_leaderboard_score", "player", playerId, $"room={roomId} channel={channel}");
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { deleted = playerId, channel });
|
||||
return Ok(new { deleted = playerId, roomId, channel });
|
||||
}
|
||||
|
||||
/// <summary>DELETE <c>api/admin/v1/leaderboards/{channel}</c> —
|
||||
/// wipe every entry in a stat channel. Used for season resets or
|
||||
/// to clear test data.</summary>
|
||||
[HttpDelete("leaderboards/{channel:int}")]
|
||||
public async Task<ActionResult> WipeChannel(int channel)
|
||||
public async Task<ActionResult> WipeChannel(int channel, [FromQuery] long roomId = 0)
|
||||
{
|
||||
var rows = await db.LeaderboardStats.Where(s => s.StatChannel == channel).ToListAsync();
|
||||
if (rows.Count == 0) return Ok(new { wiped = 0, channel });
|
||||
var rows = await db.LeaderboardStats
|
||||
.Where(s => s.RoomId == roomId && s.StatChannel == channel)
|
||||
.ToListAsync();
|
||||
if (rows.Count == 0) return Ok(new { wiped = 0, roomId, channel });
|
||||
db.LeaderboardStats.RemoveRange(rows);
|
||||
await LogAsync("wipe_leaderboard_channel", "system", channel, $"entries={rows.Count}");
|
||||
await LogAsync("wipe_leaderboard_channel", "system", channel, $"room={roomId} entries={rows.Count}");
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { wiped = rows.Count, channel });
|
||||
return Ok(new { wiped = rows.Count, roomId, channel });
|
||||
}
|
||||
|
||||
// ── Rec Room Originals (RROs) ────────────────────────────────────────
|
||||
@@ -1298,7 +1304,7 @@ public class AdminController(
|
||||
|
||||
var channels = metas.Select(m => m.Channel).ToList();
|
||||
var counts = await db.LeaderboardStats
|
||||
.Where(s => channels.Contains(s.StatChannel))
|
||||
.Where(s => s.RoomId == id && channels.Contains(s.StatChannel))
|
||||
.GroupBy(s => s.StatChannel)
|
||||
.Select(g => new { Channel = g.Key, Count = g.Count(), Best = g.Max(x => x.Value), Worst = g.Min(x => x.Value) })
|
||||
.ToListAsync();
|
||||
@@ -1329,10 +1335,11 @@ public class AdminController(
|
||||
public async Task<ActionResult> UpsertChannelMeta([FromBody] UpsertChannelMetaRequest body)
|
||||
{
|
||||
if (body is null) return BadRequest("missing body");
|
||||
var row = await db.LeaderboardChannelMeta.FirstOrDefaultAsync(c => c.Channel == body.Channel);
|
||||
var row = await db.LeaderboardChannelMeta
|
||||
.FirstOrDefaultAsync(c => c.RoomId == body.RoomId && c.Channel == body.Channel);
|
||||
if (row is null)
|
||||
{
|
||||
row = new LeaderboardChannelMetaEntity { Channel = body.Channel };
|
||||
row = new LeaderboardChannelMetaEntity { RoomId = body.RoomId, Channel = body.Channel };
|
||||
db.LeaderboardChannelMeta.Add(row);
|
||||
}
|
||||
row.RoomId = body.RoomId;
|
||||
@@ -1349,12 +1356,13 @@ public class AdminController(
|
||||
/// remove the metadata row. The channel goes back to showing as
|
||||
/// "Channel N" / unscoped. Existing score rows are kept.</summary>
|
||||
[HttpDelete("leaderboards/meta/{channel:int}")]
|
||||
public async Task<ActionResult> DeleteChannelMeta(int channel)
|
||||
public async Task<ActionResult> DeleteChannelMeta(int channel, [FromQuery] long roomId = 0)
|
||||
{
|
||||
var row = await db.LeaderboardChannelMeta.FirstOrDefaultAsync(c => c.Channel == channel);
|
||||
var row = await db.LeaderboardChannelMeta
|
||||
.FirstOrDefaultAsync(c => c.RoomId == roomId && c.Channel == channel);
|
||||
if (row is null) return Ok(new { ok = true, deleted = 0 });
|
||||
db.LeaderboardChannelMeta.Remove(row);
|
||||
await LogAsync("leaderboard_meta_remove", "channel", channel, "");
|
||||
await LogAsync("leaderboard_meta_remove", "channel", channel, $"room={roomId}");
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { ok = true, deleted = 1 });
|
||||
}
|
||||
@@ -1367,15 +1375,21 @@ public class AdminController(
|
||||
/// actually use: load a course, drive it, then orphans surfaces
|
||||
/// the channel id the game just reported.</summary>
|
||||
[HttpGet("leaderboards/orphans")]
|
||||
public async Task<ActionResult> LeaderboardOrphans()
|
||||
public async Task<ActionResult> LeaderboardOrphans([FromQuery] long? roomId = null)
|
||||
{
|
||||
var mapped = await db.LeaderboardChannelMeta.Select(c => c.Channel).ToListAsync();
|
||||
var rows = await db.LeaderboardStats
|
||||
.Where(s => !mapped.Contains(s.StatChannel))
|
||||
.GroupBy(s => s.StatChannel)
|
||||
.Select(g => new { Channel = g.Key, EntryCount = g.Count(), LastSeen = g.Max(s => s.UpdatedAt) })
|
||||
var mapped = await db.LeaderboardChannelMeta
|
||||
.Select(c => new { c.RoomId, c.Channel })
|
||||
.ToListAsync();
|
||||
var query = db.LeaderboardStats.AsQueryable();
|
||||
if (roomId is long rid) query = query.Where(s => s.RoomId == rid);
|
||||
var rows = await query
|
||||
.GroupBy(s => new { s.RoomId, s.StatChannel })
|
||||
.Select(g => new { g.Key.RoomId, Channel = g.Key.StatChannel, EntryCount = g.Count(), LastSeen = g.Max(s => s.UpdatedAt) })
|
||||
.OrderByDescending(r => r.LastSeen)
|
||||
.ToListAsync();
|
||||
rows = rows
|
||||
.Where(r => !mapped.Any(m => m.RoomId == r.RoomId && m.Channel == r.Channel))
|
||||
.ToList();
|
||||
return Ok(rows);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user