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)
|
public async Task<IActionResult> SetStat([FromBody] SetStatRequest req)
|
||||||
{
|
{
|
||||||
var pid = this.RequireCurrentPlayerId();
|
var pid = this.RequireCurrentPlayerId();
|
||||||
|
var roomId = ResolveRoomId(req.RoomId, pid);
|
||||||
var row = await db.LeaderboardStats.FirstOrDefaultAsync(s =>
|
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)
|
if (row is null)
|
||||||
{
|
{
|
||||||
row = new LeaderboardStatEntity
|
row = new LeaderboardStatEntity
|
||||||
{
|
{
|
||||||
PlayerId = pid,
|
PlayerId = pid,
|
||||||
|
RoomId = roomId,
|
||||||
StatChannel = req.StatChannel,
|
StatChannel = req.StatChannel,
|
||||||
Value = req.StatValue,
|
Value = req.StatValue,
|
||||||
};
|
};
|
||||||
@@ -103,11 +105,10 @@ public class LeaderboardController(
|
|||||||
// when it's zero (rare — direct stat report outside a room
|
// when it's zero (rare — direct stat report outside a room
|
||||||
// context) fall back to the player's current presence so a
|
// context) fall back to the player's current presence so a
|
||||||
// stray score still gets bucketed somewhere visible.
|
// stray score still gets bucketed somewhere visible.
|
||||||
var roomId = req.RoomId > 0 ? req.RoomId : (playerPresence.GetRoom(pid)?.RoomId ?? 0);
|
|
||||||
if (roomId > 0)
|
if (roomId > 0)
|
||||||
{
|
{
|
||||||
var meta = await db.LeaderboardChannelMeta
|
var meta = await db.LeaderboardChannelMeta
|
||||||
.FirstOrDefaultAsync(c => c.Channel == req.StatChannel);
|
.FirstOrDefaultAsync(c => c.RoomId == roomId && c.Channel == req.StatChannel);
|
||||||
if (meta is null)
|
if (meta is null)
|
||||||
{
|
{
|
||||||
// First time this channel reports. Stamp a meta row so
|
// First time this channel reports. Stamp a meta row so
|
||||||
@@ -124,17 +125,6 @@ public class LeaderboardController(
|
|||||||
ValueFormat = "count",
|
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();
|
await db.SaveChangesAsync();
|
||||||
@@ -161,18 +151,22 @@ public class LeaderboardController(
|
|||||||
if (pid == 0)
|
if (pid == 0)
|
||||||
return Ok(new { playerId = 0, score = 0L, rank = 0 });
|
return Ok(new { playerId = 0, score = 0L, rank = 0 });
|
||||||
|
|
||||||
|
var roomId = ResolveRoomId(req.RoomId, pid);
|
||||||
var row = await db.LeaderboardStats.FirstOrDefaultAsync(s =>
|
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)
|
if (row is null)
|
||||||
return Ok(new { playerId = (int)pid, score = 0L, rank = 0 });
|
return Ok(new { playerId = (int)pid, score = 0L, rank = 0 });
|
||||||
|
|
||||||
var higherCount = await db.LeaderboardStats
|
var lowerIsBetter = await IsLowerIsBetterAsync(roomId, req.StatChannel);
|
||||||
.CountAsync(s => s.StatChannel == req.StatChannel && s.Value > row.Value);
|
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
|
return Ok(new
|
||||||
{
|
{
|
||||||
playerId = (int)pid,
|
playerId = (int)pid,
|
||||||
score = row.Value,
|
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 pid = req.PlayerId > 0 ? (long)req.PlayerId : this.CurrentPlayerId() ?? 0;
|
||||||
var window = Math.Clamp(req.WindowSize, 1, 50);
|
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
|
var query = db.LeaderboardStats
|
||||||
.Where(s => s.StatChannel == req.StatChannel)
|
.Where(s => s.RoomId == roomId && s.StatChannel == req.StatChannel);
|
||||||
.OrderByDescending(s => s.Value)
|
query = lowerIsBetter ? query.OrderBy(s => s.Value) : query.OrderByDescending(s => s.Value);
|
||||||
.Select(s => new { s.PlayerId, s.Value })
|
var all = await query.Select(s => new { s.PlayerId, s.Value }).ToListAsync();
|
||||||
.ToListAsync();
|
|
||||||
if (all.Count == 0)
|
if (all.Count == 0)
|
||||||
{
|
{
|
||||||
// Empty case must still come back as a SingleLeaderboard
|
// Empty case must still come back as a SingleLeaderboard
|
||||||
@@ -251,12 +246,14 @@ public class LeaderboardController(
|
|||||||
var end = Math.Max(start, req.RankEnd);
|
var end = Math.Max(start, req.RankEnd);
|
||||||
var skip = start - 1;
|
var skip = start - 1;
|
||||||
var take = Math.Clamp(end - skip, 1, 200);
|
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
|
var query = db.LeaderboardStats
|
||||||
.Where(s => s.StatChannel == req.StatChannel)
|
.Where(s => s.RoomId == roomId && s.StatChannel == req.StatChannel);
|
||||||
.OrderByDescending(s => s.Value)
|
query = lowerIsBetter ? query.OrderBy(s => s.Value) : query.OrderByDescending(s => s.Value);
|
||||||
.Skip(skip).Take(take)
|
var rows = await query.Skip(skip).Take(take).ToListAsync();
|
||||||
.ToListAsync();
|
|
||||||
var entries = rows.Select((s, i) => new
|
var entries = rows.Select((s, i) => new
|
||||||
{
|
{
|
||||||
playerId = (int)s.PlayerId,
|
playerId = (int)s.PlayerId,
|
||||||
@@ -271,14 +268,15 @@ public class LeaderboardController(
|
|||||||
/// by the watch's full-leaderboard scoreboard view.</summary>
|
/// by the watch's full-leaderboard scoreboard view.</summary>
|
||||||
[HttpGet("/leaderboard/Top")]
|
[HttpGet("/leaderboard/Top")]
|
||||||
[HttpGet("/api/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);
|
take = Math.Clamp(take, 1, 200);
|
||||||
var rows = await db.LeaderboardStats
|
var resolvedRoomId = ResolveRoomId(roomId, this.CurrentPlayerId() ?? 0);
|
||||||
.Where(s => s.StatChannel == channel)
|
var lowerIsBetter = await IsLowerIsBetterAsync(resolvedRoomId, channel);
|
||||||
.OrderByDescending(s => s.Value)
|
var query = db.LeaderboardStats
|
||||||
.Take(take)
|
.Where(s => s.RoomId == resolvedRoomId && s.StatChannel == channel);
|
||||||
.ToListAsync();
|
query = lowerIsBetter ? query.OrderBy(s => s.Value) : query.OrderByDescending(s => s.Value);
|
||||||
|
var rows = await query.Take(take).ToListAsync();
|
||||||
// FullLeaderboard wire shape: GlobalOverall / GlobalPeriodic /
|
// FullLeaderboard wire shape: GlobalOverall / GlobalPeriodic /
|
||||||
// FriendsOverall / FriendsPeriodic + NextResetUTC. Use the
|
// FriendsOverall / FriendsPeriodic + NextResetUTC. Use the
|
||||||
// same Entry list for all four buckets — friends-vs-global
|
// same Entry list for all four buckets — friends-vs-global
|
||||||
@@ -298,4 +296,20 @@ public class LeaderboardController(
|
|||||||
NextResetUTC = DateTime.UtcNow.AddDays(7),
|
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()
|
public async Task<ActionResult> ListChannels()
|
||||||
{
|
{
|
||||||
var rows = await db.LeaderboardStats
|
var rows = await db.LeaderboardStats
|
||||||
.GroupBy(s => s.StatChannel)
|
.GroupBy(s => new { s.RoomId, s.StatChannel })
|
||||||
.Select(g => new
|
.Select(g => new
|
||||||
{
|
{
|
||||||
Channel = g.Key,
|
g.Key.RoomId,
|
||||||
|
Channel = g.Key.StatChannel,
|
||||||
EntryCount = g.Count(),
|
EntryCount = g.Count(),
|
||||||
MinValue = g.Min(x => x.Value),
|
MinValue = g.Min(x => x.Value),
|
||||||
MaxValue = g.Max(x => x.Value),
|
MaxValue = g.Max(x => x.Value),
|
||||||
})
|
})
|
||||||
.OrderBy(r => r.Channel)
|
.OrderBy(r => r.RoomId)
|
||||||
|
.ThenBy(r => r.Channel)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
// DB-defined meta beats hardcoded KnownStatChannels so admins
|
// DB-defined meta beats hardcoded KnownStatChannels so admins
|
||||||
// can rename / reassign channels live without a code change.
|
// 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 =>
|
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);
|
KnownStatChannels.TryGetValue(r.Channel, out var fallback);
|
||||||
return new
|
return new
|
||||||
{
|
{
|
||||||
r.Channel,
|
r.Channel,
|
||||||
Name = live?.Name is { Length: > 0 } n ? n : (fallback?.Name ?? $"Channel {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,
|
LowerIsBetter = live?.LowerIsBetter ?? fallback?.LowerIsBetter ?? false,
|
||||||
ValueFormat = live?.ValueFormat ?? fallback?.ValueFormat ?? "count",
|
ValueFormat = live?.ValueFormat ?? fallback?.ValueFormat ?? "count",
|
||||||
Verified = live is not null || fallback is not null,
|
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
|
/// the channel's natural direction (lower-is-better channels
|
||||||
/// sorted ascending, everything else descending).</summary>
|
/// sorted ascending, everything else descending).</summary>
|
||||||
[HttpGet("leaderboards/{channel:int}")]
|
[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);
|
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);
|
KnownStatChannels.TryGetValue(channel, out var fallback);
|
||||||
var name = liveMeta?.Name is { Length: > 0 } ln ? ln : (fallback?.Name ?? $"Channel {channel}");
|
var name = liveMeta?.Name is { Length: > 0 } ln ? ln : (fallback?.Name ?? $"Channel {channel}");
|
||||||
var lowerIsBetter = liveMeta?.LowerIsBetter ?? fallback?.LowerIsBetter ?? false;
|
var lowerIsBetter = liveMeta?.LowerIsBetter ?? fallback?.LowerIsBetter ?? false;
|
||||||
var valueFormat = liveMeta?.ValueFormat ?? fallback?.ValueFormat ?? "count";
|
var valueFormat = liveMeta?.ValueFormat ?? fallback?.ValueFormat ?? "count";
|
||||||
var query = db.LeaderboardStats
|
var query = db.LeaderboardStats
|
||||||
.Where(s => s.StatChannel == channel);
|
.Where(s => s.RoomId == roomId && s.StatChannel == channel);
|
||||||
query = lowerIsBetter
|
query = lowerIsBetter
|
||||||
? query.OrderBy(s => s.Value)
|
? query.OrderBy(s => s.Value)
|
||||||
: query.OrderByDescending(s => s.Value);
|
: query.OrderByDescending(s => s.Value);
|
||||||
@@ -780,7 +783,7 @@ public class AdminController(
|
|||||||
{
|
{
|
||||||
Channel = channel,
|
Channel = channel,
|
||||||
Name = name,
|
Name = name,
|
||||||
RoomId = liveMeta?.RoomId ?? 0,
|
RoomId = roomId,
|
||||||
LowerIsBetter = lowerIsBetter,
|
LowerIsBetter = lowerIsBetter,
|
||||||
ValueFormat = valueFormat,
|
ValueFormat = valueFormat,
|
||||||
Verified = liveMeta is not null || fallback is not null,
|
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>
|
/// <summary>POST <c>api/admin/v1/leaderboards/{channel}/score</c>
|
||||||
/// — upsert a single player's score in a channel.</summary>
|
/// — 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)
|
public async Task<ActionResult> SetScore(int channel, [FromBody] SetLeaderboardScoreRequest body)
|
||||||
{
|
{
|
||||||
var row = await db.LeaderboardStats.FirstOrDefaultAsync(s =>
|
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)
|
if (row is null)
|
||||||
{
|
{
|
||||||
row = new LeaderboardStatEntity
|
row = new LeaderboardStatEntity
|
||||||
{
|
{
|
||||||
PlayerId = body.PlayerId,
|
PlayerId = body.PlayerId,
|
||||||
|
RoomId = body.RoomId,
|
||||||
StatChannel = channel,
|
StatChannel = channel,
|
||||||
Value = body.Value,
|
Value = body.Value,
|
||||||
};
|
};
|
||||||
@@ -821,35 +825,37 @@ public class AdminController(
|
|||||||
row.Value = body.Value;
|
row.Value = body.Value;
|
||||||
row.UpdatedAt = DateTime.UtcNow;
|
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();
|
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}")]
|
[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 =>
|
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();
|
if (row is null) return NotFound();
|
||||||
db.LeaderboardStats.Remove(row);
|
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();
|
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> —
|
/// <summary>DELETE <c>api/admin/v1/leaderboards/{channel}</c> —
|
||||||
/// wipe every entry in a stat channel. Used for season resets or
|
/// wipe every entry in a stat channel. Used for season resets or
|
||||||
/// to clear test data.</summary>
|
/// to clear test data.</summary>
|
||||||
[HttpDelete("leaderboards/{channel:int}")]
|
[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();
|
var rows = await db.LeaderboardStats
|
||||||
if (rows.Count == 0) return Ok(new { wiped = 0, channel });
|
.Where(s => s.RoomId == roomId && s.StatChannel == channel)
|
||||||
|
.ToListAsync();
|
||||||
|
if (rows.Count == 0) return Ok(new { wiped = 0, roomId, channel });
|
||||||
db.LeaderboardStats.RemoveRange(rows);
|
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();
|
await db.SaveChangesAsync();
|
||||||
return Ok(new { wiped = rows.Count, channel });
|
return Ok(new { wiped = rows.Count, roomId, channel });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Rec Room Originals (RROs) ────────────────────────────────────────
|
// ── Rec Room Originals (RROs) ────────────────────────────────────────
|
||||||
@@ -1298,7 +1304,7 @@ public class AdminController(
|
|||||||
|
|
||||||
var channels = metas.Select(m => m.Channel).ToList();
|
var channels = metas.Select(m => m.Channel).ToList();
|
||||||
var counts = await db.LeaderboardStats
|
var counts = await db.LeaderboardStats
|
||||||
.Where(s => channels.Contains(s.StatChannel))
|
.Where(s => s.RoomId == id && channels.Contains(s.StatChannel))
|
||||||
.GroupBy(s => 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) })
|
.Select(g => new { Channel = g.Key, Count = g.Count(), Best = g.Max(x => x.Value), Worst = g.Min(x => x.Value) })
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
@@ -1329,10 +1335,11 @@ public class AdminController(
|
|||||||
public async Task<ActionResult> UpsertChannelMeta([FromBody] UpsertChannelMetaRequest body)
|
public async Task<ActionResult> UpsertChannelMeta([FromBody] UpsertChannelMetaRequest body)
|
||||||
{
|
{
|
||||||
if (body is null) return BadRequest("missing 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)
|
if (row is null)
|
||||||
{
|
{
|
||||||
row = new LeaderboardChannelMetaEntity { Channel = body.Channel };
|
row = new LeaderboardChannelMetaEntity { RoomId = body.RoomId, Channel = body.Channel };
|
||||||
db.LeaderboardChannelMeta.Add(row);
|
db.LeaderboardChannelMeta.Add(row);
|
||||||
}
|
}
|
||||||
row.RoomId = body.RoomId;
|
row.RoomId = body.RoomId;
|
||||||
@@ -1349,12 +1356,13 @@ public class AdminController(
|
|||||||
/// remove the metadata row. The channel goes back to showing as
|
/// remove the metadata row. The channel goes back to showing as
|
||||||
/// "Channel N" / unscoped. Existing score rows are kept.</summary>
|
/// "Channel N" / unscoped. Existing score rows are kept.</summary>
|
||||||
[HttpDelete("leaderboards/meta/{channel:int}")]
|
[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 });
|
if (row is null) return Ok(new { ok = true, deleted = 0 });
|
||||||
db.LeaderboardChannelMeta.Remove(row);
|
db.LeaderboardChannelMeta.Remove(row);
|
||||||
await LogAsync("leaderboard_meta_remove", "channel", channel, "");
|
await LogAsync("leaderboard_meta_remove", "channel", channel, $"room={roomId}");
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
return Ok(new { ok = true, deleted = 1 });
|
return Ok(new { ok = true, deleted = 1 });
|
||||||
}
|
}
|
||||||
@@ -1367,15 +1375,21 @@ public class AdminController(
|
|||||||
/// actually use: load a course, drive it, then orphans surfaces
|
/// actually use: load a course, drive it, then orphans surfaces
|
||||||
/// the channel id the game just reported.</summary>
|
/// the channel id the game just reported.</summary>
|
||||||
[HttpGet("leaderboards/orphans")]
|
[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 mapped = await db.LeaderboardChannelMeta
|
||||||
var rows = await db.LeaderboardStats
|
.Select(c => new { c.RoomId, c.Channel })
|
||||||
.Where(s => !mapped.Contains(s.StatChannel))
|
.ToListAsync();
|
||||||
.GroupBy(s => s.StatChannel)
|
var query = db.LeaderboardStats.AsQueryable();
|
||||||
.Select(g => new { Channel = g.Key, EntryCount = g.Count(), LastSeen = g.Max(s => s.UpdatedAt) })
|
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)
|
.OrderByDescending(r => r.LastSeen)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
rows = rows
|
||||||
|
.Where(r => !mapped.Any(m => m.RoomId == r.RoomId && m.Channel == r.Channel))
|
||||||
|
.ToList();
|
||||||
return Ok(rows);
|
return Ok(rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -497,11 +497,12 @@ public class DorkNetDbContext(DbContextOptions<DorkNetDbContext> options) : DbCo
|
|||||||
modelBuilder.Entity<LeaderboardStatEntity>(e =>
|
modelBuilder.Entity<LeaderboardStatEntity>(e =>
|
||||||
{
|
{
|
||||||
e.HasKey(s => s.Id);
|
e.HasKey(s => s.Id);
|
||||||
// One row per (player, channel) — enforced unique so
|
// One row per (room, player, channel) — enforced unique so
|
||||||
// SetStats can upsert without race conditions.
|
// SetStats can upsert without race conditions.
|
||||||
e.HasIndex(s => new { s.PlayerId, s.StatChannel }).IsUnique();
|
e.HasIndex(s => new { s.RoomId, s.PlayerId, s.StatChannel }).IsUnique();
|
||||||
// Rank computation: ORDER BY Value DESC over a channel.
|
// Rank computation: ORDER BY Value over a room/channel
|
||||||
e.HasIndex(s => new { s.StatChannel, s.Value });
|
// (direction is chosen by leaderboard metadata).
|
||||||
|
e.HasIndex(s => new { s.RoomId, s.StatChannel, s.Value });
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity<PlayerEloEntity>(e =>
|
modelBuilder.Entity<PlayerEloEntity>(e =>
|
||||||
@@ -672,11 +673,9 @@ public class DorkNetDbContext(DbContextOptions<DorkNetDbContext> options) : DbCo
|
|||||||
|
|
||||||
modelBuilder.Entity<LeaderboardChannelMetaEntity>(e =>
|
modelBuilder.Entity<LeaderboardChannelMetaEntity>(e =>
|
||||||
{
|
{
|
||||||
// Channel int IS the primary key — there's only one
|
// Channel ids are reused between rooms; a leaderboard is
|
||||||
// metadata row per stat-channel id, and the channel id
|
// identified by (RoomId, Channel), not the channel alone.
|
||||||
// is what the watch reports on POST /SetStats.
|
e.HasKey(c => new { c.RoomId, c.Channel });
|
||||||
e.HasKey(c => c.Channel);
|
|
||||||
e.Property(c => c.Channel).ValueGeneratedNever();
|
|
||||||
e.HasIndex(c => c.RoomId);
|
e.HasIndex(c => c.RoomId);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
namespace DorkNet.Server.Data.Entities;
|
namespace DorkNet.Server.Data.Entities;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Admin-curated metadata for a leaderboard <c>StatChannel</c> id. The
|
/// Admin-curated metadata for a room-scoped leaderboard
|
||||||
|
/// <c>StatChannel</c> id. The
|
||||||
/// 2020 watch reports raw integer channel ids via
|
/// 2020 watch reports raw integer channel ids via
|
||||||
/// <c>POST api/Leaderboard/v1/SetStats</c>; this table lets admins map
|
/// <c>POST api/Leaderboard/v1/SetStats</c>; this table lets admins map
|
||||||
/// each channel to a specific room (so the room detail page can list
|
/// each channel to a specific room (so the room detail page can list
|
||||||
@@ -9,22 +10,19 @@ namespace DorkNet.Server.Data.Entities;
|
|||||||
/// here override the hardcoded <c>AdminController.KnownStatChannels</c>
|
/// here override the hardcoded <c>AdminController.KnownStatChannels</c>
|
||||||
/// fallback. Channels with no row stay listed as "Channel N".
|
/// fallback. Channels with no row stay listed as "Channel N".
|
||||||
///
|
///
|
||||||
/// One channel id, one row. Repeated <c>SetStats</c> calls for the same
|
/// One row per (room, channel). Repeated <c>SetStats</c> calls for the
|
||||||
/// channel from any room write to that channel's value rows; the meta
|
/// same channel in different rooms do not share metadata or values.
|
||||||
/// row only changes when an admin renames or re-maps it.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class LeaderboardChannelMetaEntity
|
public class LeaderboardChannelMetaEntity
|
||||||
{
|
{
|
||||||
/// <summary>Same int the watch reports for this channel. Primary
|
|
||||||
/// key — there's only ever one meta row per channel.</summary>
|
|
||||||
public int Channel { get; set; }
|
|
||||||
|
|
||||||
/// <summary>Room this channel "belongs to" for the per-room detail
|
/// <summary>Room this channel "belongs to" for the per-room detail
|
||||||
/// view. 0 = global / unscoped (e.g. cross-room player progression
|
/// view. 0 = global / unscoped legacy bucket.</summary>
|
||||||
/// counters). The same room can own many channels (e.g. Stunt
|
|
||||||
/// Runner has one channel per course).</summary>
|
|
||||||
public long RoomId { get; set; }
|
public long RoomId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Same int the watch reports for this channel. Only unique
|
||||||
|
/// together with <see cref="RoomId"/>.</summary>
|
||||||
|
public int Channel { get; set; }
|
||||||
|
|
||||||
public string Name { get; set; } = string.Empty;
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>When true, a lower stored value is a better score (race
|
/// <summary>When true, a lower stored value is a better score (race
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ using System.ComponentModel.DataAnnotations;
|
|||||||
namespace DorkNet.Server.Data.Entities;
|
namespace DorkNet.Server.Data.Entities;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// One leaderboard stat row per (player, statChannel). Backs
|
/// One leaderboard stat row per (room, player, statChannel). Backs
|
||||||
/// <c>api/Leaderboard/v1/SetStats</c> + <c>api/Leaderboard/v2/getPlayerRank</c>.
|
/// <c>api/Leaderboard/v1/SetStats</c> + <c>api/Leaderboard/v2/getPlayerRank</c>.
|
||||||
///
|
///
|
||||||
/// The 2020 watch's <c>RecNet.Leaderboards</c> uses an int channel
|
/// The 2020 watch's <c>RecNet.Leaderboards</c> uses an int channel
|
||||||
@@ -17,6 +17,11 @@ public class LeaderboardStatEntity
|
|||||||
public long Id { get; set; }
|
public long Id { get; set; }
|
||||||
public long PlayerId { get; set; }
|
public long PlayerId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Room the score was reported from. The same stat channel
|
||||||
|
/// id is reused by different rooms, so room scope is part of the
|
||||||
|
/// leaderboard identity. 0 means legacy/global/unscoped.</summary>
|
||||||
|
public long RoomId { get; set; }
|
||||||
|
|
||||||
/// <summary>RecNet stat channel id (small int per game mode +
|
/// <summary>RecNet stat channel id (small int per game mode +
|
||||||
/// stat type — e.g. paintball-CTF-wins, dodgeball-hits).</summary>
|
/// stat type — e.g. paintball-CTF-wins, dodgeball-hits).</summary>
|
||||||
public int StatChannel { get; set; }
|
public int StatChannel { get; set; }
|
||||||
|
|||||||
@@ -1122,7 +1122,7 @@ namespace DorkNet.Server.Migrations
|
|||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.HasKey("Channel");
|
b.HasKey("RoomId", "Channel");
|
||||||
|
|
||||||
b.HasIndex("RoomId");
|
b.HasIndex("RoomId");
|
||||||
|
|
||||||
@@ -1138,6 +1138,9 @@ namespace DorkNet.Server.Migrations
|
|||||||
b.Property<long>("PlayerId")
|
b.Property<long>("PlayerId")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long>("RoomId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
b.Property<int>("StatChannel")
|
b.Property<int>("StatChannel")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
@@ -1149,10 +1152,10 @@ namespace DorkNet.Server.Migrations
|
|||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.HasIndex("PlayerId", "StatChannel")
|
b.HasIndex("RoomId", "PlayerId", "StatChannel")
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
b.HasIndex("StatChannel", "Value");
|
b.HasIndex("RoomId", "StatChannel", "Value");
|
||||||
|
|
||||||
b.ToTable("LeaderboardStats");
|
b.ToTable("LeaderboardStats");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -516,7 +516,7 @@ namespace DorkNet.Server.Migrations
|
|||||||
},
|
},
|
||||||
constraints: table =>
|
constraints: table =>
|
||||||
{
|
{
|
||||||
table.PrimaryKey("PK_LeaderboardChannelMeta", x => x.Channel);
|
table.PrimaryKey("PK_LeaderboardChannelMeta", x => new { x.RoomId, x.Channel });
|
||||||
});
|
});
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
migrationBuilder.CreateTable(
|
||||||
@@ -526,6 +526,7 @@ namespace DorkNet.Server.Migrations
|
|||||||
Id = table.Column<long>(type: "INTEGER", nullable: false)
|
Id = table.Column<long>(type: "INTEGER", nullable: false)
|
||||||
.Annotation("Sqlite:Autoincrement", true),
|
.Annotation("Sqlite:Autoincrement", true),
|
||||||
PlayerId = table.Column<long>(type: "INTEGER", nullable: false),
|
PlayerId = table.Column<long>(type: "INTEGER", nullable: false),
|
||||||
|
RoomId = table.Column<long>(type: "INTEGER", nullable: false),
|
||||||
StatChannel = table.Column<int>(type: "INTEGER", nullable: false),
|
StatChannel = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
Value = table.Column<long>(type: "INTEGER", nullable: false),
|
Value = table.Column<long>(type: "INTEGER", nullable: false),
|
||||||
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: false)
|
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: false)
|
||||||
@@ -1550,15 +1551,15 @@ namespace DorkNet.Server.Migrations
|
|||||||
column: "RoomId");
|
column: "RoomId");
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_LeaderboardStats_PlayerId_StatChannel",
|
name: "IX_LeaderboardStats_RoomId_PlayerId_StatChannel",
|
||||||
table: "LeaderboardStats",
|
table: "LeaderboardStats",
|
||||||
columns: new[] { "PlayerId", "StatChannel" },
|
columns: new[] { "RoomId", "PlayerId", "StatChannel" },
|
||||||
unique: true);
|
unique: true);
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_LeaderboardStats_StatChannel_Value",
|
name: "IX_LeaderboardStats_RoomId_StatChannel_Value",
|
||||||
table: "LeaderboardStats",
|
table: "LeaderboardStats",
|
||||||
columns: new[] { "StatChannel", "Value" });
|
columns: new[] { "RoomId", "StatChannel", "Value" });
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_Messages_RecipientPlayerId_SentAt",
|
name: "IX_Messages_RecipientPlayerId_SentAt",
|
||||||
|
|||||||
@@ -1119,7 +1119,7 @@ namespace DorkNet.Server.Migrations
|
|||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.HasKey("Channel");
|
b.HasKey("RoomId", "Channel");
|
||||||
|
|
||||||
b.HasIndex("RoomId");
|
b.HasIndex("RoomId");
|
||||||
|
|
||||||
@@ -1135,6 +1135,9 @@ namespace DorkNet.Server.Migrations
|
|||||||
b.Property<long>("PlayerId")
|
b.Property<long>("PlayerId")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long>("RoomId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
b.Property<int>("StatChannel")
|
b.Property<int>("StatChannel")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
@@ -1146,10 +1149,10 @@ namespace DorkNet.Server.Migrations
|
|||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.HasIndex("PlayerId", "StatChannel")
|
b.HasIndex("RoomId", "PlayerId", "StatChannel")
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
b.HasIndex("StatChannel", "Value");
|
b.HasIndex("RoomId", "StatChannel", "Value");
|
||||||
|
|
||||||
b.ToTable("LeaderboardStats");
|
b.ToTable("LeaderboardStats");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ public static class DatabaseBootstrap
|
|||||||
// IF NOT EXISTS no-ops when EnsureCreated already built them).
|
// IF NOT EXISTS no-ops when EnsureCreated already built them).
|
||||||
await EnsureSignupCodeTablesAsync(db);
|
await EnsureSignupCodeTablesAsync(db);
|
||||||
await EnsureServerSettingsColumnsAsync(db);
|
await EnsureServerSettingsColumnsAsync(db);
|
||||||
|
await EnsureLeaderboardSchemaAsync(db);
|
||||||
|
|
||||||
// Coach system account at Player.Id=1. The RR-Original room seeder
|
// Coach system account at Player.Id=1. The RR-Original room seeder
|
||||||
// below assigns CreatorPlayerId=1 to every canonical room
|
// below assigns CreatorPlayerId=1 to every canonical room
|
||||||
@@ -302,6 +303,117 @@ public static class DatabaseBootstrap
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task EnsureLeaderboardSchemaAsync(DorkNetDbContext db)
|
||||||
|
{
|
||||||
|
var provider = db.Database.ProviderName ?? string.Empty;
|
||||||
|
if (provider.Contains("Npgsql", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
await db.Database.ExecuteSqlRawAsync(
|
||||||
|
@"ALTER TABLE ""LeaderboardStats"" ADD COLUMN IF NOT EXISTS ""RoomId"" bigint NOT NULL DEFAULT 0;");
|
||||||
|
await db.Database.ExecuteSqlRawAsync(
|
||||||
|
"""
|
||||||
|
UPDATE "LeaderboardStats"
|
||||||
|
SET "RoomId" = COALESCE((
|
||||||
|
SELECT "RoomId"
|
||||||
|
FROM "LeaderboardChannelMeta"
|
||||||
|
WHERE "LeaderboardChannelMeta"."Channel" = "LeaderboardStats"."StatChannel"
|
||||||
|
LIMIT 1
|
||||||
|
), 0)
|
||||||
|
WHERE "RoomId" = 0;
|
||||||
|
""");
|
||||||
|
await db.Database.ExecuteSqlRawAsync(
|
||||||
|
@"DROP INDEX IF EXISTS ""IX_LeaderboardStats_PlayerId_StatChannel"";");
|
||||||
|
await db.Database.ExecuteSqlRawAsync(
|
||||||
|
@"DROP INDEX IF EXISTS ""IX_LeaderboardStats_StatChannel_Value"";");
|
||||||
|
await db.Database.ExecuteSqlRawAsync(
|
||||||
|
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_LeaderboardStats_RoomId_PlayerId_StatChannel""
|
||||||
|
ON ""LeaderboardStats"" (""RoomId"", ""PlayerId"", ""StatChannel"");");
|
||||||
|
await db.Database.ExecuteSqlRawAsync(
|
||||||
|
@"CREATE INDEX IF NOT EXISTS ""IX_LeaderboardStats_RoomId_StatChannel_Value""
|
||||||
|
ON ""LeaderboardStats"" (""RoomId"", ""StatChannel"", ""Value"");");
|
||||||
|
|
||||||
|
await db.Database.ExecuteSqlRawAsync(
|
||||||
|
"""
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
pk_cols text[];
|
||||||
|
BEGIN
|
||||||
|
SELECT array_agg(a.attname ORDER BY x.ordinality)
|
||||||
|
INTO pk_cols
|
||||||
|
FROM pg_constraint c
|
||||||
|
JOIN unnest(c.conkey) WITH ORDINALITY AS x(attnum, ordinality) ON true
|
||||||
|
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = x.attnum
|
||||||
|
WHERE c.conrelid = '"LeaderboardChannelMeta"'::regclass
|
||||||
|
AND c.contype = 'p';
|
||||||
|
|
||||||
|
IF pk_cols IS DISTINCT FROM ARRAY['RoomId', 'Channel'] THEN
|
||||||
|
ALTER TABLE "LeaderboardChannelMeta" DROP CONSTRAINT IF EXISTS "PK_LeaderboardChannelMeta";
|
||||||
|
ALTER TABLE "LeaderboardChannelMeta"
|
||||||
|
ADD CONSTRAINT "PK_LeaderboardChannelMeta" PRIMARY KEY ("RoomId", "Channel");
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
""");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await db.Database.ExecuteSqlRawAsync(
|
||||||
|
@"ALTER TABLE ""LeaderboardStats"" ADD COLUMN ""RoomId"" INTEGER NOT NULL DEFAULT 0;");
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// SQLite has no ADD COLUMN IF NOT EXISTS. Ignore duplicate
|
||||||
|
// column errors; later queries verify the resulting shape.
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.Database.ExecuteSqlRawAsync(
|
||||||
|
"""
|
||||||
|
UPDATE "LeaderboardStats"
|
||||||
|
SET "RoomId" = COALESCE((
|
||||||
|
SELECT "RoomId"
|
||||||
|
FROM "LeaderboardChannelMeta"
|
||||||
|
WHERE "LeaderboardChannelMeta"."Channel" = "LeaderboardStats"."StatChannel"
|
||||||
|
LIMIT 1
|
||||||
|
), 0)
|
||||||
|
WHERE "RoomId" = 0;
|
||||||
|
""");
|
||||||
|
await db.Database.ExecuteSqlRawAsync(
|
||||||
|
@"DROP INDEX IF EXISTS ""IX_LeaderboardStats_PlayerId_StatChannel"";");
|
||||||
|
await db.Database.ExecuteSqlRawAsync(
|
||||||
|
@"DROP INDEX IF EXISTS ""IX_LeaderboardStats_StatChannel_Value"";");
|
||||||
|
await db.Database.ExecuteSqlRawAsync(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS "__LeaderboardChannelMeta_new" (
|
||||||
|
"RoomId" INTEGER NOT NULL,
|
||||||
|
"Channel" INTEGER NOT NULL,
|
||||||
|
"Name" TEXT NOT NULL,
|
||||||
|
"LowerIsBetter" INTEGER NOT NULL,
|
||||||
|
"ValueFormat" TEXT NOT NULL,
|
||||||
|
"CreatedAt" TEXT NOT NULL,
|
||||||
|
"UpdatedAt" TEXT NOT NULL,
|
||||||
|
CONSTRAINT "PK_LeaderboardChannelMeta" PRIMARY KEY ("RoomId", "Channel")
|
||||||
|
);
|
||||||
|
""");
|
||||||
|
await db.Database.ExecuteSqlRawAsync(@"DELETE FROM ""__LeaderboardChannelMeta_new"";");
|
||||||
|
await db.Database.ExecuteSqlRawAsync(
|
||||||
|
"""
|
||||||
|
INSERT OR REPLACE INTO "__LeaderboardChannelMeta_new"
|
||||||
|
("RoomId", "Channel", "Name", "LowerIsBetter", "ValueFormat", "CreatedAt", "UpdatedAt")
|
||||||
|
SELECT "RoomId", "Channel", "Name", "LowerIsBetter", "ValueFormat", "CreatedAt", "UpdatedAt"
|
||||||
|
FROM "LeaderboardChannelMeta";
|
||||||
|
""");
|
||||||
|
await db.Database.ExecuteSqlRawAsync(@"DROP TABLE ""LeaderboardChannelMeta"";");
|
||||||
|
await db.Database.ExecuteSqlRawAsync(
|
||||||
|
@"ALTER TABLE ""__LeaderboardChannelMeta_new"" RENAME TO ""LeaderboardChannelMeta"";");
|
||||||
|
await db.Database.ExecuteSqlRawAsync(
|
||||||
|
@"CREATE INDEX IF NOT EXISTS ""IX_LeaderboardChannelMeta_RoomId"" ON ""LeaderboardChannelMeta"" (""RoomId"");");
|
||||||
|
await db.Database.ExecuteSqlRawAsync(
|
||||||
|
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_LeaderboardStats_RoomId_PlayerId_StatChannel"" ON ""LeaderboardStats"" (""RoomId"", ""PlayerId"", ""StatChannel"");");
|
||||||
|
await db.Database.ExecuteSqlRawAsync(
|
||||||
|
@"CREATE INDEX IF NOT EXISTS ""IX_LeaderboardStats_RoomId_StatChannel_Value"" ON ""LeaderboardStats"" (""RoomId"", ""StatChannel"", ""Value"");");
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>SQLite migration-history reconciliation. Handles two
|
/// <summary>SQLite migration-history reconciliation. Handles two
|
||||||
/// cutover scenarios so legacy DBs boot cleanly without manual SQL:
|
/// cutover scenarios so legacy DBs boot cleanly without manual SQL:
|
||||||
/// <list type="number">
|
/// <list type="number">
|
||||||
|
|||||||
@@ -629,12 +629,14 @@ interface RoomLeaderboardRow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface OrphanRow {
|
interface OrphanRow {
|
||||||
|
roomId: number;
|
||||||
channel: number;
|
channel: number;
|
||||||
entryCount: number;
|
entryCount: number;
|
||||||
lastSeen: string;
|
lastSeen: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ChannelEntries {
|
interface ChannelEntries {
|
||||||
|
roomId: number;
|
||||||
channel: number;
|
channel: number;
|
||||||
name: string;
|
name: string;
|
||||||
lowerIsBetter: boolean;
|
lowerIsBetter: boolean;
|
||||||
@@ -665,7 +667,7 @@ function formatChannelValue(v: number | null | undefined, fmt: string) {
|
|||||||
function LeaderboardsTab({ roomId }: { roomId: number }) {
|
function LeaderboardsTab({ roomId }: { roomId: number }) {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const lb = useApi<RoomLeaderboardRow[]>(`/rooms/${roomId}/leaderboards`);
|
const lb = useApi<RoomLeaderboardRow[]>(`/rooms/${roomId}/leaderboards`);
|
||||||
const orphans = useApi<OrphanRow[]>(`/leaderboards/orphans`);
|
const orphans = useApi<OrphanRow[]>(`/leaderboards/orphans?roomId=${roomId}`);
|
||||||
const [addOpen, setAddOpen] = useState(false);
|
const [addOpen, setAddOpen] = useState(false);
|
||||||
const [adoptChannel, setAdoptChannel] = useState<number | null>(null);
|
const [adoptChannel, setAdoptChannel] = useState<number | null>(null);
|
||||||
const [expanded, setExpanded] = useState<number | null>(null);
|
const [expanded, setExpanded] = useState<number | null>(null);
|
||||||
@@ -709,7 +711,7 @@ function LeaderboardsTab({ roomId }: { roomId: number }) {
|
|||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
if (!confirm(`Unregister channel #${c.channel}? Scores stay, but it stops appearing under this room.`)) return;
|
if (!confirm(`Unregister channel #${c.channel}? Scores stay, but it stops appearing under this room.`)) return;
|
||||||
try {
|
try {
|
||||||
await api(`/leaderboards/meta/${c.channel}`, { method: 'DELETE' });
|
await api(`/leaderboards/meta/${c.channel}?roomId=${roomId}`, { method: 'DELETE' });
|
||||||
toast.push('Unregistered', 'success');
|
toast.push('Unregistered', 'success');
|
||||||
refresh();
|
refresh();
|
||||||
} catch (e) { toast.push((e as Error).message, 'error'); }
|
} catch (e) { toast.push((e as Error).message, 'error'); }
|
||||||
@@ -721,7 +723,7 @@ function LeaderboardsTab({ roomId }: { roomId: number }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{expanded === c.channel && (
|
{expanded === c.channel && (
|
||||||
<ChannelEntriesPanel channel={c.channel} />
|
<ChannelEntriesPanel roomId={roomId} channel={c.channel} />
|
||||||
)}
|
)}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
@@ -768,8 +770,8 @@ function LeaderboardsTab({ roomId }: { roomId: number }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ChannelEntriesPanel({ channel }: { channel: number }) {
|
function ChannelEntriesPanel({ roomId, channel }: { roomId: number; channel: number }) {
|
||||||
const { data, loading, error } = useApi<ChannelEntries>(`/leaderboards/${channel}?take=25`);
|
const { data, loading, error } = useApi<ChannelEntries>(`/leaderboards/${channel}?roomId=${roomId}&take=25`);
|
||||||
if (loading && !data) return <div className="px-6 py-3 text-xs text-ink-400 bg-ink-900/40">Loading…</div>;
|
if (loading && !data) return <div className="px-6 py-3 text-xs text-ink-400 bg-ink-900/40">Loading…</div>;
|
||||||
if (error) return <div className="px-6 py-3 text-xs text-danger bg-ink-900/40">{error}</div>;
|
if (error) return <div className="px-6 py-3 text-xs text-danger bg-ink-900/40">{error}</div>;
|
||||||
if (!data) return null;
|
if (!data) return null;
|
||||||
|
|||||||
Reference in New Issue
Block a user