mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
Implement Play Menu Tags management: add endpoints for retrieving and updating tags; enhance database schema and UI integration
This commit is contained in:
@@ -33,6 +33,7 @@ public class RoomsController(
|
|||||||
OnlinePresenceService onlinePresence,
|
OnlinePresenceService onlinePresence,
|
||||||
DomainConfig domain,
|
DomainConfig domain,
|
||||||
NotificationService notifications,
|
NotificationService notifications,
|
||||||
|
ServerSettingsService serverSettings,
|
||||||
ILogger<RoomsController> logger) : ControllerBase
|
ILogger<RoomsController> logger) : ControllerBase
|
||||||
{
|
{
|
||||||
private long? CurrentPlayerId => ControllerBaseExtensions.CurrentPlayerId(this);
|
private long? CurrentPlayerId => ControllerBaseExtensions.CurrentPlayerId(this);
|
||||||
@@ -143,23 +144,30 @@ public class RoomsController(
|
|||||||
// '#' at render time, so sending "#community" produces "##community" in
|
// '#' at render time, so sending "#community" produces "##community" in
|
||||||
// the UI. Names stored in the DB also drop the prefix.
|
// the UI. Names stored in the DB also drop the prefix.
|
||||||
[HttpGet("api/rooms/v1/filters")]
|
[HttpGet("api/rooms/v1/filters")]
|
||||||
public IActionResult Filters() => Ok(new
|
public async Task<IActionResult> Filters()
|
||||||
{
|
{
|
||||||
PinnedFilters = new[] { "community", "recroomoriginal", "featured", "quest" },
|
var tags = await serverSettings.GetPlayMenuTagsAsync();
|
||||||
PopularFilters = new[] { "paintball", "dodgeball", "soccer", "lasertag", "discgolf" },
|
return Ok(new
|
||||||
});
|
{
|
||||||
|
PinnedFilters = tags.PinnedTags,
|
||||||
|
PopularFilters = tags.PopularTags,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet("api/rooms/v1/tags")]
|
[HttpGet("api/rooms/v1/tags")]
|
||||||
public IActionResult Tags() => Ok(new[] {
|
public async Task<IActionResult> Tags()
|
||||||
"community", "recroomoriginal", "featured", "quest",
|
{
|
||||||
"paintball", "dodgeball", "soccer", "lasertag", "discgolf",
|
var tags = await serverSettings.GetPlayMenuTagsAsync();
|
||||||
});
|
return Ok(tags.PinnedTags.Concat(tags.PopularTags).Distinct(StringComparer.OrdinalIgnoreCase).ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet("api/rooms/v1/pinnedtags")]
|
[HttpGet("api/rooms/v1/pinnedtags")]
|
||||||
public IActionResult PinnedTags() => Ok(new[] { "community", "recroomoriginal", "featured" });
|
public async Task<IActionResult> PinnedTags()
|
||||||
|
=> Ok((await serverSettings.GetPlayMenuTagsAsync()).PinnedTags);
|
||||||
|
|
||||||
[HttpGet("api/rooms/v1/populartags")]
|
[HttpGet("api/rooms/v1/populartags")]
|
||||||
public IActionResult PopularTags() => Ok(new[] { "paintball", "dodgeball", "soccer" });
|
public async Task<IActionResult> PopularTags()
|
||||||
|
=> Ok((await serverSettings.GetPlayMenuTagsAsync()).PopularTags);
|
||||||
|
|
||||||
// ── Single room lookups ──────────────────────────────────────────────
|
// ── Single room lookups ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -1494,6 +1494,26 @@ public class AdminController(
|
|||||||
return Ok(weekly);
|
return Ok(weekly);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("settings/play-menu-tags")]
|
||||||
|
public async Task<ActionResult> GetPlayMenuTags()
|
||||||
|
=> Ok(await serverSettings.GetPlayMenuTagsAsync());
|
||||||
|
|
||||||
|
public sealed record PlayMenuTagsRequest(
|
||||||
|
List<string>? PinnedTags,
|
||||||
|
List<string>? PopularTags);
|
||||||
|
|
||||||
|
[HttpPost("settings/play-menu-tags")]
|
||||||
|
public async Task<ActionResult> SetPlayMenuTags([FromBody] PlayMenuTagsRequest body)
|
||||||
|
{
|
||||||
|
var tags = await serverSettings.SetPlayMenuTagsAsync(
|
||||||
|
body.PinnedTags ?? [],
|
||||||
|
body.PopularTags ?? []);
|
||||||
|
await LogAsync("play_menu_tags_updated", "system", 0,
|
||||||
|
$"pinned={string.Join(",", tags.PinnedTags)} popular={string.Join(",", tags.PopularTags)}");
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return Ok(tags);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Signup codes ─────────────────────────────────────────────────────
|
// ── Signup codes ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>GET <c>api/admin/v1/signup-codes</c> — every issued code
|
/// <summary>GET <c>api/admin/v1/signup-codes</c> — every issued code
|
||||||
|
|||||||
@@ -609,6 +609,7 @@ public class DorkNetDbContext(DbContextOptions<DorkNetDbContext> options) : DbCo
|
|||||||
e.Property(s => s.WeeklyChallengesCompletedRequired).HasDefaultValue(true);
|
e.Property(s => s.WeeklyChallengesCompletedRequired).HasDefaultValue(true);
|
||||||
e.Property(s => s.WeeklyChallengesJson).HasDefaultValue(string.Empty);
|
e.Property(s => s.WeeklyChallengesJson).HasDefaultValue(string.Empty);
|
||||||
e.Property(s => s.WeeklyChallengeRewardJson).HasDefaultValue(string.Empty);
|
e.Property(s => s.WeeklyChallengeRewardJson).HasDefaultValue(string.Empty);
|
||||||
|
e.Property(s => s.PlayMenuTagsJson).HasDefaultValue(string.Empty);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity<RoomKeyEntity>(e =>
|
modelBuilder.Entity<RoomKeyEntity>(e =>
|
||||||
|
|||||||
@@ -38,5 +38,10 @@ public class ServerSettingsEntity
|
|||||||
/// server-side XP/token grant.</summary>
|
/// server-side XP/token grant.</summary>
|
||||||
public string WeeklyChallengeRewardJson { get; set; } = string.Empty;
|
public string WeeklyChallengeRewardJson { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Admin-managed Play menu room filter chips as JSON. Stores
|
||||||
|
/// separate pinned and popular tag lists; wire endpoints serve them to
|
||||||
|
/// the watch without leading # characters.</summary>
|
||||||
|
public string PlayMenuTagsJson { get; set; } = string.Empty;
|
||||||
|
|
||||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,19 @@ public class ConfigService(IConfiguration config, DomainConfig domain)
|
|||||||
["FriendsEnabled"] = "true",
|
["FriendsEnabled"] = "true",
|
||||||
["ChatEnabled"] = "true",
|
["ChatEnabled"] = "true",
|
||||||
["VoiceEnabled"] = "true",
|
["VoiceEnabled"] = "true",
|
||||||
|
// Rec Center category doors are baked with these
|
||||||
|
// config lookups. Missing values leave the door browser
|
||||||
|
// and door-specific return spawns without a category.
|
||||||
|
["Door.Shooters.Title"] = "Shooters",
|
||||||
|
["Door.Shooters.Query"] = "#paintball,#lasertag,#recroyale",
|
||||||
|
["Door.Creative.Title"] = "Creative",
|
||||||
|
["Door.Creative.Query"] = "#creative,#makerpen,#template",
|
||||||
|
["Door.Quests.Title"] = "Quests",
|
||||||
|
["Door.Quests.Query"] = "#quest",
|
||||||
|
["Door.Sports.Title"] = "Sports",
|
||||||
|
["Door.Sports.Query"] = "#sport",
|
||||||
|
["Door.Featured.Title"] = "Featured",
|
||||||
|
["Door.Featured.Query"] = "#featured",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,50 +77,50 @@ public class RoomService(DorkNetDbContext db)
|
|||||||
// tools/extract-locations-binary.py. Output cached at data/room_locations.json.
|
// tools/extract-locations-binary.py. Output cached at data/room_locations.json.
|
||||||
// Each Location has a SceneName matching one of the .unity scenes the client
|
// Each Location has a SceneName matching one of the .unity scenes the client
|
||||||
// ships (see tools/dump-all-scenes.py for the 96-scene index).
|
// ships (see tools/dump-all-scenes.py for the 96-scene index).
|
||||||
("RecCenter", "Rec Center", "A social hub to meet and mingle with friends new and old.", "cbad71af-0831-44d8-b8ef-69edafa841f6", "recroomoriginal,featured", "image_RecCenter.png"),
|
("RecCenter", "Rec Center", "A social hub to meet and mingle with friends new and old.", "cbad71af-0831-44d8-b8ef-69edafa841f6", "recroomoriginal,featured,hangout,chill,social", "image_RecCenter.png"),
|
||||||
("3DCharades", "3D Charades", "Take turns drawing, acting, and guessing funny phrases with your friends!", "4078dfed-24bb-4db7-863f-578ba48d726b", "recroomoriginal", "image_3DCharades.png"),
|
("3DCharades", "3D Charades", "Take turns drawing, acting, and guessing funny phrases with your friends!", "4078dfed-24bb-4db7-863f-578ba48d726b", "recroomoriginal,charades,party,creative", "image_3DCharades.png"),
|
||||||
("DiscGolfLake", "DiscGolf Lake", "A leisurely stroll through the grass. Throw your disc into the goal.", "f6f7256c-e438-4299-b99e-d20bef8cf7e0", "recroomoriginal,sport", "image_DiscGolfLake.png"),
|
("DiscGolfLake", "DiscGolf Lake", "A leisurely stroll through the grass. Throw your disc into the goal.", "f6f7256c-e438-4299-b99e-d20bef8cf7e0", "recroomoriginal,sport,discgolf", "image_DiscGolfLake.png"),
|
||||||
("DiscGolfPropulsion","DiscGolf Propulsion","Throw your disc through hazards and around wind machines on this challenging course!", "d9378c9f-80bc-46fb-ad1e-1bed8a674f55", "recroomoriginal,sport", "image_DiscGolfPropulsion.png"),
|
("DiscGolfPropulsion","DiscGolf Propulsion","Throw your disc through hazards and around wind machines on this challenging course!", "d9378c9f-80bc-46fb-ad1e-1bed8a674f55", "recroomoriginal,sport,discgolf", "image_DiscGolfPropulsion.png"),
|
||||||
("Dodgeball", "Dodgeball", "Throw dodgeballs to knock out your friends in this gym classic!", "3d474b26-26f7-45e9-9a36-9b02847d5e6f", "recroomoriginal,sport", "image_Dodgeball.png"),
|
("Dodgeball", "Dodgeball", "Throw dodgeballs to knock out your friends in this gym classic!", "3d474b26-26f7-45e9-9a36-9b02847d5e6f", "recroomoriginal,sport,dodgeball,pvp", "image_Dodgeball.png"),
|
||||||
("Paddleball", "Paddleball", "A simple rally game between two players in a plexiglass tube with a zero-g ball.", "d89f74fa-d51e-477a-a425-025a891dd499", "recroomoriginal,sport", "image_Paddleball.png"),
|
("Paddleball", "Paddleball", "A simple rally game between two players in a plexiglass tube with a zero-g ball.", "d89f74fa-d51e-477a-a425-025a891dd499", "recroomoriginal,sport,paddleball,pvp", "image_Paddleball.png"),
|
||||||
("Paintball", "Paintball", "Red and Blue teams splat each other in capture the flag and team battle.", "e122fe98-e7db-49e8-a1b1-105424b6e1f0", "recroomoriginal,sport", "image_Paintball.png"),
|
("Paintball", "Paintball", "Red and Blue teams splat each other in capture the flag and team battle.", "e122fe98-e7db-49e8-a1b1-105424b6e1f0", "recroomoriginal,sport,paintball,pvp", "image_Paintball.png"),
|
||||||
// Soccer scene = soccer.unity, Location ReplicationId verified
|
// Soccer scene = soccer.unity, Location ReplicationId verified
|
||||||
// (was previously mis-mapped to IsleOfLostSkulls' GUID).
|
// (was previously mis-mapped to IsleOfLostSkulls' GUID).
|
||||||
("Soccer", "Soccer", "Teams of three run around slamming themselves into an over-sized soccer ball. Goal!", "6d5eea4b-f069-4ed0-9916-0e2f07df0d03", "recroomoriginal,sport", "image_Soccer.png"),
|
("Soccer", "Soccer", "Teams of three run around slamming themselves into an over-sized soccer ball. Goal!", "6d5eea4b-f069-4ed0-9916-0e2f07df0d03", "recroomoriginal,sport,soccer,pvp", "image_Soccer.png"),
|
||||||
// LaserTag in the 2020 build IS the CyberJunkCity arena scene
|
// LaserTag in the 2020 build IS the CyberJunkCity arena scene
|
||||||
// (Arena_Cyberjunk_City.unity). Was previously mis-mapped to
|
// (Arena_Cyberjunk_City.unity). Was previously mis-mapped to
|
||||||
// soccer's GUID. Hangar (Arena_Hangar3.unity) is the alternate
|
// soccer's GUID. Hangar (Arena_Hangar3.unity) is the alternate
|
||||||
// map players reach via the in-game lobby.
|
// map players reach via the in-game lobby.
|
||||||
("LaserTag", "Laser Tag", "Teams battle each other and waves of robots in a totally cyber neon future city.", "9d6456ce-6264-48b4-808d-2d96b3d91038", "recroomoriginal,sport", "image_LaserTag.png"),
|
("LaserTag", "Laser Tag", "Teams battle each other and waves of robots in a totally cyber neon future city.", "9d6456ce-6264-48b4-808d-2d96b3d91038", "recroomoriginal,sport,lasertag,pvp", "image_LaserTag.png"),
|
||||||
("CyberJunkCity", "Laser Tag CyberJunk","Teams battle each other and waves of robots in a totally cyber neon future city.", "9d6456ce-6264-48b4-808d-2d96b3d91038", "recroomoriginal,sport", "image_CyberJunkCity.png"),
|
("CyberJunkCity", "Laser Tag CyberJunk","Teams battle each other and waves of robots in a totally cyber neon future city.", "9d6456ce-6264-48b4-808d-2d96b3d91038", "recroomoriginal,sport,lasertag,pvp", "image_CyberJunkCity.png"),
|
||||||
("LaserTagHangar", "Laser Tag Hangar", "Teams battle in an industrial warehouse map.", "239e676c-f12f-489f-bf3a-d4c383d692c3", "recroomoriginal,sport", "image_LaserTag.png"),
|
("LaserTagHangar", "Laser Tag Hangar", "Teams battle in an industrial warehouse map.", "239e676c-f12f-489f-bf3a-d4c383d692c3", "recroomoriginal,sport,lasertag,pvp", "image_LaserTag.png"),
|
||||||
("RecRoyaleSquads", "Rec Royale Squads", "Squads of three battle it out on Frontier Island. Last squad standing wins!", "253fa009-6e65-4c90-91a1-7137a56a267f", "recroomoriginal,featured,sport", "image_RecRoyaleSquads.png"),
|
("RecRoyaleSquads", "Rec Royale Squads", "Squads of three battle it out on Frontier Island. Last squad standing wins!", "253fa009-6e65-4c90-91a1-7137a56a267f", "recroomoriginal,featured,sport,recroyale,pvp,battle", "image_RecRoyaleSquads.png"),
|
||||||
// RecRoyaleSolos was previously pointing at a Home location
|
// RecRoyaleSolos was previously pointing at a Home location
|
||||||
// GUID (85b43509-…); the real Solos Location is b010171f-….
|
// GUID (85b43509-…); the real Solos Location is b010171f-….
|
||||||
("RecRoyaleSolos", "Rec Royale Solos", "Battle it out on Frontier Island. Last person standing wins!", "b010171f-4875-4e89-baba-61e878cd41e1", "recroomoriginal,sport", "image_RecRoyaleSolos.png"),
|
("RecRoyaleSolos", "Rec Royale Solos", "Battle it out on Frontier Island. Last person standing wins!", "b010171f-4875-4e89-baba-61e878cd41e1", "recroomoriginal,sport,recroyale,pvp,battle", "image_RecRoyaleSolos.png"),
|
||||||
("GoldenTrophy", "Quest For The Golden Trophy", "The goblin king stole Coach's Golden Trophy. Team up and embark on an epic quest to recover it!", "91e16e35-f48f-4700-ab8a-a1b79e50e51b", "recroomoriginal,quest", "image_GoldenTrophy.png"),
|
("GoldenTrophy", "Quest For The Golden Trophy", "The goblin king stole Coach's Golden Trophy. Team up and embark on an epic quest to recover it!", "91e16e35-f48f-4700-ab8a-a1b79e50e51b", "recroomoriginal,quest,co-op,adventure", "image_GoldenTrophy.png"),
|
||||||
("TheRiseofJumbotron","The Rise of Jumbotron", "Robot invaders threaten the galaxy! Team up with your friends and bring the laser heat!", "acc06e66-c2d0-4361-b0cd-46246a4c455c", "recroomoriginal,quest", "image_TheRiseofJumbotron.png"),
|
("TheRiseofJumbotron","The Rise of Jumbotron", "Robot invaders threaten the galaxy! Team up with your friends and bring the laser heat!", "acc06e66-c2d0-4361-b0cd-46246a4c455c", "recroomoriginal,quest,co-op,adventure", "image_TheRiseofJumbotron.png"),
|
||||||
("CrimsonCauldron", "Curse of the Crimson Cauldron", "Can your band of adventurers brave the enchanted wilds, and lift the curse of the crimson cauldron?", "949fa41f-4347-45c0-b7ac-489129174045", "recroomoriginal,quest", "image_CrimsonCauldron.png"),
|
("CrimsonCauldron", "Curse of the Crimson Cauldron", "Can your band of adventurers brave the enchanted wilds, and lift the curse of the crimson cauldron?", "949fa41f-4347-45c0-b7ac-489129174045", "recroomoriginal,quest,co-op,adventure", "image_CrimsonCauldron.png"),
|
||||||
// IsleOfLostSkulls was previously pointing at Quarry's GUID;
|
// IsleOfLostSkulls was previously pointing at Quarry's GUID;
|
||||||
// real Location ReplicationId is 7e01cfe0-… (scene Quest_Pirate1_additive).
|
// real Location ReplicationId is 7e01cfe0-… (scene Quest_Pirate1_additive).
|
||||||
("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", "image_IsleOfLostSkulls.png"),
|
("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", "image_Crescendo.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", "image_Crescendo.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", "image_StuntRunner.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"),
|
||||||
("Drive-In", "Rec Drive-In", "Watch movies, hang out with friends, or chill at the bar.", "65ddbb48-5a01-4e3e-972d-e5c7419e2bc3", "recroomoriginal,featured", "image_DriveIn.png"),
|
("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.
|
// 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", "image_Park.png"),
|
("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"),
|
||||||
("PerformanceHall", "Performance Hall", "A big stage for live performances. Sing, perform, or just enjoy the show.", "9932f88f-3929-43a0-a012-a40b5128e346", "recroomoriginal", "image_PerformanceHall.png"),
|
("PerformanceHall", "Performance Hall", "A big stage for live performances. Sing, perform, or just enjoy the show.", "9932f88f-3929-43a0-a012-a40b5128e346", "recroomoriginal,hangout,music", "image_PerformanceHall.png"),
|
||||||
("EventRoom", "The Lounge", "A modular event hall with movable furniture for parties and meetings.", "a067557f-ca32-43e6-b6e5-daaec60b4f5a", "recroomoriginal", "image_EventRoom.png"),
|
("EventRoom", "The Lounge", "A modular event hall with movable furniture for parties and meetings.", "a067557f-ca32-43e6-b6e5-daaec60b4f5a", "recroomoriginal,hangout,chill", "image_EventRoom.png"),
|
||||||
("BowlingAlley", "Bowling Alley", "Classic ten-pin bowling. Roll strikes, beat your friends.", "ae929543-9a07-41d5-8ee9-dbbee8c36800", "recroomoriginal,sport", "image_BowlingAlley.png"),
|
("BowlingAlley", "Bowling Alley", "Classic ten-pin bowling. Roll strikes, beat your friends.", "ae929543-9a07-41d5-8ee9-dbbee8c36800", "recroomoriginal,sport,bowling", "image_BowlingAlley.png"),
|
||||||
// Sandbox templates (cloning canvases) — every "Home"-named
|
// Sandbox templates (cloning canvases) — every "Home"-named
|
||||||
// Location with a recognizable scene. Players land in these
|
// Location with a recognizable scene. Players land in these
|
||||||
// when picking a stage for a custom room.
|
// when picking a stage for a custom room.
|
||||||
("River", "River", "An outdoor template for building. Mountains, river, and forest as your starting canvas.", "e122fe98-e7db-49e8-a1b1-105424b6e1f0", "recroomoriginal,template", "image_River.png"),
|
("River", "River", "An outdoor template for building. Mountains, river, and forest as your starting canvas.", "e122fe98-e7db-49e8-a1b1-105424b6e1f0", "recroomoriginal,template,creative,makerpen", "image_River.png"),
|
||||||
("Homestead", "Homestead", "A frontier-themed template — barn, fences, dusty trails. Build your homestead.", "a785267d-c579-42ea-be43-fec1992d1ca7", "recroomoriginal,template", "image_Homestead.png"),
|
("Homestead", "Homestead", "A frontier-themed template — barn, fences, dusty trails. Build your homestead.", "a785267d-c579-42ea-be43-fec1992d1ca7", "recroomoriginal,template,creative,makerpen", "image_Homestead.png"),
|
||||||
("Quarry", "Quarry", "A rocky open-pit template, ideal for industrial and obstacle-course builds.", "ff4c6427-7079-4f59-b22a-69b089420827", "recroomoriginal,template", "image_Quarry.png"),
|
("Quarry", "Quarry", "A rocky open-pit template, ideal for industrial and obstacle-course builds.", "ff4c6427-7079-4f59-b22a-69b089420827", "recroomoriginal,template,creative,makerpen", "image_Quarry.png"),
|
||||||
("Clearcut", "Clearcut", "An open clearing template — flat ground, mountains in the distance.", "380d18b5-de9c-49f3-80f7-f4a95c1de161", "recroomoriginal,template", "image_Clearcut.png"),
|
("Clearcut", "Clearcut", "An open clearing template — flat ground, mountains in the distance.", "380d18b5-de9c-49f3-80f7-f4a95c1de161", "recroomoriginal,template,creative,makerpen", "image_Clearcut.png"),
|
||||||
("Spillway", "Spillway", "A water-park template — pools, waterslides, and concrete chutes for action builds.", "58763055-2dfb-4814-80b8-16fac5c85709", "recroomoriginal,template", "image_Spillway.png"),
|
("Spillway", "Spillway", "A water-park template — pools, waterslides, and concrete chutes for action builds.", "58763055-2dfb-4814-80b8-16fac5c85709", "recroomoriginal,template,creative,makerpen", "image_Spillway.png"),
|
||||||
("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"),
|
("MakerRoom", "Maker Room", "A blank-walled canvas. The classic starting point for a Maker Pen build.", "a75f7547-79eb-47c6-8986-6767abcb4f92", "recroomoriginal,template,featured,creative,makerpen", "image_RecCenter.png"),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Stable id allocation: 100..1000 reserved for seeded rooms. User
|
// Stable id allocation: 100..1000 reserved for seeded rooms. User
|
||||||
@@ -571,7 +571,10 @@ public class RoomService(DorkNetDbContext db)
|
|||||||
if (bareTag.Length > 0)
|
if (bareTag.Length > 0)
|
||||||
{
|
{
|
||||||
var needle = $"%{bareTag}%";
|
var needle = $"%{bareTag}%";
|
||||||
q = q.Where(r => EF.Functions.Like(r.TagsCsv, needle));
|
q = q.Where(r =>
|
||||||
|
EF.Functions.Like(r.TagsCsv, needle) ||
|
||||||
|
EF.Functions.Like(r.Name, needle) ||
|
||||||
|
EF.Functions.Like(r.Description, needle));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return await q.OrderByDescending(r => r.HotScore).Take(take).ToListAsync();
|
return await q.OrderByDescending(r => r.HotScore).Take(take).ToListAsync();
|
||||||
|
|||||||
@@ -54,6 +54,24 @@ public class ServerSettingsService(DorkNetDbContext db)
|
|||||||
return ToWeeklySettings(row);
|
return ToWeeklySettings(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<PlayMenuTagSettings> GetPlayMenuTagsAsync()
|
||||||
|
{
|
||||||
|
var row = await GetAsync();
|
||||||
|
return ToPlayMenuTags(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PlayMenuTagSettings> SetPlayMenuTagsAsync(
|
||||||
|
IReadOnlyList<string> pinned,
|
||||||
|
IReadOnlyList<string> popular)
|
||||||
|
{
|
||||||
|
var normalized = NormalizePlayMenuTags(pinned, popular);
|
||||||
|
var existing = await GetTrackedRowAsync();
|
||||||
|
existing.PlayMenuTagsJson = JsonSerializer.Serialize(normalized, JsonOptions);
|
||||||
|
existing.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return normalized with { UpdatedAt = existing.UpdatedAt };
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<WeeklyChallengeSettings> SetWeeklyChallengesAsync(
|
public async Task<WeeklyChallengeSettings> SetWeeklyChallengesAsync(
|
||||||
bool completedRequired,
|
bool completedRequired,
|
||||||
IReadOnlyList<WeeklyChallengeTemplate> challenges,
|
IReadOnlyList<WeeklyChallengeTemplate> challenges,
|
||||||
@@ -122,6 +140,29 @@ public class ServerSettingsService(DorkNetDbContext db)
|
|||||||
row.UpdatedAt);
|
row.UpdatedAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static PlayMenuTagSettings ToPlayMenuTags(ServerSettingsEntity row)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(row.PlayMenuTagsJson))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var parsed = JsonSerializer.Deserialize<PlayMenuTagSettings>(
|
||||||
|
row.PlayMenuTagsJson,
|
||||||
|
JsonOptions);
|
||||||
|
if (parsed is not null)
|
||||||
|
return NormalizePlayMenuTags(parsed.PinnedTags, parsed.PopularTags)
|
||||||
|
with { UpdatedAt = row.UpdatedAt };
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Bad settings should not break the Play menu; admins can
|
||||||
|
// overwrite them from the SPA.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return DefaultPlayMenuTags() with { UpdatedAt = row.UpdatedAt };
|
||||||
|
}
|
||||||
|
|
||||||
private static List<WeeklyChallengeTemplate> NormalizeWeeklyChallenges(
|
private static List<WeeklyChallengeTemplate> NormalizeWeeklyChallenges(
|
||||||
IReadOnlyList<WeeklyChallengeTemplate> challenges)
|
IReadOnlyList<WeeklyChallengeTemplate> challenges)
|
||||||
{
|
{
|
||||||
@@ -189,6 +230,65 @@ public class ServerSettingsService(DorkNetDbContext db)
|
|||||||
ConsumableItemDesc: string.Empty,
|
ConsumableItemDesc: string.Empty,
|
||||||
EquipmentPrefabName: string.Empty,
|
EquipmentPrefabName: string.Empty,
|
||||||
EquipmentModificationGuid: string.Empty);
|
EquipmentModificationGuid: string.Empty);
|
||||||
|
|
||||||
|
public static PlayMenuTagSettings DefaultPlayMenuTags() =>
|
||||||
|
new(
|
||||||
|
PinnedTags:
|
||||||
|
[
|
||||||
|
"community",
|
||||||
|
"recroomoriginal",
|
||||||
|
"featured",
|
||||||
|
"quest",
|
||||||
|
"sport",
|
||||||
|
"template",
|
||||||
|
"hangout",
|
||||||
|
"creative",
|
||||||
|
],
|
||||||
|
PopularTags:
|
||||||
|
[
|
||||||
|
"paintball",
|
||||||
|
"dodgeball",
|
||||||
|
"soccer",
|
||||||
|
"lasertag",
|
||||||
|
"recroyale",
|
||||||
|
"discgolf",
|
||||||
|
"charades",
|
||||||
|
"bowling",
|
||||||
|
"paddleball",
|
||||||
|
"stuntrunner",
|
||||||
|
"makerpen",
|
||||||
|
"pvp",
|
||||||
|
"co-op",
|
||||||
|
"chill",
|
||||||
|
"music",
|
||||||
|
"parkour",
|
||||||
|
],
|
||||||
|
UpdatedAt: DateTime.UtcNow);
|
||||||
|
|
||||||
|
private static PlayMenuTagSettings NormalizePlayMenuTags(
|
||||||
|
IReadOnlyList<string> pinned,
|
||||||
|
IReadOnlyList<string> popular)
|
||||||
|
{
|
||||||
|
static List<string> Normalize(IReadOnlyList<string> tags, int max)
|
||||||
|
{
|
||||||
|
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
return tags
|
||||||
|
.Select(t => (t ?? string.Empty).Trim().TrimStart('#').ToLowerInvariant())
|
||||||
|
.Where(t => t.Length > 0)
|
||||||
|
.Where(t => t.All(c => char.IsLetterOrDigit(c) || c is '-' or '_'))
|
||||||
|
.Where(seen.Add)
|
||||||
|
.Take(max)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
var defaults = DefaultPlayMenuTags();
|
||||||
|
var pinnedTags = Normalize(pinned, 16);
|
||||||
|
var popularTags = Normalize(popular, 32);
|
||||||
|
return new PlayMenuTagSettings(
|
||||||
|
pinnedTags.Count > 0 ? pinnedTags : defaults.PinnedTags,
|
||||||
|
popularTags.Count > 0 ? popularTags : defaults.PopularTags,
|
||||||
|
DateTime.UtcNow);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record WeeklyChallengeSettings(
|
public sealed record WeeklyChallengeSettings(
|
||||||
@@ -215,3 +315,8 @@ public sealed record WeeklyChallengeReward(
|
|||||||
string ConsumableItemDesc,
|
string ConsumableItemDesc,
|
||||||
string EquipmentPrefabName,
|
string EquipmentPrefabName,
|
||||||
string EquipmentModificationGuid);
|
string EquipmentModificationGuid);
|
||||||
|
|
||||||
|
public sealed record PlayMenuTagSettings(
|
||||||
|
List<string> PinnedTags,
|
||||||
|
List<string> PopularTags,
|
||||||
|
DateTime UpdatedAt);
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ public static class DatabaseBootstrap
|
|||||||
// idempotent raw SQL that works on a fresh DB too (CREATE TABLE
|
// idempotent raw SQL that works on a fresh DB too (CREATE TABLE
|
||||||
// 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);
|
||||||
|
|
||||||
// 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
|
||||||
@@ -236,6 +237,29 @@ public static class DatabaseBootstrap
|
|||||||
await db.Database.ExecuteSqlRawAsync(sql);
|
await db.Database.ExecuteSqlRawAsync(sql);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task EnsureServerSettingsColumnsAsync(DorkNetDbContext db)
|
||||||
|
{
|
||||||
|
var provider = db.Database.ProviderName ?? string.Empty;
|
||||||
|
if (provider.Contains("Npgsql", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
await db.Database.ExecuteSqlRawAsync(
|
||||||
|
@"ALTER TABLE ""ServerSettings"" ADD COLUMN IF NOT EXISTS ""PlayMenuTagsJson"" text NOT NULL DEFAULT '';");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await db.Database.ExecuteSqlRawAsync(
|
||||||
|
@"ALTER TABLE ""ServerSettings"" ADD COLUMN ""PlayMenuTagsJson"" TEXT NOT NULL DEFAULT '';");
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// SQLite has no ADD COLUMN IF NOT EXISTS. If it already exists,
|
||||||
|
// ignore the duplicate-column error; any other schema problem
|
||||||
|
// will surface when EF reads ServerSettings.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <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">
|
||||||
|
|||||||
@@ -10,8 +10,20 @@ import { useToast } from '../components/Toast';
|
|||||||
import { Confirm } from '../components/Confirm';
|
import { Confirm } from '../components/Confirm';
|
||||||
import { RefreshCw, Trash, Upload } from '../components/Icons';
|
import { RefreshCw, Trash, Upload } from '../components/Icons';
|
||||||
|
|
||||||
|
interface PlayMenuTagSettings {
|
||||||
|
pinnedTags: string[];
|
||||||
|
popularTags: string[];
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
export function Rooms() {
|
export function Rooms() {
|
||||||
const { data, loading, error, refresh } = useApi<Room[]>('/rooms');
|
const { data, loading, error, refresh } = useApi<Room[]>('/rooms');
|
||||||
|
const {
|
||||||
|
data: playMenuTags,
|
||||||
|
loading: tagsLoading,
|
||||||
|
error: tagsError,
|
||||||
|
refresh: refreshPlayMenuTags,
|
||||||
|
} = useApi<PlayMenuTagSettings>('/settings/play-menu-tags');
|
||||||
const [filter, setFilter] = useState('');
|
const [filter, setFilter] = useState('');
|
||||||
const [filterKind, setFilterKind] = useState<'all' | 'original' | 'custom' | 'dorm'>('all');
|
const [filterKind, setFilterKind] = useState<'all' | 'original' | 'custom' | 'dorm'>('all');
|
||||||
const [pendingDelete, setPendingDelete] = useState<Room | null>(null);
|
const [pendingDelete, setPendingDelete] = useState<Room | null>(null);
|
||||||
@@ -64,6 +76,13 @@ export function Rooms() {
|
|||||||
</>}
|
</>}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<PlayMenuTagsPanel
|
||||||
|
tags={playMenuTags}
|
||||||
|
loading={tagsLoading}
|
||||||
|
error={tagsError}
|
||||||
|
onSaved={refreshPlayMenuTags}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="card overflow-hidden">
|
<div className="card overflow-hidden">
|
||||||
<div className="flex flex-wrap items-center gap-2 border-b border-ink-800 px-3 py-2.5">
|
<div className="flex flex-wrap items-center gap-2 border-b border-ink-800 px-3 py-2.5">
|
||||||
<input value={filter} onChange={e => setFilter(e.target.value)} placeholder="Filter by name…" className="input max-w-xs" />
|
<input value={filter} onChange={e => setFilter(e.target.value)} placeholder="Filter by name…" className="input max-w-xs" />
|
||||||
@@ -156,6 +175,102 @@ export function Rooms() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PlayMenuTagsPanel({
|
||||||
|
tags,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
onSaved,
|
||||||
|
}: {
|
||||||
|
tags: PlayMenuTagSettings | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
onSaved: () => void;
|
||||||
|
}) {
|
||||||
|
const [pinnedText, setPinnedText] = useState('');
|
||||||
|
const [popularText, setPopularText] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!tags) return;
|
||||||
|
setPinnedText(tags.pinnedTags.join('\n'));
|
||||||
|
setPopularText(tags.popularTags.join('\n'));
|
||||||
|
}, [tags]);
|
||||||
|
|
||||||
|
const parseTags = (text: string) =>
|
||||||
|
text
|
||||||
|
.split(/[\n,]+/)
|
||||||
|
.map(t => t.trim().replace(/^#+/, '').toLowerCase())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const save = async (reset = false) => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await api<PlayMenuTagSettings>('/settings/play-menu-tags', {
|
||||||
|
method: 'POST',
|
||||||
|
body: reset
|
||||||
|
? { PinnedTags: [], PopularTags: [] }
|
||||||
|
: { PinnedTags: parseTags(pinnedText), PopularTags: parseTags(popularText) },
|
||||||
|
});
|
||||||
|
toast.push(reset ? 'Play menu tags reset.' : 'Play menu tags saved.', 'success');
|
||||||
|
onSaved();
|
||||||
|
} catch (e) {
|
||||||
|
toast.push((e as Error).message, 'error');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card !p-4 mb-4">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold text-ink-50">Play menu tags</h2>
|
||||||
|
<p className="mt-1 text-xs text-ink-400">
|
||||||
|
Filter chips shown in the watch Play menu. Use one tag per line or commas; leave off the #.
|
||||||
|
</p>
|
||||||
|
{tags && (
|
||||||
|
<p className="mt-1 text-[11px] text-ink-500">
|
||||||
|
Last changed {new Date(tags.updatedAt).toLocaleString()}.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={() => save(true)} disabled={busy || loading} className="btn-secondary text-xs">
|
||||||
|
Defaults
|
||||||
|
</button>
|
||||||
|
<button onClick={() => save(false)} disabled={busy || loading} className="btn-primary text-xs">
|
||||||
|
{busy ? 'Saving...' : 'Save tags'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="mt-3 text-sm text-danger">{error}</div>}
|
||||||
|
{loading && !tags && <div className="mt-3 text-xs text-ink-400">Loading tags...</div>}
|
||||||
|
{tags && (
|
||||||
|
<div className="mt-4 grid gap-3 md:grid-cols-2">
|
||||||
|
<label className="block">
|
||||||
|
<span className="label">Pinned tags</span>
|
||||||
|
<textarea
|
||||||
|
value={pinnedText}
|
||||||
|
onChange={e => setPinnedText(e.target.value)}
|
||||||
|
className="input mt-1 min-h-36 font-mono text-xs"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="label">Popular tags</span>
|
||||||
|
<textarea
|
||||||
|
value={popularText}
|
||||||
|
onChange={e => setPopularText(e.target.value)}
|
||||||
|
className="input mt-1 min-h-36 font-mono text-xs"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Per-room hard-delete modal with two typed-name confirmations ─────────
|
// ── Per-room hard-delete modal with two typed-name confirmations ─────────
|
||||||
// The previous bulk "purge all custom" button was removed — too easy to
|
// The previous bulk "purge all custom" button was removed — too easy to
|
||||||
// click by accident. Each room now gets its own purge flow that requires
|
// click by accident. Each room now gets its own purge flow that requires
|
||||||
|
|||||||
Reference in New Issue
Block a user