diff --git a/DorkNet.Server/Controllers/API/Leaderboard/LeaderboardController.cs b/DorkNet.Server/Controllers/API/Leaderboard/LeaderboardController.cs index 5dc7d2c..cd4654a 100644 --- a/DorkNet.Server/Controllers/API/Leaderboard/LeaderboardController.cs +++ b/DorkNet.Server/Controllers/API/Leaderboard/LeaderboardController.cs @@ -78,13 +78,15 @@ public class LeaderboardController( public async Task 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. [HttpGet("/leaderboard/Top")] [HttpGet("/api/leaderboard/Top")] - public async Task Top([FromQuery] int channel, [FromQuery] int take = 50) + public async Task 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 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; + } } diff --git a/DorkNet.Server/Controllers/Admin/AdminController.cs b/DorkNet.Server/Controllers/Admin/AdminController.cs index f3b56cc..6b4e97f 100644 --- a/DorkNet.Server/Controllers/Admin/AdminController.cs +++ b/DorkNet.Server/Controllers/Admin/AdminController.cs @@ -699,28 +699,31 @@ public class AdminController( public async Task 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). [HttpGet("leaderboards/{channel:int}")] - public async Task ChannelDetail(int channel, [FromQuery] int take = 100) + public async Task 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); /// POST api/admin/v1/leaderboards/{channel}/score /// — upsert a single player's score in a channel. @@ -805,12 +808,13 @@ public class AdminController( public async Task 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 DeleteScore(int channel, long playerId) + public async Task 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 }); } /// DELETE api/admin/v1/leaderboards/{channel} — /// wipe every entry in a stat channel. Used for season resets or /// to clear test data. [HttpDelete("leaderboards/{channel:int}")] - public async Task WipeChannel(int channel) + public async Task 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 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. [HttpDelete("leaderboards/meta/{channel:int}")] - public async Task DeleteChannelMeta(int channel) + public async Task 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. [HttpGet("leaderboards/orphans")] - public async Task LeaderboardOrphans() + public async Task 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); } diff --git a/DorkNet.Server/Data/DorkNetDbContext.cs b/DorkNet.Server/Data/DorkNetDbContext.cs index aa9f864..62ad6ed 100644 --- a/DorkNet.Server/Data/DorkNetDbContext.cs +++ b/DorkNet.Server/Data/DorkNetDbContext.cs @@ -497,11 +497,12 @@ public class DorkNetDbContext(DbContextOptions options) : DbCo modelBuilder.Entity(e => { 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. - e.HasIndex(s => new { s.PlayerId, s.StatChannel }).IsUnique(); - // Rank computation: ORDER BY Value DESC over a channel. - e.HasIndex(s => new { s.StatChannel, s.Value }); + e.HasIndex(s => new { s.RoomId, s.PlayerId, s.StatChannel }).IsUnique(); + // Rank computation: ORDER BY Value over a room/channel + // (direction is chosen by leaderboard metadata). + e.HasIndex(s => new { s.RoomId, s.StatChannel, s.Value }); }); modelBuilder.Entity(e => @@ -672,11 +673,9 @@ public class DorkNetDbContext(DbContextOptions options) : DbCo modelBuilder.Entity(e => { - // Channel int IS the primary key — there's only one - // metadata row per stat-channel id, and the channel id - // is what the watch reports on POST /SetStats. - e.HasKey(c => c.Channel); - e.Property(c => c.Channel).ValueGeneratedNever(); + // Channel ids are reused between rooms; a leaderboard is + // identified by (RoomId, Channel), not the channel alone. + e.HasKey(c => new { c.RoomId, c.Channel }); e.HasIndex(c => c.RoomId); }); } diff --git a/DorkNet.Server/Data/Entities/LeaderboardChannelMetaEntity.cs b/DorkNet.Server/Data/Entities/LeaderboardChannelMetaEntity.cs index 2e57a2b..b6956cd 100644 --- a/DorkNet.Server/Data/Entities/LeaderboardChannelMetaEntity.cs +++ b/DorkNet.Server/Data/Entities/LeaderboardChannelMetaEntity.cs @@ -1,7 +1,8 @@ namespace DorkNet.Server.Data.Entities; /// -/// Admin-curated metadata for a leaderboard StatChannel id. The +/// Admin-curated metadata for a room-scoped leaderboard +/// StatChannel id. The /// 2020 watch reports raw integer channel ids via /// POST api/Leaderboard/v1/SetStats; this table lets admins map /// 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 AdminController.KnownStatChannels /// fallback. Channels with no row stay listed as "Channel N". /// -/// One channel id, one row. Repeated SetStats calls for the same -/// channel from any room write to that channel's value rows; the meta -/// row only changes when an admin renames or re-maps it. +/// One row per (room, channel). Repeated SetStats calls for the +/// same channel in different rooms do not share metadata or values. /// public class LeaderboardChannelMetaEntity { - /// Same int the watch reports for this channel. Primary - /// key — there's only ever one meta row per channel. - public int Channel { get; set; } - /// Room this channel "belongs to" for the per-room detail - /// view. 0 = global / unscoped (e.g. cross-room player progression - /// counters). The same room can own many channels (e.g. Stunt - /// Runner has one channel per course). + /// view. 0 = global / unscoped legacy bucket. public long RoomId { get; set; } + /// Same int the watch reports for this channel. Only unique + /// together with . + public int Channel { get; set; } + public string Name { get; set; } = string.Empty; /// When true, a lower stored value is a better score (race diff --git a/DorkNet.Server/Data/Entities/StatsEntities.cs b/DorkNet.Server/Data/Entities/StatsEntities.cs index 050988f..e73ff04 100644 --- a/DorkNet.Server/Data/Entities/StatsEntities.cs +++ b/DorkNet.Server/Data/Entities/StatsEntities.cs @@ -3,7 +3,7 @@ using System.ComponentModel.DataAnnotations; namespace DorkNet.Server.Data.Entities; /// -/// One leaderboard stat row per (player, statChannel). Backs +/// One leaderboard stat row per (room, player, statChannel). Backs /// api/Leaderboard/v1/SetStats + api/Leaderboard/v2/getPlayerRank. /// /// The 2020 watch's RecNet.Leaderboards uses an int channel @@ -17,6 +17,11 @@ public class LeaderboardStatEntity public long Id { get; set; } public long PlayerId { get; set; } + /// 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. + public long RoomId { get; set; } + /// RecNet stat channel id (small int per game mode + /// stat type — e.g. paintball-CTF-wins, dodgeball-hits). public int StatChannel { get; set; } diff --git a/DorkNet.Server/Migrations/20260525181055_Initial.Designer.cs b/DorkNet.Server/Migrations/20260525181055_Initial.Designer.cs index f0005f3..ade6c3e 100644 --- a/DorkNet.Server/Migrations/20260525181055_Initial.Designer.cs +++ b/DorkNet.Server/Migrations/20260525181055_Initial.Designer.cs @@ -1122,7 +1122,7 @@ namespace DorkNet.Server.Migrations .IsRequired() .HasColumnType("TEXT"); - b.HasKey("Channel"); + b.HasKey("RoomId", "Channel"); b.HasIndex("RoomId"); @@ -1138,6 +1138,9 @@ namespace DorkNet.Server.Migrations b.Property("PlayerId") .HasColumnType("INTEGER"); + b.Property("RoomId") + .HasColumnType("INTEGER"); + b.Property("StatChannel") .HasColumnType("INTEGER"); @@ -1149,10 +1152,10 @@ namespace DorkNet.Server.Migrations b.HasKey("Id"); - b.HasIndex("PlayerId", "StatChannel") + b.HasIndex("RoomId", "PlayerId", "StatChannel") .IsUnique(); - b.HasIndex("StatChannel", "Value"); + b.HasIndex("RoomId", "StatChannel", "Value"); b.ToTable("LeaderboardStats"); }); diff --git a/DorkNet.Server/Migrations/20260525181055_Initial.cs b/DorkNet.Server/Migrations/20260525181055_Initial.cs index 0389cb9..89b0225 100644 --- a/DorkNet.Server/Migrations/20260525181055_Initial.cs +++ b/DorkNet.Server/Migrations/20260525181055_Initial.cs @@ -516,7 +516,7 @@ namespace DorkNet.Server.Migrations }, constraints: table => { - table.PrimaryKey("PK_LeaderboardChannelMeta", x => x.Channel); + table.PrimaryKey("PK_LeaderboardChannelMeta", x => new { x.RoomId, x.Channel }); }); migrationBuilder.CreateTable( @@ -526,6 +526,7 @@ namespace DorkNet.Server.Migrations Id = table.Column(type: "INTEGER", nullable: false) .Annotation("Sqlite:Autoincrement", true), PlayerId = table.Column(type: "INTEGER", nullable: false), + RoomId = table.Column(type: "INTEGER", nullable: false), StatChannel = table.Column(type: "INTEGER", nullable: false), Value = table.Column(type: "INTEGER", nullable: false), UpdatedAt = table.Column(type: "TEXT", nullable: false) @@ -1550,15 +1551,15 @@ namespace DorkNet.Server.Migrations column: "RoomId"); migrationBuilder.CreateIndex( - name: "IX_LeaderboardStats_PlayerId_StatChannel", + name: "IX_LeaderboardStats_RoomId_PlayerId_StatChannel", table: "LeaderboardStats", - columns: new[] { "PlayerId", "StatChannel" }, + columns: new[] { "RoomId", "PlayerId", "StatChannel" }, unique: true); migrationBuilder.CreateIndex( - name: "IX_LeaderboardStats_StatChannel_Value", + name: "IX_LeaderboardStats_RoomId_StatChannel_Value", table: "LeaderboardStats", - columns: new[] { "StatChannel", "Value" }); + columns: new[] { "RoomId", "StatChannel", "Value" }); migrationBuilder.CreateIndex( name: "IX_Messages_RecipientPlayerId_SentAt", diff --git a/DorkNet.Server/Migrations/DorkNetDbContextModelSnapshot.cs b/DorkNet.Server/Migrations/DorkNetDbContextModelSnapshot.cs index e2570f7..d598652 100644 --- a/DorkNet.Server/Migrations/DorkNetDbContextModelSnapshot.cs +++ b/DorkNet.Server/Migrations/DorkNetDbContextModelSnapshot.cs @@ -1119,7 +1119,7 @@ namespace DorkNet.Server.Migrations .IsRequired() .HasColumnType("TEXT"); - b.HasKey("Channel"); + b.HasKey("RoomId", "Channel"); b.HasIndex("RoomId"); @@ -1135,6 +1135,9 @@ namespace DorkNet.Server.Migrations b.Property("PlayerId") .HasColumnType("INTEGER"); + b.Property("RoomId") + .HasColumnType("INTEGER"); + b.Property("StatChannel") .HasColumnType("INTEGER"); @@ -1146,10 +1149,10 @@ namespace DorkNet.Server.Migrations b.HasKey("Id"); - b.HasIndex("PlayerId", "StatChannel") + b.HasIndex("RoomId", "PlayerId", "StatChannel") .IsUnique(); - b.HasIndex("StatChannel", "Value"); + b.HasIndex("RoomId", "StatChannel", "Value"); b.ToTable("LeaderboardStats"); }); diff --git a/DorkNet.Server/Startup/DatabaseBootstrap.cs b/DorkNet.Server/Startup/DatabaseBootstrap.cs index 7857bdf..5469c31 100644 --- a/DorkNet.Server/Startup/DatabaseBootstrap.cs +++ b/DorkNet.Server/Startup/DatabaseBootstrap.cs @@ -101,6 +101,7 @@ public static class DatabaseBootstrap // IF NOT EXISTS no-ops when EnsureCreated already built them). await EnsureSignupCodeTablesAsync(db); await EnsureServerSettingsColumnsAsync(db); + await EnsureLeaderboardSchemaAsync(db); // Coach system account at Player.Id=1. The RR-Original room seeder // 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"");"); + } + /// SQLite migration-history reconciliation. Handles two /// cutover scenarios so legacy DBs boot cleanly without manual SQL: /// diff --git a/DorkNet.Server/admin-ui/src/pages/Room.tsx b/DorkNet.Server/admin-ui/src/pages/Room.tsx index e699779..12d6c07 100644 --- a/DorkNet.Server/admin-ui/src/pages/Room.tsx +++ b/DorkNet.Server/admin-ui/src/pages/Room.tsx @@ -629,12 +629,14 @@ interface RoomLeaderboardRow { } interface OrphanRow { + roomId: number; channel: number; entryCount: number; lastSeen: string; } interface ChannelEntries { + roomId: number; channel: number; name: string; lowerIsBetter: boolean; @@ -665,7 +667,7 @@ function formatChannelValue(v: number | null | undefined, fmt: string) { function LeaderboardsTab({ roomId }: { roomId: number }) { const toast = useToast(); const lb = useApi(`/rooms/${roomId}/leaderboards`); - const orphans = useApi(`/leaderboards/orphans`); + const orphans = useApi(`/leaderboards/orphans?roomId=${roomId}`); const [addOpen, setAddOpen] = useState(false); const [adoptChannel, setAdoptChannel] = useState(null); const [expanded, setExpanded] = useState(null); @@ -709,7 +711,7 @@ function LeaderboardsTab({ roomId }: { roomId: number }) { onClick={async () => { if (!confirm(`Unregister channel #${c.channel}? Scores stay, but it stops appearing under this room.`)) return; try { - await api(`/leaderboards/meta/${c.channel}`, { method: 'DELETE' }); + await api(`/leaderboards/meta/${c.channel}?roomId=${roomId}`, { method: 'DELETE' }); toast.push('Unregistered', 'success'); refresh(); } catch (e) { toast.push((e as Error).message, 'error'); } @@ -721,7 +723,7 @@ function LeaderboardsTab({ roomId }: { roomId: number }) { {expanded === c.channel && ( - + )} ))} @@ -768,8 +770,8 @@ function LeaderboardsTab({ roomId }: { roomId: number }) { ); } -function ChannelEntriesPanel({ channel }: { channel: number }) { - const { data, loading, error } = useApi(`/leaderboards/${channel}?take=25`); +function ChannelEntriesPanel({ roomId, channel }: { roomId: number; channel: number }) { + const { data, loading, error } = useApi(`/leaderboards/${channel}?roomId=${roomId}&take=25`); if (loading && !data) return
Loading…
; if (error) return
{error}
; if (!data) return null;