From a8d7ab49440401117d6f7aafa04ad885d21861a2 Mon Sep 17 00:00:00 2001 From: Alexa <53471801+Alexa-RR@users.noreply.github.com> Date: Fri, 3 Jul 2026 22:05:20 +0300 Subject: [PATCH] feat: Add tests for room visit visibility and bootstrap logic; update versioning and image references --- .../CanonicalRoomBootstrapTests.cs | 47 ++++++++ DorkNet.Server.Tests/DorkNetServerFactory.cs | 4 +- .../GameClientEndpointContractTests.cs | 4 +- DorkNet.Server.Tests/GameClientSession.cs | 8 +- DorkNet.Server.Tests/RoomsVisitedByTests.cs | 104 ++++++++++++++++++ .../VersionCheckControllerTests.cs | 8 +- .../API/Rooms/V2/RoomsController.cs | 33 +++--- DorkNet.Server/Data/LegacyUpgrades.cs | 1 + DorkNet.Server/Data/room_images.json | 1 + DorkNet.Server/Services/RoomService.cs | 65 ++++++++++- 10 files changed, 245 insertions(+), 30 deletions(-) create mode 100644 DorkNet.Server.Tests/CanonicalRoomBootstrapTests.cs create mode 100644 DorkNet.Server.Tests/RoomsVisitedByTests.cs diff --git a/DorkNet.Server.Tests/CanonicalRoomBootstrapTests.cs b/DorkNet.Server.Tests/CanonicalRoomBootstrapTests.cs new file mode 100644 index 0000000..394e68f --- /dev/null +++ b/DorkNet.Server.Tests/CanonicalRoomBootstrapTests.cs @@ -0,0 +1,47 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using DorkNet.Server.Data; + +namespace DorkNet.Server.Tests; + +public sealed class CanonicalRoomBootstrapTests : IClassFixture +{ + private readonly DorkNetServerFactory _factory; + + public CanonicalRoomBootstrapTests(DorkNetServerFactory factory) => _factory = factory; + + [Fact] + public async Task Bootstrap_seeds_stunt_runner_subroom_and_rec_rally_remote_image_name() + { + using var _ = _factory.CreateClient(new() { AllowAutoRedirect = false }); + + await using var scope = _factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var stuntRunner = await db.Rooms.SingleAsync(r => r.Name == "StuntRunner"); + var stuntRunnerScenes = await db.RoomScenes + .Where(s => s.RoomId == stuntRunner.Id) + .OrderBy(s => s.OrderIndex) + .ToListAsync(); + + Assert.Collection( + stuntRunnerScenes, + scene => + { + Assert.Equal(0, scene.OrderIndex); + Assert.Equal("StuntRunner", scene.Name); + Assert.Equal("b7281665-a715-4051-826b-8e08e69c6172", scene.RoomSceneLocationId); + Assert.Equal("", scene.DataBlobName); + }, + scene => + { + Assert.Equal(1, scene.OrderIndex); + Assert.Equal("TheMainEvent", scene.Name); + Assert.Equal("3a636bd2-f896-424c-9225-c184522c0d87", scene.RoomSceneLocationId); + Assert.Equal("", scene.DataBlobName); + }); + + var recRally = await db.Rooms.SingleAsync(r => r.Name == "RecRally"); + Assert.Equal("image_RecRally.jpg", recRally.ImageName); + } +} diff --git a/DorkNet.Server.Tests/DorkNetServerFactory.cs b/DorkNet.Server.Tests/DorkNetServerFactory.cs index 58f73cf..30c2246 100644 --- a/DorkNet.Server.Tests/DorkNetServerFactory.cs +++ b/DorkNet.Server.Tests/DorkNetServerFactory.cs @@ -38,8 +38,8 @@ public sealed class DorkNetServerFactory : WebApplicationFactory ["S3:Endpoint"] = "", ["S3:AccessKey"] = "", ["S3:SecretKey"] = "", - ["DorkNet:DefaultClientVersion"] = "december_2020_12_18", - ["DorkNet:SupportedVersions:0"] = "december_2020_12_18", + ["DorkNet:DefaultClientVersion"] = "march_2023_03_21", + ["DorkNet:SupportedVersions:0"] = "march_2023_03_21", }); }); builder.ConfigureServices(services => diff --git a/DorkNet.Server.Tests/GameClientEndpointContractTests.cs b/DorkNet.Server.Tests/GameClientEndpointContractTests.cs index 2d7b860..c08eba1 100644 --- a/DorkNet.Server.Tests/GameClientEndpointContractTests.cs +++ b/DorkNet.Server.Tests/GameClientEndpointContractTests.cs @@ -61,8 +61,8 @@ public sealed class GameClientEndpointContractTests : ToHttpMethod(route.Method), new Uri($"http://{host}{path}")); request.Headers.Accept.ParseAdd("application/json"); - request.Headers.UserAgent.ParseAdd("RecRoom/2020.12.18"); - request.Headers.TryAddWithoutValidation("X-DorkNet-Version", "december_2020_12_18"); + request.Headers.UserAgent.ParseAdd("RecRoom/2023.03.21"); + request.Headers.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21"); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken); if (NeedsRequestBody(request.Method)) diff --git a/DorkNet.Server.Tests/GameClientSession.cs b/DorkNet.Server.Tests/GameClientSession.cs index cfc519e..dcc2318 100644 --- a/DorkNet.Server.Tests/GameClientSession.cs +++ b/DorkNet.Server.Tests/GameClientSession.cs @@ -21,8 +21,8 @@ public static class GameClientSessionFactory HttpMethod.Post, new Uri($"http://auth.{apexDomain}/connect/token")); tokenRequest.Headers.Accept.ParseAdd("application/json"); - tokenRequest.Headers.UserAgent.ParseAdd("RecRoom/2020.12.18"); - tokenRequest.Headers.TryAddWithoutValidation("X-DorkNet-Version", "december_2020_12_18"); + tokenRequest.Headers.UserAgent.ParseAdd("RecRoom/2023.03.21"); + tokenRequest.Headers.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21"); tokenRequest.Content = new FormUrlEncodedContent(new Dictionary { ["grant_type"] = "password", @@ -49,8 +49,8 @@ public static class GameClientSessionFactory HttpMethod.Get, new Uri($"http://auth.{apexDomain}/connect/userinfo")); userInfoRequest.Headers.Accept.ParseAdd("application/json"); - userInfoRequest.Headers.UserAgent.ParseAdd("RecRoom/2020.12.18"); - userInfoRequest.Headers.TryAddWithoutValidation("X-DorkNet-Version", "december_2020_12_18"); + userInfoRequest.Headers.UserAgent.ParseAdd("RecRoom/2023.03.21"); + userInfoRequest.Headers.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21"); userInfoRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); using var userInfoResponse = await client.SendAsync(userInfoRequest); diff --git a/DorkNet.Server.Tests/RoomsVisitedByTests.cs b/DorkNet.Server.Tests/RoomsVisitedByTests.cs new file mode 100644 index 0000000..fb6fd33 --- /dev/null +++ b/DorkNet.Server.Tests/RoomsVisitedByTests.cs @@ -0,0 +1,104 @@ +using System.Net.Http.Headers; +using System.Text.Json; +using DorkNet.Server.Data; +using DorkNet.Server.Data.Entities; +using DorkNet.Server.Services; +using Microsoft.Extensions.DependencyInjection; + +namespace DorkNet.Server.Tests; + +public sealed class RoomsVisitedByTests : IClassFixture +{ + private readonly DorkNetServerFactory _factory; + + public RoomsVisitedByTests(DorkNetServerFactory factory) => _factory = factory; + + [Fact] + public async Task Continue_rooms_hide_private_rooms_from_non_owners() + { + using var setupClient = ApiClient(); + var owner = await GameClientSessionFactory.CreateAsync(setupClient, _factory.ApexDomain); + var visitor = await GameClientSessionFactory.CreateAsync(setupClient, _factory.ApexDomain); + + var suffix = Guid.NewGuid().ToString("N")[..8]; + var publicRoomId = 9_100_001L; + var privateRoomId = 9_100_002L; + var publicRoomName = $"ContinuePublic_{suffix}"; + var privateRoomName = $"ContinuePrivate_{suffix}"; + var now = DateTime.UtcNow; + + await using (var scope = _factory.Services.CreateAsyncScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.Rooms.AddRange( + BuildRoom(publicRoomId, publicRoomName, owner.PlayerId, accessibility: 1), + BuildRoom(privateRoomId, privateRoomName, owner.PlayerId, accessibility: 0)); + db.RoomVisits.AddRange( + BuildVisit(publicRoomId, visitor.PlayerId, now.AddMinutes(-2)), + BuildVisit(privateRoomId, visitor.PlayerId, now.AddMinutes(-1)), + BuildVisit(privateRoomId, owner.PlayerId, now)); + await db.SaveChangesAsync(); + } + + using var visitorClient = ApiClient(visitor); + var visitorNames = await GetContinueRoomNamesAsync(visitorClient); + + Assert.Contains(publicRoomName, visitorNames); + Assert.DoesNotContain(privateRoomName, visitorNames); + + using var ownerClient = ApiClient(owner); + var ownerNames = await GetContinueRoomNamesAsync(ownerClient); + + Assert.Contains(privateRoomName, ownerNames); + } + + private HttpClient ApiClient(GameClientSession? session = null) + { + var client = _factory.CreateClient(new() { AllowAutoRedirect = false }); + client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}"); + client.Timeout = TimeSpan.FromSeconds(30); + client.DefaultRequestHeaders.Accept.ParseAdd("application/json"); + client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21"); + client.DefaultRequestHeaders.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21"); + if (session is not null) + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken); + return client; + } + + private static async Task GetContinueRoomNamesAsync(HttpClient client) + { + using var response = await client.GetAsync("/rooms/visitedby/me"); + response.EnsureSuccessStatusCode(); + + await using var body = await response.Content.ReadAsStreamAsync(); + using var document = await JsonDocument.ParseAsync(body); + return document.RootElement + .EnumerateArray() + .Select(room => room.GetProperty("Name").GetString() ?? string.Empty) + .ToArray(); + } + + private static RoomEntity BuildRoom(long id, string name, long creatorPlayerId, int accessibility) => new() + { + Id = id, + Name = name, + Description = name, + CreatorPlayerId = creatorPlayerId, + ImageName = RoomService.DefaultRoomImageName, + State = 0, + Accessibility = accessibility, + IsAGRoom = true, + IsDormRoom = false, + HiddenFromBrowse = false, + TagsCsv = "community", + }; + + private static RoomVisitEntity BuildVisit(long roomId, long playerId, DateTime visitedAt) => new() + { + RoomId = roomId, + PlayerId = playerId, + FirstVisitAt = visitedAt, + LastVisitAt = visitedAt, + VisitCount = 1, + }; +} diff --git a/DorkNet.Server.Tests/VersionCheckControllerTests.cs b/DorkNet.Server.Tests/VersionCheckControllerTests.cs index e63e2e0..ab43d2d 100644 --- a/DorkNet.Server.Tests/VersionCheckControllerTests.cs +++ b/DorkNet.Server.Tests/VersionCheckControllerTests.cs @@ -11,11 +11,11 @@ namespace DorkNet.Server.Tests; public sealed class VersionCheckControllerTests { [Fact] - public void December_2020_build_is_valid_with_minimal_service_config() + public void March_2023_build_is_valid_with_minimal_service_config() { var controller = CreateController(); - var result = Assert.IsType(controller.Check("20201210", "0")); + var result = Assert.IsType(controller.Check("20230321", "0")); Assert.Equal(0, ReadVersionStatus(result.Value)); } @@ -37,9 +37,9 @@ public sealed class VersionCheckControllerTests [new Late2020VersionPlugin()], new HashSet(StringComparer.OrdinalIgnoreCase) { - "december_2020_12_18", + "march_2023_03_21", }, - "december_2020_12_18"); + "march_2023_03_21"); return new VersionCheckController( config, diff --git a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs index 0580cbc..fcd3738 100644 --- a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs +++ b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs @@ -2078,31 +2078,34 @@ public class RoomsController( { var pid = CurrentPlayerId; if (pid is null) return Ok(Array.Empty()); - return await VisitedBy(pid.Value); + return await VisitedByForViewerAsync(pid.Value, pid.Value); } [HttpGet("rooms/visitedby/{playerId:long}")] [HttpGet("roomserver/rooms/visitedby/{playerId:long}")] public async Task VisitedBy(long playerId) { - var ids = await db.RoomVisits.AsNoTracking() + return await VisitedByForViewerAsync(playerId, CurrentPlayerId); + } + + private async Task VisitedByForViewerAsync(long playerId, long? viewerPlayerId) + { + var rows = await db.RoomVisits.AsNoTracking() .Where(v => v.PlayerId == playerId) - .OrderByDescending(v => v.LastVisitAt) - .Select(v => v.RoomId) + .Join( + db.Rooms.AsNoTracking(), + visit => visit.RoomId, + room => room.Id, + (visit, room) => new { visit, room }) + .Where(x => x.room.Accessibility == 1 || + (viewerPlayerId.HasValue && x.room.CreatorPlayerId == viewerPlayerId.Value)) + .OrderByDescending(x => x.visit.LastVisitAt) + .Select(x => x.room) .Take(100) .ToListAsync(); - if (ids.Count == 0) return Ok(Array.Empty()); - var rows = await db.Rooms.AsNoTracking() - .Where(r => ids.Contains(r.Id)) - .ToListAsync(); - var byId = rows.ToDictionary(r => r.Id); - var ordered = ids - .Select(id => byId.TryGetValue(id, out var room) ? room : null) - .Where(r => r is not null) - .Select(r => r!) - .ToList(); - return Ok(await BuildRoomServerListAsync(ordered)); + if (rows.Count == 0) return Ok(Array.Empty()); + return Ok(await BuildRoomServerListAsync(rows)); } [HttpGet("api/rooms/v1/moderatedby/me")] diff --git a/DorkNet.Server/Data/LegacyUpgrades.cs b/DorkNet.Server/Data/LegacyUpgrades.cs index be11fb0..7da61cb 100644 --- a/DorkNet.Server/Data/LegacyUpgrades.cs +++ b/DorkNet.Server/Data/LegacyUpgrades.cs @@ -75,6 +75,7 @@ public static class LegacyUpgrades ["Park"] = "4o5lschc01nani8xeywao622n.png", ["Soccer"] = "apo2sd115tuwueyoprp8jhx7b.png", ["StuntRunner"] = "8j4f516axpnpjgnj342t85vkd.png", + ["RecRally"] = "image_RecRally.jpg", }; var names = canonical.Keys.ToArray(); diff --git a/DorkNet.Server/Data/room_images.json b/DorkNet.Server/Data/room_images.json index b62e57a..3b148a9 100644 --- a/DorkNet.Server/Data/room_images.json +++ b/DorkNet.Server/Data/room_images.json @@ -15,6 +15,7 @@ "RecCenter": "50xrbhogfdae0mcgzfkomlghu.png", "RecRoyaleSolos": "8ners1eg89dy974ixbim40sky.png", "RecRoyaleSquads": "0c1qdk77fbl8c5slphl3k1t8e.png", + "RecRally": "image_RecRally.jpg", "Soccer": "apo2sd115tuwueyoprp8jhx7b.png", "StuntRunner": "8j4f516axpnpjgnj342t85vkd.png", "TheRiseofJumbotron": "8x6qaj4nlbudf1412tupxkq9n.png" diff --git a/DorkNet.Server/Services/RoomService.cs b/DorkNet.Server/Services/RoomService.cs index fcdba91..d090eea 100644 --- a/DorkNet.Server/Services/RoomService.cs +++ b/DorkNet.Server/Services/RoomService.cs @@ -108,7 +108,7 @@ public class RoomService(DorkNetDbContext db) ("IsleOfLostSkulls", "The Isle of Lost Skulls", "Can your pirate crew get to the Isle, defeat its fearsome guardian, and escape with the gold?", "7e01cfe0-820a-406f-b1b3-0a5bf575235c", "recroomoriginal,quest,co-op,adventure", "image_IsleOfLostSkulls.png"), ("Crescendo", "Crescendo of the Blood Moon", "Brave the haunted halls of Castle Dracula and survive the night.", "49cb8993-a956-43e2-86f4-1318f279b22a", "recroomoriginal,quest,co-op,adventure", "by3mjs9jbozpdvu6g9aje7jgz.png"), ("StuntRunner", "Stunt Runner", "A solo platforming gauntlet — sprint, climb and dodge to reach the trophy at the top.", "b7281665-a715-4051-826b-8e08e69c6172", "recroomoriginal,sport,quest,stuntrunner,parkour", "image_StuntRunner.png"), - ("RecRally", "Rec Rally", "Race across Chaparral with friends in off-road vehicles built for jumps, boosts, and tight turns.", "56193568-9ae0-498c-8a77-4df79dec91f5", "recroomoriginal,featured,sport,recrally,racing,pvp", "image_RecRally.png"), + ("RecRally", "Rec Rally", "Race across Chaparral with friends in off-road vehicles built for jumps, boosts, and tight turns.", "56193568-9ae0-498c-8a77-4df79dec91f5", "recroomoriginal,featured,sport,recrally,racing,pvp", "image_RecRally.jpg"), ("Drive-In", "Rec Drive-In", "Watch movies, hang out with friends, or chill at the bar.", "65ddbb48-5a01-4e3e-972d-e5c7419e2bc3", "recroomoriginal,featured,hangout,chill", "image_DriveIn.png"), // Hub / hang rooms — no specific gameplay, just shared spaces. ("Park", "The Park", "An outdoor park with picnic tables and lawn games — a chill place to hang out.", "0a864c86-5a71-4e18-8041-8124e4dc9d98", "recroomoriginal,featured,hangout,chill", "image_Park.png"), @@ -193,7 +193,7 @@ public class RoomService(DorkNetDbContext db) ["Quarry"] = "image_Quarry.png", ["Clearcut"] = "image_Clearcut.png", ["Spillway"] = "image_Spillway.png", - ["RecRally"] = "image_RecRally.png", + ["RecRally"] = "image_RecRally.jpg", }; var candidates = new[] { @@ -371,7 +371,7 @@ public class RoomService(DorkNetDbContext db) ("BowlingAlley", "Bowling Alley", "Classic ten-pin bowling. Roll strikes, beat your friends.", "ae929543-9a07-41d5-8ee9-dbbee8c36800", "recroomoriginal,sport", "image_BowlingAlley.png"), ("Crescendo", "Crescendo of the Blood Moon", "Brave the haunted halls of Castle Dracula and survive the night.", "49cb8993-a956-43e2-86f4-1318f279b22a", "recroomoriginal,quest", "by3mjs9jbozpdvu6g9aje7jgz.png"), ("LaserTagHangar", "Laser Tag Hangar", "Teams battle in an industrial warehouse map.", "239e676c-f12f-489f-bf3a-d4c383d692c3", "recroomoriginal,sport", "image_LaserTag.png"), - ("RecRally", "Rec Rally", "Race across Chaparral with friends in off-road vehicles built for jumps, boosts, and tight turns.", "56193568-9ae0-498c-8a77-4df79dec91f5", "recroomoriginal,featured,sport,recrally,racing,pvp", "image_RecRally.png"), + ("RecRally", "Rec Rally", "Race across Chaparral with friends in off-road vehicles built for jumps, boosts, and tight turns.", "56193568-9ae0-498c-8a77-4df79dec91f5", "recroomoriginal,featured,sport,recrally,racing,pvp", "image_RecRally.jpg"), ("MakerRoom", "Maker Room", "A blank-walled canvas. The classic starting point for a Maker Pen build.", "a75f7547-79eb-47c6-8986-6767abcb4f92", "recroomoriginal,template,featured", "image_RecCenter.png"), }; @@ -1229,6 +1229,21 @@ public class RoomService(DorkNetDbContext db) await EnsureScenesAsync(lasertag.Id, lasertagScenes); } + // Stunt Runner's lobby contains a portal to TheMainEvent. The + // client resolves that sub-room from RoomDetails before it sends + // /goto/room/StuntRunner/TheMainEvent, so both scenes must be in + // RoomScenes, with OrderIndex 0 matching the base lobby join. + var stuntRunner = await db.Rooms.FirstOrDefaultAsync(r => r.Name == "StuntRunner"); + if (stuntRunner is not null) + { + var stuntRunnerScenes = new (int OrderIndex, string Name, string Location)[] + { + (0, "StuntRunner", "b7281665-a715-4051-826b-8e08e69c6172"), + (1, "TheMainEvent", "3a636bd2-f896-424c-9225-c184522c0d87"), + }; + await EnsureOrderedScenesAsync(stuntRunner.Id, stuntRunnerScenes); + } + await db.SaveChangesAsync(); } @@ -1261,6 +1276,50 @@ public class RoomService(DorkNetDbContext db) } } + /// Upserts canonical scenes whose order is part of the + /// client contract. Used for baked rooms where in-world portals reference + /// the scene names and the base room join must keep SubRoomId=0. + private async Task EnsureOrderedScenesAsync( + long roomId, + (int OrderIndex, string Name, string Location)[] scenes) + { + var existing = await db.RoomScenes + .Where(s => s.RoomId == roomId) + .ToListAsync(); + var existingByName = existing + .GroupBy(s => s.Name, StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase); + + foreach (var (orderIndex, name, location) in scenes) + { + if (!existingByName.TryGetValue(name, out var scene)) + { + db.RoomScenes.Add(new RoomSceneEntity + { + RoomId = roomId, + OrderIndex = orderIndex, + Name = name, + RoomSceneLocationId = location, + DataBlobName = "", + MaxPlayers = 8, + IsSandbox = false, + CanMatchmakeInto = true, + DataModifiedAt = DateTime.UtcNow, + }); + continue; + } + + scene.OrderIndex = orderIndex; + scene.RoomSceneLocationId = location; + scene.DataBlobName = ""; + scene.MaxPlayers = 8; + scene.IsSandbox = false; + scene.CanMatchmakeInto = true; + if (scene.DataModifiedAt == default) + scene.DataModifiedAt = DateTime.UtcNow; + } + } + private static async Task TryDownloadAsync(HttpClient http, string url, string targetPath) { // Skip if already on disk — admins can delete the file to force