From e002b88d7192656a72cf734521da5fc8c4f5c829 Mon Sep 17 00:00:00 2001 From: Alexa <53471801+Alexa-RR@users.noreply.github.com> Date: Thu, 2 Jul 2026 00:54:04 +0300 Subject: [PATCH] feat: add 3D Charades word lists management and profanity filter toggle - Implemented Charades word lists management in the admin UI, allowing for unlimited named word lists with difficulty settings. - Added functionality to bind live card slots to specific word lists, with automatic fallback to built-in lists. - Introduced a profanity filter toggle in server settings, enabling or disabling the server-side profanity filter. - Updated database schema to support new features, including additional columns for profanity filter status and charades slot bindings. - Enhanced documentation to reflect new features and their usage. --- DorkNet.Contracts/DorkNetRouteOwnership.cs | 6 + DorkNet.Server.Tests/CharadesWordListTests.cs | 88 ++++ .../DorkNet.Server.Tests.csproj | 1 + .../MatchmakePullInviteTests.cs | 57 +++ .../API/Activities/ActivitiesController.cs | 26 +- .../API/Sanitize/SanitizeController.cs | 39 +- .../Controllers/Admin/AdminController.cs | 110 +++++ .../Controllers/Match/GoToController.cs | 49 ++ DorkNet.Server/Data/DorkNetDbContext.cs | 1 + .../Data/Entities/CharadesWordListEntity.cs | 46 ++ .../Data/Entities/ServerSettingsEntity.cs | 18 + .../Services/CharadesWordListService.cs | 442 ++++++++++++++++++ .../Services/ServerSettingsService.cs | 46 ++ DorkNet.Server/Startup/DatabaseBootstrap.cs | 71 +++ .../Startup/ServiceCollectionExtensions.cs | 1 + .../admin-ui/src/pages/Charades.tsx | 429 +++++++++++++++++ DorkNet.Server/admin-ui/src/pages/Content.tsx | 15 +- .../admin-ui/src/pages/Settings.tsx | 56 +++ docs/admin.md | 37 +- 19 files changed, 1516 insertions(+), 22 deletions(-) create mode 100644 DorkNet.Server.Tests/CharadesWordListTests.cs create mode 100644 DorkNet.Server.Tests/MatchmakePullInviteTests.cs create mode 100644 DorkNet.Server/Data/Entities/CharadesWordListEntity.cs create mode 100644 DorkNet.Server/Services/CharadesWordListService.cs create mode 100644 DorkNet.Server/admin-ui/src/pages/Charades.tsx diff --git a/DorkNet.Contracts/DorkNetRouteOwnership.cs b/DorkNet.Contracts/DorkNetRouteOwnership.cs index b23474b..0a9b94b 100644 --- a/DorkNet.Contracts/DorkNetRouteOwnership.cs +++ b/DorkNet.Contracts/DorkNetRouteOwnership.cs @@ -46,6 +46,12 @@ public static class DorkNetRouteOwnership "/discover", "/featuredrooms", "/goto", + // 2023 clients renamed the whole goto/* matchmaking surface + // to matchmake/* (matchmake/room, /instance, /invite, + // /chatinvite, …). Same controller as /goto, so it belongs + // to the match service; without this it fell through to the + // Monolith default owner. + "/matchmake", "/hot_rooms", "/hot_roomsandplaylists", "/invite", diff --git a/DorkNet.Server.Tests/CharadesWordListTests.cs b/DorkNet.Server.Tests/CharadesWordListTests.cs new file mode 100644 index 0000000..4046536 --- /dev/null +++ b/DorkNet.Server.Tests/CharadesWordListTests.cs @@ -0,0 +1,88 @@ +using System.Net; +using System.Text; +using System.Text.Json; + +namespace DorkNet.Server.Tests; + +/// +/// End-to-end coverage for the 3D Charades word-list feature and the +/// profanity-filter toggle. Boots the full server (which runs the seeder) +/// against SQLite via and exercises the +/// public wire endpoints the March 2023 client actually calls. +/// +public sealed class CharadesWordListTests : IClassFixture +{ + private readonly DorkNetServerFactory _factory; + + public CharadesWordListTests(DorkNetServerFactory factory) => _factory = factory; + + private HttpClient ApiClient() + { + var client = _factory.CreateClient(new() { AllowAutoRedirect = false }); + client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}"); + client.Timeout = TimeSpan.FromSeconds(30); + return client; + } + + [Theory] + [InlineData("Charades")] // enum-name form + [InlineData("CharadesAprilFoolsDay")] + [InlineData("Icebreakers")] + [InlineData("0")] // int form the client may send + [InlineData("1")] + [InlineData("2")] + public async Task Words_endpoint_returns_seeded_deck_in_wire_shape(string source) + { + using var client = ApiClient(); + using var res = await client.GetAsync($"/api/activities/charades/v1/words/{source}"); + + Assert.Equal(HttpStatusCode.OK, res.StatusCode); + using var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync()); + Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind); + Assert.True(doc.RootElement.GetArrayLength() > 0, $"expected a non-empty deck for '{source}'"); + + var first = doc.RootElement[0]; + // Exact wire key casing the client's LitJson importer reads. + Assert.True(first.TryGetProperty("EN_US", out var en) && en.ValueKind == JsonValueKind.String, + "each card must expose a string EN_US field"); + Assert.True(first.TryGetProperty("Difficulty", out var diff) && diff.ValueKind == JsonValueKind.Number, + "each card must expose a numeric Difficulty field"); + } + + [Fact] + public async Task December_noparam_route_serves_the_charades_slot() + { + using var client = ApiClient(); + using var res = await client.GetAsync("/api/activities/charades/v1/words"); + + Assert.Equal(HttpStatusCode.OK, res.StatusCode); + using var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync()); + Assert.True(doc.RootElement.GetArrayLength() > 0); + } + + [Fact] + public async Task Unknown_source_falls_back_to_charades_slot() + { + using var client = ApiClient(); + using var res = await client.GetAsync("/api/activities/charades/v1/words/totally-bogus"); + + Assert.Equal(HttpStatusCode.OK, res.StatusCode); + using var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync()); + Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind); + Assert.True(doc.RootElement.GetArrayLength() > 0); + } + + [Fact] + public async Task Profanity_filter_is_active_by_default() + { + using var client = ApiClient(); + using var res = await client.PostAsync( + "/api/sanitize/v1/requestIsStringPure", + new StringContent("{\"Value\":\"you piece of shit\"}", Encoding.UTF8, "application/json")); + + Assert.Equal(HttpStatusCode.OK, res.StatusCode); + var body = (await res.Content.ReadAsStringAsync()).Trim(); + // Default state: filter on → the profane string is NOT pure. + Assert.Equal("false", body.ToLowerInvariant()); + } +} diff --git a/DorkNet.Server.Tests/DorkNet.Server.Tests.csproj b/DorkNet.Server.Tests/DorkNet.Server.Tests.csproj index 7cd69a8..71ce8e3 100644 --- a/DorkNet.Server.Tests/DorkNet.Server.Tests.csproj +++ b/DorkNet.Server.Tests/DorkNet.Server.Tests.csproj @@ -17,6 +17,7 @@ + diff --git a/DorkNet.Server.Tests/MatchmakePullInviteTests.cs b/DorkNet.Server.Tests/MatchmakePullInviteTests.cs new file mode 100644 index 0000000..4a1767e --- /dev/null +++ b/DorkNet.Server.Tests/MatchmakePullInviteTests.cs @@ -0,0 +1,57 @@ +using System.Net; +using DorkNet.Contracts; + +namespace DorkNet.Server.Tests; + +/// +/// Regression coverage for the 2023 "pull a player into a private instance" +/// flow. The 2023 client renamed the matchmaking surface from goto/* +/// to matchmake/* and added matchmake/chatinvite/{account}/{instance} +/// for chat party invites; the server had neither the routes nor the gateway +/// ownership entry, so those requests 404'd. +/// +public sealed class MatchmakePullInviteTests : IClassFixture +{ + private readonly DorkNetServerFactory _factory; + + public MatchmakePullInviteTests(DorkNetServerFactory factory) => _factory = factory; + + private HttpClient ApiClient() + { + var client = _factory.CreateClient(new() { AllowAutoRedirect = false }); + client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}"); + client.Timeout = TimeSpan.FromSeconds(30); + return client; + } + + [Theory] + [InlineData("/matchmake/instance/999")] + [InlineData("/matchmake/invite/999")] + [InlineData("/matchmake/chatinvite/2/16777216")] // {accountId}/{roomInstanceId} + public async Task Matchmake_pull_routes_exist(string path) + { + using var client = ApiClient(); + using var res = await client.PostAsync(path, new StringContent(string.Empty)); + + // The bug was a hard 404 (no route). These resolve to a dorm-fallback + // matchmaking response when the instance/invite doesn't exist, so any + // non-404 status proves the route is now mounted. + Assert.NotEqual(HttpStatusCode.NotFound, res.StatusCode); + } + + [Theory] + [InlineData("/matchmake/instance/999")] + [InlineData("/matchmake/invite/999")] + [InlineData("/matchmake/chatinvite/2/16777216")] + [InlineData("/matchmake/room/5")] + public void Matchmake_paths_route_to_same_service_as_goto(string path) + { + const string apex = "dork.test"; + var gotoOwner = DorkNetRouteOwnership.ResolvePublicService($"api.{apex}", "/goto/instance/999", apex); + var owner = DorkNetRouteOwnership.ResolvePublicService($"api.{apex}", path, apex); + + // matchmake/* must land on the same service that owns goto/* — before + // the fix it fell through to the Monolith default owner. + Assert.Equal(gotoOwner, owner); + } +} diff --git a/DorkNet.Server/Controllers/API/Activities/ActivitiesController.cs b/DorkNet.Server/Controllers/API/Activities/ActivitiesController.cs index a82877c..d58c3c4 100644 --- a/DorkNet.Server/Controllers/API/Activities/ActivitiesController.cs +++ b/DorkNet.Server/Controllers/API/Activities/ActivitiesController.cs @@ -1,16 +1,32 @@ using Microsoft.AspNetCore.Mvc; +using DorkNet.Server.Services; namespace DorkNet.Server.Controllers.API.Activities; /// /// api.{rec.net,localhost}/api/activities/* — per-activity content -/// (charades words, party-game prompts, etc.). The 2020 watch -/// fetches these as a static list at room-join time; we ship an -/// empty list so the activity's bundled defaults kick in. +/// (charades words, party-game prompts, etc.). +/// +/// The March 2023 watch fetches a 3D Charades deck at card-box spawn from +/// api/activities/charades/v1/words/{source}, where {source} +/// is one of three baked CardBox.cardSource slots — Charades, +/// CharadesAprilFoolsDay, or Icebreakers (verified in the +/// 2023.03.21 il2cpp dump). We resolve the admin-bound word list for the +/// requested slot via and return the +/// wire array of { EN_US, Difficulty }. The older December route +/// (no {source}) maps to the default Charades slot. /// [ApiController] -public class ActivitiesController : ControllerBase +public class ActivitiesController(CharadesWordListService charades) : ControllerBase { + [HttpGet("api/activities/charades/v1/words/{source}")] + public async Task CharadesWords(string source) + => Ok(await charades.ResolveWireWordsAsync(source)); + + /// December 2020 shape — no source segment. Serves the default + /// Charades slot so the older client still gets a populated deck. [HttpGet("api/activities/charades/v1/words")] - public IActionResult CharadesWords() => Ok(Array.Empty()); + public async Task CharadesWordsDefault() + => Ok(await charades.ResolveWireWordsAsync( + nameof(CharadesWordListService.CharadesSlot.Charades))); } diff --git a/DorkNet.Server/Controllers/API/Sanitize/SanitizeController.cs b/DorkNet.Server/Controllers/API/Sanitize/SanitizeController.cs index 521e455..6f92a78 100644 --- a/DorkNet.Server/Controllers/API/Sanitize/SanitizeController.cs +++ b/DorkNet.Server/Controllers/API/Sanitize/SanitizeController.cs @@ -10,10 +10,29 @@ namespace DorkNet.Server.Controllers.API.Sanitize; /// invention name / chat message to be submitted. Delegates to /// for the actual scrubbing. Response /// shape: {Text (purified), IsClean (bool)}. +/// +/// When the admin has flipped +/// , +/// every route short-circuits to "clean" — input is returned unmodified +/// and purity checks always pass — so nothing gets censored. /// [ApiController] -public class SanitizeController(ILogger logger) : ControllerBase +public class SanitizeController( + ILogger logger, + ServerSettingsService settings) : ControllerBase { + /// Purify unless the filter is admin-disabled, in which case + /// the input is returned untouched. + private async Task PurifyAsync(string input) + => await settings.IsProfanityFilterDisabledAsync() + ? input + : ProfanityFilter.Purify(input); + + /// Clean-check unless the filter is admin-disabled, in which + /// case everything is treated as clean. + private async Task IsCleanAsync(string input) + => await settings.IsProfanityFilterDisabledAsync() || ProfanityFilter.IsClean(input); + [HttpPost("api/sanitize/v1")] [HttpPost("api/sanitize/v1/text")] public async Task Sanitize() @@ -73,7 +92,7 @@ public class SanitizeController(ILogger logger) : Controller // ASP.NET Core's StringOutputFormatter doesn't serialize as // text/plain (raw string sans quotes), which the watch then sees // as an empty cleanVersion after LitJson decode. - var purified = ProfanityFilter.Purify(text); + var purified = await PurifyAsync(text); var json = System.Text.Json.JsonSerializer.Serialize(purified); logger.LogInformation("[sanitize] input=\"{Input}\" purified=\"{Purified}\" json={Json}", text, purified, json); return Content(json, "application/json"); @@ -95,7 +114,7 @@ public class SanitizeController(ILogger logger) : Controller /// {Text} with profanity replaced. Wire shape matches /// StringSanitization.PurifyString. [HttpPost("api/sanitize/v1/purifyString")] - public IActionResult PurifyString( + public async Task PurifyString( [FromBody] SanitizeBody? body, [FromForm(Name = "Text")] string? textForm, [FromForm(Name = "Input")] string? inputForm, @@ -104,28 +123,28 @@ public class SanitizeController(ILogger logger) : Controller { var input = body?.Text ?? body?.Input ?? body?.Value ?? textForm ?? inputForm ?? valueForm ?? textQuery ?? string.Empty; - return Ok(new { Text = ProfanityFilter.Purify(input) }); + return Ok(new { Text = await PurifyAsync(input) }); } /// POST api/sanitize/v1/requestIsStringPure — /// returns a bare JSON bool. Watch's ExpectPrimitiveResponse /// rejects object bodies here. [HttpPost("api/sanitize/v1/requestIsStringPure")] - public IActionResult IsStringPure( + public async Task IsStringPure( [FromBody] SanitizeBody? body, [FromForm(Name = "Text")] string? textForm, [FromQuery(Name = "text")] string? textQuery) { var input = body?.Text ?? body?.Input ?? body?.Value ?? textForm ?? textQuery ?? string.Empty; - return Ok(ProfanityFilter.IsClean(input)); + return Ok(await IsCleanAsync(input)); } /// POST api/sanitize/v1/isPure — 2023 route used /// by CV2 maker pen text such as port names. It imports an object /// field named IsPure. [HttpPost("api/sanitize/v1/isPure")] - public IActionResult IsPureV1( + public async Task IsPureV1( [FromBody] SanitizeBody? body, [FromForm(Name = "Text")] string? textForm, [FromForm(Name = "Input")] string? inputForm, @@ -134,7 +153,7 @@ public class SanitizeController(ILogger logger) : Controller { var input = body?.Text ?? body?.Input ?? body?.Value ?? textForm ?? inputForm ?? valueForm ?? textQuery ?? string.Empty; - var isPure = ProfanityFilter.IsClean(input); + var isPure = await IsCleanAsync(input); logger.LogInformation( "[sanitize] isPure context={Context} intent={Intent} ruleset={Ruleset} valueLength={ValueLength} isPure={IsPure}", body?.Context ?? string.Empty, @@ -151,13 +170,13 @@ public class SanitizeController(ILogger logger) : Controller /// used by room/settings forms. Its importer reads an IsPure /// object field, unlike the newer bare-bool route above. [HttpPost("api/sanitize/isPure")] - public IActionResult IsPure( + public async Task IsPure( [FromBody] SanitizeBody? body, [FromForm(Name = "Text")] string? textForm, [FromQuery(Name = "text")] string? textQuery) { var input = body?.Text ?? body?.Input ?? body?.Value ?? textForm ?? textQuery ?? string.Empty; - return Ok(new { IsPure = ProfanityFilter.IsClean(input) }); + return Ok(new { IsPure = await IsCleanAsync(input) }); } } diff --git a/DorkNet.Server/Controllers/Admin/AdminController.cs b/DorkNet.Server/Controllers/Admin/AdminController.cs index fb8f5db..bb2e97f 100644 --- a/DorkNet.Server/Controllers/Admin/AdminController.cs +++ b/DorkNet.Server/Controllers/Admin/AdminController.cs @@ -36,6 +36,7 @@ public class AdminController( PlayerPresenceService playerPresence, PlayerLogService playerLog, ServerSettingsService serverSettings, + CharadesWordListService charades, SignupCodeService signupCodes, IObjectStorage storage, DomainConfig domain, @@ -1490,12 +1491,121 @@ public class AdminController( row.SignupsDisabled, row.GlobalFriendsEnabled, row.WeeklyChallengesCompletedRequired, + row.ProfanityFilterDisabled, row.UpdatedAt, }); } public sealed record SignupsToggleRequest(bool Disabled); public sealed record GlobalFriendsToggleRequest(bool Enabled); + public sealed record ProfanityFilterToggleRequest(bool Disabled); + + /// POST api/admin/v1/settings/profanity — flip the + /// server-side profanity filter. While disabled, every + /// api/sanitize/* route returns input unchanged and treats all + /// text as clean, so room/invention names and chat are never censored. + /// Takes effect immediately (checked per request). + [HttpPost("settings/profanity")] + public async Task SetProfanityFilterDisabled([FromBody] ProfanityFilterToggleRequest body) + { + var row = await serverSettings.SetProfanityFilterDisabledAsync(body.Disabled); + await LogAsync(body.Disabled ? "profanity_filter_disabled" : "profanity_filter_enabled", "system", 0, ""); + await db.SaveChangesAsync(); + return Ok(new + { + row.SignupsDisabled, + row.GlobalFriendsEnabled, + row.WeeklyChallengesCompletedRequired, + row.ProfanityFilterDisabled, + row.UpdatedAt, + }); + } + + // ── 3D Charades word lists ─────────────────────────────────────────── + + public sealed record CharadesWordDto(string Text, int Difficulty); + public sealed record CharadesWordListWriteDto(string? Name, List? Words); + public sealed record CharadesImportDto(string? Text, int DefaultDifficulty, bool Replace); + public sealed record CharadesBindingsDto(long Charades, long CharadesAprilFoolsDay, long Icebreakers); + + private static List? MapWords(List? words) + => words?.Select(w => new CharadesWordListService.CharadesWord(w.Text, w.Difficulty)).ToList(); + + /// GET api/admin/v1/charades/wordlists — the whole word + /// list library plus the live slot→list bindings, so the SPA can render + /// the editor and the three slot pickers in one round trip. + [HttpGet("charades/wordlists")] + public async Task ListCharadesWordLists() + { + var lists = await charades.GetAllAsync(); + var bindings = await charades.GetBindingsAsync(); + return Ok(new { lists, bindings }); + } + + /// POST api/admin/v1/charades/wordlists — create a new + /// word list (optionally pre-populated). + [HttpPost("charades/wordlists")] + public async Task CreateCharadesWordList([FromBody] CharadesWordListWriteDto body) + { + var dto = await charades.CreateAsync(body.Name ?? string.Empty, MapWords(body.Words)); + await LogAsync("create_charades_list", "global", dto.Id, dto.Name); + return Ok(dto); + } + + /// PUT api/admin/v1/charades/wordlists/{id} — rename + /// and/or replace the words of a list. Null fields are left as-is. + [HttpPut("charades/wordlists/{id:long}")] + public async Task UpdateCharadesWordList(long id, [FromBody] CharadesWordListWriteDto body) + { + var dto = await charades.UpdateAsync(id, body.Name, MapWords(body.Words)); + if (dto is null) return NotFound(); + await LogAsync("update_charades_list", "global", id, dto.Name); + return Ok(dto); + } + + /// DELETE api/admin/v1/charades/wordlists/{id} — remove a + /// list. Any slot pointing at it is unbound (falls back to its built-in). + [HttpDelete("charades/wordlists/{id:long}")] + public async Task DeleteCharadesWordList(long id) + { + var ok = await charades.DeleteAsync(id); + if (!ok) return NotFound(); + await LogAsync("delete_charades_list", "global", id, ""); + return Ok(new { deleted = id }); + } + + /// POST api/admin/v1/charades/wordlists/{id}/import — + /// bulk-add cards from pasted text (one phrase per line, optional + /// | difficulty suffix). Replace=true overwrites the + /// list; otherwise the cards are appended. + [HttpPost("charades/wordlists/{id:long}/import")] + public async Task ImportCharadesWordList(long id, [FromBody] CharadesImportDto body) + { + var dto = await charades.ImportAsync(id, body.Text ?? string.Empty, body.DefaultDifficulty, body.Replace); + if (dto is null) return NotFound(); + await LogAsync("import_charades_list", "global", id, dto.Name); + return Ok(dto); + } + + /// PUT api/admin/v1/charades/bindings — set which list is + /// live for each of the client's three card-source slots. A list id of + /// 0 unbinds that slot (falls back to its built-in). This is the + /// "switch between lists" control. + [HttpPut("charades/bindings")] + public async Task SetCharadesBindings([FromBody] CharadesBindingsDto body) + { + foreach (var listId in new[] { body.Charades, body.CharadesAprilFoolsDay, body.Icebreakers }) + { + if (listId != 0 && !await db.CharadesWordLists.AnyAsync(l => l.Id == listId)) + return BadRequest(new { error = "unknown_list", id = listId }); + } + + var bindings = await serverSettings.SetCharadesSlotBindingsAsync( + new CharadesSlotBindings(body.Charades, body.CharadesAprilFoolsDay, body.Icebreakers)); + await LogAsync("set_charades_bindings", "system", 0, + $"{bindings.Charades}/{bindings.CharadesAprilFoolsDay}/{bindings.Icebreakers}"); + return Ok(bindings); + } /// POST api/admin/v1/settings/global-friends — flip the /// "everyone is friends" toggle. No relationship rows are written; the diff --git a/DorkNet.Server/Controllers/Match/GoToController.cs b/DorkNet.Server/Controllers/Match/GoToController.cs index 008a271..8da87f5 100644 --- a/DorkNet.Server/Controllers/Match/GoToController.cs +++ b/DorkNet.Server/Controllers/Match/GoToController.cs @@ -505,6 +505,11 @@ public class GoToController( /// dorm if the instance was forgotten (server restart, owner /// already left and nobody re-created it). [HttpPost("/goto/instance/{roomInstanceId:long}")] + // The 2023 client renamed the whole matchmaking surface from goto/* to + // matchmake/* (verified 2023.03.21 il2cpp dump — RecNet.Matchmaking + // ANOAAIHGHMG builds "matchmake/instance/{0}"). Same handler, both + // mounts, so December and 2023 clients each hit the path they build. + [HttpPost("/matchmake/instance/{roomInstanceId:long}")] public async Task> GoToInstance(long roomInstanceId) { var resp = await BuildPrivateInstanceJoinResponseAsync(roomInstanceId); @@ -512,7 +517,51 @@ public class GoToController( return Ok(resp); } + /// POST matchmake/chatinvite/{accountId}/{roomInstanceId} + /// — the 2023 "pull into my match" path. When a party owner invites + /// someone over chat (PlayerParty.AcceptChatPartyInvite → + /// RecNet.Matchmaking chatinvite, verified in the 2023.03.21 il2cpp + /// dump), the invitee's watch posts here to matchmake into the owner's + /// instance. The two path params are the inviter's account id and the + /// room instance id in an order the client doesn't document; a private + /// instance id is always ≥ 2^24 while account ids are small sequential, + /// so we take the param that resolves to a registered instance (falling + /// back to the larger value). Unlike the message-based + /// POST /invite flow, a chat party invite writes no invitee row, + /// so we add the caller to the allow-list here before resolving the + /// join — the owner explicitly pulled them in. + [HttpPost("/matchmake/chatinvite/{p0:long}/{p1:long}")] + public async Task> MatchmakeChatInvite(long p0, long p1) + { + var roomInstanceId = await ResolveChatInviteInstanceAsync(p0, p1); + + if (this.CurrentPlayerId() is long callerId && roomInstanceId > 0) + await privateInstances.InviteAsync(roomInstanceId, callerId); + + var resp = await BuildPrivateInstanceJoinResponseAsync(roomInstanceId); + await RecordResponseAsync(resp); + logger.LogInformation( + "[matchmake-chatinvite] caller={Caller} p0={P0} p1={P1} → instance={Instance} error={Error}", + this.CurrentPlayerId(), p0, p1, roomInstanceId, resp.ErrorCode); + return Ok(resp); + } + + /// Pick the room-instance param out of a chatinvite's two path + /// segments. Prefers whichever resolves to a registered private + /// instance; if neither does (e.g. a public-room party pull whose id is + /// synthetic), falls back to the larger value since instance ids dwarf + /// account ids. + private async Task ResolveChatInviteInstanceAsync(long p0, long p1) + { + if (await privateInstances.GetAsync(p0) is not null) return p0; + if (await privateInstances.GetAsync(p1) is not null) return p1; + return Math.Max(p0, p1); + } + [HttpPost("/goto/invite/{inviteId:long}")] + // 2023 rename of goto/invite → matchmake/invite (RecNet.Matchmaking + // OIEDPBLNMOP). {inviteId} is the invite message id, same as December. + [HttpPost("/matchmake/invite/{inviteId:long}")] public async Task> GoToInvite(long inviteId) { var callerId = this.CurrentPlayerId(); diff --git a/DorkNet.Server/Data/DorkNetDbContext.cs b/DorkNet.Server/Data/DorkNetDbContext.cs index feaca38..af9834e 100644 --- a/DorkNet.Server/Data/DorkNetDbContext.cs +++ b/DorkNet.Server/Data/DorkNetDbContext.cs @@ -60,6 +60,7 @@ public class DorkNetDbContext(DbContextOptions options) : DbCo public DbSet GameSessions => Set(); public DbSet CommunityBoardRows => Set(); public DbSet LoadingScreenTips => Set(); + public DbSet CharadesWordLists => Set(); public DbSet ServerSettings => Set(); public DbSet RoomKeys => Set(); public DbSet RoomKeyPurchases => Set(); diff --git a/DorkNet.Server/Data/Entities/CharadesWordListEntity.cs b/DorkNet.Server/Data/Entities/CharadesWordListEntity.cs new file mode 100644 index 0000000..d5f1630 --- /dev/null +++ b/DorkNet.Server/Data/Entities/CharadesWordListEntity.cs @@ -0,0 +1,46 @@ +namespace DorkNet.Server.Data.Entities; + +/// +/// One admin-authored 3D Charades word list. The March 2023 client +/// pulls a deck at card-box spawn time from +/// api/activities/charades/v1/words/{source}, where +/// {source} is one of three baked CardBox.cardSource +/// slots — Charades, CharadesAprilFoolsDay, or +/// Icebreakers (verified in the 2023.03.21 il2cpp dump: +/// GEFMIBEPMKJ.EODJJDILECA). The game itself only ever requests +/// those three ids. +/// +/// To let admins keep an unlimited library and freely switch which list +/// each slot serves, every list is a row here (name + words), and the +/// live slot→list binding lives in +/// . This is +/// the same admin-editable-collection shape as +/// : a plain entity CRUD'd from the +/// SPA, created on existing DBs via the idempotent bootstrap (it +/// post-dates the consolidated Initial migration). +/// +public class CharadesWordListEntity +{ + public long Id { get; set; } + + /// Admin-facing display name, e.g. "Default", "Movie night", + /// "April Fools". Not sent to the client. + public string Name { get; set; } = string.Empty; + + /// Serialized List<CharadesWord> — each entry is + /// { Text, Difficulty }. Text becomes the wire + /// EN_US field; Difficulty is the client + /// CNMMMNJJDMM enum (0 easy, 1 hard, 10 very hard, 20 + /// icebreaker). Stored as JSON so a list can grow to hundreds of + /// cards without a child table. + public string WordsJson { get; set; } = "[]"; + + /// True for the lists seeded on first boot (Default / + /// April Fools / Icebreakers). Purely informational — admins may + /// still rename, edit, or delete them; it only gates the one-time + /// seed so we don't duplicate them every startup. + public bool IsBuiltIn { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/DorkNet.Server/Data/Entities/ServerSettingsEntity.cs b/DorkNet.Server/Data/Entities/ServerSettingsEntity.cs index 4d3c979..36c009c 100644 --- a/DorkNet.Server/Data/Entities/ServerSettingsEntity.cs +++ b/DorkNet.Server/Data/Entities/ServerSettingsEntity.cs @@ -64,5 +64,23 @@ public class ServerSettingsEntity /// migration each time. public string DiscoveredGameConfigsJson { get; set; } = string.Empty; + /// When true, the server-side profanity filter behind + /// api/sanitize/* is bypassed — every string is treated as + /// clean and returned unmodified. Built for private friend servers + /// that don't want room/invention names or chat censored. Off by + /// default (filter active). See + /// and SanitizeController. + public bool ProfanityFilterDisabled { get; set; } + + /// Which charades word list (row id in + /// ) is live for each of the + /// client's three baked card-source slots. JSON object keyed by the + /// client enum name — {"Charades":id,"CharadesAprilFoolsDay":id, + /// "Icebreakers":id}. Empty/0 or a dangling id falls back to the + /// list the seeder created for that slot. Lets admins keep an + /// unlimited library and switch which list a slot serves without a + /// redeploy. + public string CharadesSlotBindingsJson { get; set; } = string.Empty; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; } diff --git a/DorkNet.Server/Services/CharadesWordListService.cs b/DorkNet.Server/Services/CharadesWordListService.cs new file mode 100644 index 0000000..c6f2826 --- /dev/null +++ b/DorkNet.Server/Services/CharadesWordListService.cs @@ -0,0 +1,442 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using DorkNet.Server.Data; +using DorkNet.Server.Data.Entities; +using Microsoft.EntityFrameworkCore; + +namespace DorkNet.Server.Services; + +/// +/// Admin-managed library of 3D Charades word lists plus the live +/// slot→list bindings the March 2023 client reads. +/// +/// Wire contract (verified in the 2023.03.21 il2cpp dump — +/// CardBox / GEFMIBEPMKJ): the client GETs +/// api/activities/charades/v1/words/{source} at card-box spawn, +/// where {source} is one of three baked cardSource enum +/// slots (). The response is a JSON array of +/// { "EN_US": string, "Difficulty": int } where Difficulty is the +/// client CNMMMNJJDMM enum ( etc.). +/// +/// Storage: each list is a row +/// (unlimited library); the live slot bindings live in +/// via +/// . Switching a slot just repoints its +/// binding — no redeploy, effective on the next card-box refresh. +/// +public class CharadesWordListService(DorkNetDbContext db, ServerSettingsService settings) +{ + /// The three baked CardBox.cardSource values the client + /// can request. Integer values match the client + /// GEFMIBEPMKJ.EODJJDILECA enum. + public enum CharadesSlot + { + Charades = 0, + CharadesAprilFoolsDay = 1, + Icebreakers = 2, + } + + // Client CNMMMNJJDMM difficulty enum values. + public const int DifficultyEasy = 0; + public const int DifficultyHard = 1; + public const int DifficultyVeryHard = 10; + public const int DifficultyIcebreaker = 20; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + }; + + /// One card as stored/edited in the admin UI. + public sealed record CharadesWord(string Text, int Difficulty); + + /// A word list as returned to the admin SPA. + public sealed record CharadesWordListDto( + long Id, + string Name, + IReadOnlyList Words, + bool IsBuiltIn, + DateTime UpdatedAt); + + /// Wire entry. Property names are pinned to the exact casing + /// the client's LitJson importer reads (EN_US, Difficulty); + /// LitJson key lookup is case-sensitive. + public sealed record CharadesWordWire( + [property: JsonPropertyName("EN_US")] string EN_US, + [property: JsonPropertyName("Difficulty")] int Difficulty); + + // ---- library CRUD ---------------------------------------------------- + + public async Task> GetAllAsync() + { + var rows = await db.CharadesWordLists.AsNoTracking() + .OrderByDescending(l => l.IsBuiltIn) + .ThenBy(l => l.Id) + .ToListAsync(); + return rows.Select(ToDto).ToList(); + } + + public async Task GetAsync(long id) + { + var row = await db.CharadesWordLists.AsNoTracking() + .FirstOrDefaultAsync(l => l.Id == id); + return row is null ? null : ToDto(row); + } + + public async Task CreateAsync( + string name, IReadOnlyList? words = null, bool isBuiltIn = false) + { + var row = new CharadesWordListEntity + { + Name = CleanName(name), + WordsJson = SerializeWords(NormalizeWords(words ?? [])), + IsBuiltIn = isBuiltIn, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + }; + db.CharadesWordLists.Add(row); + await db.SaveChangesAsync(); + return ToDto(row); + } + + /// Rename and/or replace the words of a list. Null args are + /// left untouched. + public async Task UpdateAsync( + long id, string? name, IReadOnlyList? words) + { + var row = await db.CharadesWordLists.FirstOrDefaultAsync(l => l.Id == id); + if (row is null) return null; + if (name is not null) row.Name = CleanName(name); + if (words is not null) row.WordsJson = SerializeWords(NormalizeWords(words)); + row.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + return ToDto(row); + } + + public async Task DeleteAsync(long id) + { + var row = await db.CharadesWordLists.FirstOrDefaultAsync(l => l.Id == id); + if (row is null) return false; + db.CharadesWordLists.Remove(row); + await db.SaveChangesAsync(); + + // If any slot was pointing at the list we just deleted, drop the + // dangling binding so resolution falls back cleanly. + var bindings = await settings.GetCharadesSlotBindingsAsync(); + if (bindings.Charades == id || bindings.CharadesAprilFoolsDay == id || bindings.Icebreakers == id) + { + await settings.SetCharadesSlotBindingsAsync(new CharadesSlotBindings( + bindings.Charades == id ? 0 : bindings.Charades, + bindings.CharadesAprilFoolsDay == id ? 0 : bindings.CharadesAprilFoolsDay, + bindings.Icebreakers == id ? 0 : bindings.Icebreakers)); + } + return true; + } + + /// Append (or replace) cards parsed from pasted text. + /// One phrase per line; an optional | difficulty suffix per line + /// (easy/hard/veryhard/icebreaker or a raw int) overrides + /// . + public async Task ImportAsync( + long id, string pasteText, int defaultDifficulty, bool replace) + { + var row = await db.CharadesWordLists.FirstOrDefaultAsync(l => l.Id == id); + if (row is null) return null; + + var incoming = ParsePaste(pasteText, defaultDifficulty); + var combined = replace + ? incoming + : DeserializeWords(row.WordsJson).Concat(incoming).ToList(); + + row.WordsJson = SerializeWords(NormalizeWords(combined)); + row.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + return ToDto(row); + } + + // ---- slot bindings --------------------------------------------------- + + public Task GetBindingsAsync() => settings.GetCharadesSlotBindingsAsync(); + + public async Task SetBindingAsync(CharadesSlot slot, long listId) + { + // A listId of 0 clears the binding (falls back to the built-in for + // that slot). A non-zero id must reference an existing list. + if (listId != 0 && !await db.CharadesWordLists.AnyAsync(l => l.Id == listId)) + throw new ArgumentException($"No charades word list with id {listId}."); + + var current = await settings.GetCharadesSlotBindingsAsync(); + var next = slot switch + { + CharadesSlot.Charades => current with { Charades = listId }, + CharadesSlot.CharadesAprilFoolsDay => current with { CharadesAprilFoolsDay = listId }, + CharadesSlot.Icebreakers => current with { Icebreakers = listId }, + _ => current, + }; + return await settings.SetCharadesSlotBindingsAsync(next); + } + + // ---- client resolution ---------------------------------------------- + + /// Resolve the wire deck for a client-requested source. The + /// route param may be the enum name (Charades) or its int + /// (0); unknown values fall back to the Charades slot. Returns + /// the bound list's cards, or that slot's built-in seed list if the + /// binding is empty/dangling, or an empty array as a last resort. + public async Task> ResolveWireWordsAsync(string source) + { + var slot = ParseSlot(source); + var bindings = await settings.GetCharadesSlotBindingsAsync(); + var boundId = slot switch + { + CharadesSlot.CharadesAprilFoolsDay => bindings.CharadesAprilFoolsDay, + CharadesSlot.Icebreakers => bindings.Icebreakers, + _ => bindings.Charades, + }; + + CharadesWordListEntity? row = null; + if (boundId != 0) + row = await db.CharadesWordLists.AsNoTracking().FirstOrDefaultAsync(l => l.Id == boundId); + + // Fallback: the built-in list whose name matches the slot's default. + row ??= await db.CharadesWordLists.AsNoTracking() + .Where(l => l.IsBuiltIn && l.Name == DefaultListName(slot)) + .FirstOrDefaultAsync(); + + var words = row is null ? [] : DeserializeWords(row.WordsJson); + return words.Select(w => new CharadesWordWire(w.Text, w.Difficulty)).ToList(); + } + + // ---- seeding --------------------------------------------------------- + + /// Seed the three built-in lists (Default / April Fools / + /// Icebreakers) and point each slot at them. Idempotent: no-ops once any + /// list exists, so admin edits are never clobbered on restart. + public async Task SeedAsync() + { + if (await db.CharadesWordLists.AnyAsync()) return; + + var defaultList = await CreateAsync( + DefaultListName(CharadesSlot.Charades), + DefaultCharadesWords, isBuiltIn: true); + var aprilList = await CreateAsync( + DefaultListName(CharadesSlot.CharadesAprilFoolsDay), + AprilFoolsWords, isBuiltIn: true); + var iceList = await CreateAsync( + DefaultListName(CharadesSlot.Icebreakers), + IcebreakerWords, isBuiltIn: true); + + await settings.SetCharadesSlotBindingsAsync(new CharadesSlotBindings( + defaultList.Id, aprilList.Id, iceList.Id)); + } + + // ---- helpers --------------------------------------------------------- + + public static IReadOnlyList ParsePaste(string text, int defaultDifficulty) + { + if (string.IsNullOrWhiteSpace(text)) return []; + var result = new List(); + foreach (var raw in text.Split('\n')) + { + var line = raw.Trim().TrimEnd('\r').Trim(); + if (line.Length == 0) continue; + + var difficulty = defaultDifficulty; + var pipe = line.LastIndexOf('|'); + if (pipe > 0 && pipe < line.Length - 1) + { + var maybe = line[(pipe + 1)..].Trim(); + if (TryParseDifficulty(maybe, out var d)) + { + difficulty = d; + line = line[..pipe].Trim(); + } + } + if (line.Length == 0) continue; + result.Add(new CharadesWord(line, difficulty)); + } + return result; + } + + private static bool TryParseDifficulty(string s, out int difficulty) + { + switch (s.Replace(" ", string.Empty).ToLowerInvariant()) + { + case "easy": difficulty = DifficultyEasy; return true; + case "hard": difficulty = DifficultyHard; return true; + case "veryhard": case "vhard": case "stupidhard": difficulty = DifficultyVeryHard; return true; + case "icebreaker": case "icebreakers": difficulty = DifficultyIcebreaker; return true; + } + if (int.TryParse(s, out var n) && + n is DifficultyEasy or DifficultyHard or DifficultyVeryHard or DifficultyIcebreaker) + { + difficulty = n; + return true; + } + difficulty = DifficultyEasy; + return false; + } + + private static CharadesSlot ParseSlot(string source) + { + if (string.IsNullOrWhiteSpace(source)) return CharadesSlot.Charades; + var s = source.Trim(); + if (int.TryParse(s, out var n) && Enum.IsDefined(typeof(CharadesSlot), n)) + return (CharadesSlot)n; + if (Enum.TryParse(s, ignoreCase: true, out var slot)) + return slot; + return CharadesSlot.Charades; + } + + private static string DefaultListName(CharadesSlot slot) => slot switch + { + CharadesSlot.CharadesAprilFoolsDay => "April Fools (impossible)", + CharadesSlot.Icebreakers => "Icebreakers", + _ => "Default", + }; + + private static CharadesWordListDto ToDto(CharadesWordListEntity row) => new( + row.Id, row.Name, DeserializeWords(row.WordsJson), row.IsBuiltIn, row.UpdatedAt); + + private static string CleanName(string name) + { + var trimmed = (name ?? string.Empty).Trim(); + if (trimmed.Length == 0) trimmed = "Untitled list"; + return trimmed.Length > 128 ? trimmed[..128] : trimmed; + } + + /// Trim, drop blanks, clamp difficulty to a known value, and + /// de-duplicate case-insensitively (keeping first occurrence). + private static IReadOnlyList NormalizeWords(IReadOnlyList words) + { + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + var result = new List(); + foreach (var w in words) + { + var text = (w.Text ?? string.Empty).Trim(); + if (text.Length == 0) continue; + if (text.Length > 256) text = text[..256]; + if (!seen.Add(text)) continue; + var difficulty = w.Difficulty is DifficultyEasy or DifficultyHard + or DifficultyVeryHard or DifficultyIcebreaker + ? w.Difficulty + : DifficultyEasy; + result.Add(new CharadesWord(text, difficulty)); + } + return result; + } + + private static string SerializeWords(IReadOnlyList words) => + JsonSerializer.Serialize(words, JsonOptions); + + private static IReadOnlyList DeserializeWords(string json) + { + if (string.IsNullOrWhiteSpace(json)) return []; + try + { + return JsonSerializer.Deserialize>(json, JsonOptions) ?? []; + } + catch + { + return []; + } + } + + // ---- seed content ---------------------------------------------------- + + private static CharadesWord E(string t) => new(t, DifficultyEasy); + private static CharadesWord H(string t) => new(t, DifficultyHard); + private static CharadesWord V(string t) => new(t, DifficultyVeryHard); + private static CharadesWord I(string t) => new(t, DifficultyIcebreaker); + + /// A broad, family-friendly default deck spread across the + /// three charades difficulties (easy / hard / very hard). + private static readonly IReadOnlyList DefaultCharadesWords = + [ + // Easy — single actable words + E("Dog"), E("Cat"), E("Elephant"), E("Monkey"), E("Snake"), E("Rabbit"), + E("Penguin"), E("Shark"), E("Spider"), E("Kangaroo"), E("Frog"), E("Horse"), + E("Running"), E("Swimming"), E("Dancing"), E("Sleeping"), E("Eating"), E("Jumping"), + E("Crying"), E("Laughing"), E("Sneezing"), E("Fishing"), E("Cooking"), E("Painting"), + E("Guitar"), E("Piano"), E("Drums"), E("Basketball"), E("Soccer"), E("Tennis"), + E("Skateboard"), E("Bicycle"), E("Airplane"), E("Rocket"), E("Boat"), E("Train"), + E("Umbrella"), E("Telephone"), E("Camera"), E("Toothbrush"), E("Scissors"), E("Hammer"), + E("Pizza"), E("Ice cream"), E("Banana"), E("Popcorn"), E("Birthday cake"), E("Hamburger"), + E("Robot"), E("Ghost"), E("Zombie"), E("Superhero"), E("Pirate"), E("Wizard"), + // Hard — phrases, titles, actions + H("Riding a roller coaster"), H("Brushing your teeth"), H("Tying your shoes"), + H("Blowing out candles"), H("Milking a cow"), H("Climbing a ladder"), + H("Walking a tightrope"), H("Playing video games"), H("Taking a selfie"), + H("Surfing a wave"), H("Building a sandcastle"), H("Chopping wood"), + H("Conducting an orchestra"), H("Directing traffic"), H("Juggling"), + H("The Statue of Liberty"), H("A washing machine"), H("A vending machine"), + H("A traffic light"), H("A grandfather clock"), H("A roller coaster"), + H("Peter Pan"), H("Sherlock Holmes"), H("Frankenstein"), H("Cinderella"), + H("Harry Potter"), H("Spider-Man"), H("The Loch Ness Monster"), + H("Doing laundry"), H("Mowing the lawn"), H("Flying a kite"), + H("Scuba diving"), H("Rock climbing"), H("Bowling a strike"), + // Very hard — abstract-but-still-actable, longer titles + V("Photosynthesis"), V("The theory of relativity"), V("A midlife crisis"), + V("Rush hour traffic"), V("Herding cats"), V("A house of cards"), + V("The circle of life"), V("Breaking the fourth wall"), V("A wild goose chase"), + V("Death and taxes"), V("The domino effect"), V("Time flies when you're having fun"), + V("A blessing in disguise"), V("Barking up the wrong tree"), + V("The elephant in the room"), V("Raining cats and dogs"), + V("A penny for your thoughts"), V("Bite the bullet"), + V("The tip of the iceberg"), V("Once in a blue moon"), + ]; + + /// April Fools deck — concepts that are (nearly) impossible to + /// act out. All flagged very hard. + private static readonly IReadOnlyList AprilFoolsWords = + [ + V("The concept of Tuesday"), V("Existential dread"), V("The smell of rain"), + V("Object-oriented programming"), V("The year 1997"), V("Nostalgia"), + V("The color infrared"), V("A prime number"), V("Dramatic irony"), + V("The stock market"), V("Deja vu"), V("The taste of the number seven"), + V("Quantum entanglement"), V("The feeling of Sunday night"), + V("Sarcasm"), V("The concept of infinity"), V("Static electricity"), + V("The sound of one hand clapping"), V("Impostor syndrome"), + V("The general theory of relativity"), V("A tax audit"), + V("The plot of Inception"), V("Schadenfreude"), V("The Dewey Decimal System"), + V("Awkward silence"), V("The gluten in bread"), V("Inflation"), + V("The concept of a concept"), V("Wi-Fi"), V("The color of Wednesday"), + V("A recursive function"), V("The heat death of the universe"), + V("Peripheral vision"), V("The smell of a new car"), + V("Cryptocurrency"), V("The uncanny valley"), V("A palindrome"), + V("The passage of time"), V("Cognitive dissonance"), + V("The concept of nothing at all"), + ]; + + /// Icebreaker prompts for the Icebreakers card source. All + /// flagged with the icebreaker difficulty so they group correctly. + private static readonly IReadOnlyList IcebreakerWords = + [ + I("Your dream vacation"), I("A hidden talent you have"), + I("Your favorite childhood snack"), I("The best gift you ever got"), + I("A superpower you'd want"), I("Your go-to karaoke song"), + I("The last show you binged"), I("Your comfort food"), + I("A place you'd love to visit"), I("Your favorite season"), + I("The best concert you've been to"), I("Your dream job as a kid"), + I("A weird food combo you love"), I("Your favorite board game"), + I("The best advice you've received"), I("A skill you'd love to learn"), + I("Your favorite holiday"), I("The first thing you'd buy if you won the lottery"), + I("Your most-used emoji"), I("A movie you can quote by heart"), + I("Your favorite way to relax"), I("The pet you have or wish you had"), + I("A hobby you picked up recently"), I("Your favorite ice cream flavor"), + I("The best trip you've ever taken"), I("A song stuck in your head"), + I("Your favorite thing about Rec Room"), I("The coolest place you've been"), + I("A talent show act you'd perform"), I("Your dream superpower team-up"), + ]; +} + +/// Which word list (row id) is live for each of the client's +/// three baked charades card-source slots. A 0 means "unbound" — the +/// resolver falls back to that slot's built-in seed list. +public sealed record CharadesSlotBindings( + long Charades, + long CharadesAprilFoolsDay, + long Icebreakers) +{ + public static CharadesSlotBindings Empty => new(0, 0, 0); +} diff --git a/DorkNet.Server/Services/ServerSettingsService.cs b/DorkNet.Server/Services/ServerSettingsService.cs index 575fc7f..f9aec38 100644 --- a/DorkNet.Server/Services/ServerSettingsService.cs +++ b/DorkNet.Server/Services/ServerSettingsService.cs @@ -151,6 +151,52 @@ public class ServerSettingsService(DorkNetDbContext db) return ToWeeklySettings(existing); } + /// Whether the server-side profanity filter behind + /// api/sanitize/* is bypassed. Off by default (filter active). + public async Task IsProfanityFilterDisabledAsync() + { + var row = await db.ServerSettings.AsNoTracking() + .FirstOrDefaultAsync(s => s.Id == RowId); + return row?.ProfanityFilterDisabled ?? false; + } + + public async Task SetProfanityFilterDisabledAsync(bool disabled) + { + var existing = await GetTrackedRowAsync(); + existing.ProfanityFilterDisabled = disabled; + existing.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + return existing; + } + + /// Live charades slot→list bindings. Empty/unparseable JSON + /// resolves to (every slot + /// falls back to its built-in seed list). + public async Task GetCharadesSlotBindingsAsync() + { + var row = await GetAsync(); + if (string.IsNullOrWhiteSpace(row.CharadesSlotBindingsJson)) + return CharadesSlotBindings.Empty; + try + { + return JsonSerializer.Deserialize( + row.CharadesSlotBindingsJson, JsonOptions) ?? CharadesSlotBindings.Empty; + } + catch + { + return CharadesSlotBindings.Empty; + } + } + + public async Task SetCharadesSlotBindingsAsync(CharadesSlotBindings bindings) + { + var existing = await GetTrackedRowAsync(); + existing.CharadesSlotBindingsJson = JsonSerializer.Serialize(bindings, JsonOptions); + existing.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + return bindings; + } + private async Task GetTrackedRowAsync() { var existing = await db.ServerSettings.FirstOrDefaultAsync(s => s.Id == RowId); diff --git a/DorkNet.Server/Startup/DatabaseBootstrap.cs b/DorkNet.Server/Startup/DatabaseBootstrap.cs index 4b6bb53..aafdddb 100644 --- a/DorkNet.Server/Startup/DatabaseBootstrap.cs +++ b/DorkNet.Server/Startup/DatabaseBootstrap.cs @@ -103,6 +103,7 @@ public static class DatabaseBootstrap // IF NOT EXISTS no-ops when EnsureCreated already built them). await EnsureSignupCodeTablesAsync(db); await EnsureServerSettingsColumnsAsync(db); + await EnsureCharadesWordListTableAsync(db); await EnsureInventorySchemaAsync(db); await EnsureLeaderboardSchemaAsync(db); await EnsureRoomColumnsAsync(db); @@ -168,6 +169,13 @@ public static class DatabaseBootstrap var storeService = scope.ServiceProvider.GetRequiredService(); await storeService.SeedAsync(); + // Seed the three built-in 3D Charades word lists (Default / April + // Fools / Icebreakers) and bind each client card-source slot to + // them. Idempotent — no-ops once any list exists so admin edits + // survive restarts. + var charadesService = scope.ServiceProvider.GetRequiredService(); + await charadesService.SeedAsync(); + // Tell the /healthz probe migrations are done. Coolify's rolling // deploy holds traffic at the LB until this flips and /healthz // returns 200. @@ -438,6 +446,10 @@ public static class DatabaseBootstrap @"ALTER TABLE ""ServerSettings"" ADD COLUMN IF NOT EXISTS ""DiscoveredGameConfigsJson"" text NOT NULL DEFAULT '';"); await db.Database.ExecuteSqlRawAsync( @"ALTER TABLE ""ServerSettings"" ADD COLUMN IF NOT EXISTS ""GlobalFriendsEnabled"" boolean NOT NULL DEFAULT false;"); + await db.Database.ExecuteSqlRawAsync( + @"ALTER TABLE ""ServerSettings"" ADD COLUMN IF NOT EXISTS ""ProfanityFilterDisabled"" boolean NOT NULL DEFAULT false;"); + await db.Database.ExecuteSqlRawAsync( + @"ALTER TABLE ""ServerSettings"" ADD COLUMN IF NOT EXISTS ""CharadesSlotBindingsJson"" text NOT NULL DEFAULT '';"); return; } @@ -488,6 +500,30 @@ public static class DatabaseBootstrap // ignore the duplicate-column error; any other schema problem // will surface when EF reads ServerSettings. } + + try + { + await db.Database.ExecuteSqlRawAsync( + @"ALTER TABLE ""ServerSettings"" ADD COLUMN ""ProfanityFilterDisabled"" INTEGER NOT NULL DEFAULT 0;"); + } + 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. + } + + try + { + await db.Database.ExecuteSqlRawAsync( + @"ALTER TABLE ""ServerSettings"" ADD COLUMN ""CharadesSlotBindingsJson"" 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. + } } private static async Task EnsureInventorySchemaAsync(DorkNetDbContext db) @@ -625,6 +661,41 @@ public static class DatabaseBootstrap @"CREATE INDEX IF NOT EXISTS ""IX_LeaderboardStats_RoomId_StatChannel_Value"" ON ""LeaderboardStats"" (""RoomId"", ""StatChannel"", ""Value"");"); } + /// Idempotently create the CharadesWordLists table on both + /// providers. Post-dates the consolidated Initial migration (same + /// rationale as ): Postgres + /// is EnsureCreated-only and the SQLite Initial migration doesn't + /// include it, so we create it here with CREATE TABLE IF NOT EXISTS + /// (a no-op on a fresh DB where EnsureCreated/convention already + /// built it). + private static async Task EnsureCharadesWordListTableAsync(DorkNetDbContext db) + { + var provider = db.Database.ProviderName ?? string.Empty; + var pg = provider.Contains("Npgsql", StringComparison.OrdinalIgnoreCase); + var sql = pg + ? """ + CREATE TABLE IF NOT EXISTS "CharadesWordLists" ( + "Id" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + "Name" text NOT NULL DEFAULT '', + "WordsJson" text NOT NULL DEFAULT '[]', + "IsBuiltIn" boolean NOT NULL DEFAULT false, + "CreatedAt" timestamp with time zone NOT NULL DEFAULT now(), + "UpdatedAt" timestamp with time zone NOT NULL DEFAULT now() + ); + """ + : """ + CREATE TABLE IF NOT EXISTS "CharadesWordLists" ( + "Id" INTEGER NOT NULL CONSTRAINT "PK_CharadesWordLists" PRIMARY KEY AUTOINCREMENT, + "Name" TEXT NOT NULL DEFAULT '', + "WordsJson" TEXT NOT NULL DEFAULT '[]', + "IsBuiltIn" INTEGER NOT NULL DEFAULT 0, + "CreatedAt" TEXT NOT NULL, + "UpdatedAt" TEXT NOT NULL + ); + """; + await db.Database.ExecuteSqlRawAsync(sql); + } + private static async Task EnsureMarch2023TablesAsync(DorkNetDbContext db) { var provider = db.Database.ProviderName ?? string.Empty; diff --git a/DorkNet.Server/Startup/ServiceCollectionExtensions.cs b/DorkNet.Server/Startup/ServiceCollectionExtensions.cs index 8e6013d..6ff0705 100644 --- a/DorkNet.Server/Startup/ServiceCollectionExtensions.cs +++ b/DorkNet.Server/Startup/ServiceCollectionExtensions.cs @@ -217,6 +217,7 @@ public static class ServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); // Singletons — these own connectionless state (Redis-backed or // process-local) and don't need a per-request scope. services.AddSingleton(); diff --git a/DorkNet.Server/admin-ui/src/pages/Charades.tsx b/DorkNet.Server/admin-ui/src/pages/Charades.tsx new file mode 100644 index 0000000..6ad3078 --- /dev/null +++ b/DorkNet.Server/admin-ui/src/pages/Charades.tsx @@ -0,0 +1,429 @@ +import { useEffect, useMemo, useState } from 'react'; +import { api, get } from '../lib/api'; +import { PageHeader } from '../components/PageHeader'; +import { useToast } from '../components/Toast'; +import { Plus, Trash, RefreshCw } from '../components/Icons'; + +// Difficulty values mirror the client CNMMMNJJDMM enum (verified in the +// 2023.03.21 il2cpp dump). The Charades slot uses easy/hard/very-hard; the +// Icebreakers slot uses its own value so cards group correctly in-game. +const DIFFICULTIES = [ + { value: 0, label: 'Easy' }, + { value: 1, label: 'Hard' }, + { value: 10, label: 'Very hard' }, + { value: 20, label: 'Icebreaker' }, +] as const; + +// The three baked client card-source slots. Each is fed by whichever +// library list its binding points at. +const SLOTS = [ + { + key: 'charades' as const, + title: 'Charades', + blurb: 'The main 3D Charades deck players draw from.', + }, + { + key: 'charadesAprilFoolsDay' as const, + title: 'April Fools', + blurb: 'The seasonal "impossible to explain" deck.', + }, + { + key: 'icebreakers' as const, + title: 'Icebreakers', + blurb: 'Prompt-style cards for the Icebreakers card box.', + }, +]; + +interface CharadesWord { + text: string; + difficulty: number; +} + +interface WordList { + id: number; + name: string; + words: CharadesWord[]; + isBuiltIn: boolean; + updatedAt: string; +} + +interface SlotBindings { + charades: number; + charadesAprilFoolsDay: number; + icebreakers: number; +} + +interface WordListsResponse { + lists: WordList[]; + bindings: SlotBindings; +} + +export function Charades({ embedded }: { embedded?: boolean } = {}) { + const [lists, setLists] = useState(null); + const [bindings, setBindings] = useState(null); + const [err, setErr] = useState(null); + const [busy, setBusy] = useState(false); + const toast = useToast(); + + const load = async () => { + try { + const data = await get('/charades/wordlists'); + setLists(data.lists); + setBindings(data.bindings); + setErr(null); + } catch (e) { + setErr((e as Error).message); + } + }; + + useEffect(() => { void load(); }, []); + + const updateLocal = (idx: number, next: WordList) => { + setLists(cur => { + if (!cur) return cur; + const copy = cur.slice(); + copy[idx] = next; + return copy; + }); + }; + + const addNew = async () => { + setBusy(true); + try { + const created = await api('/charades/wordlists', { + method: 'POST', + body: { Name: 'New list', Words: [] }, + }); + setLists([...(lists ?? []), created]); + toast.push('List created.', 'success'); + } catch (e) { + toast.push((e as Error).message, 'error'); + } finally { + setBusy(false); + } + }; + + const saveList = async (idx: number) => { + if (!lists) return; + const l = lists[idx]; + try { + const saved = await api(`/charades/wordlists/${l.id}`, { + method: 'PUT', + body: { Name: l.name, Words: l.words }, + }); + updateLocal(idx, saved); + toast.push('Saved.', 'success'); + } catch (e) { + toast.push((e as Error).message, 'error'); + } + }; + + const removeList = async (idx: number) => { + if (!lists) return; + const l = lists[idx]; + const boundSlot = bindings && ( + bindings.charades === l.id || + bindings.charadesAprilFoolsDay === l.id || + bindings.icebreakers === l.id); + const warn = boundSlot + ? ` It's currently live on a slot — that slot will fall back to its built-in list.` + : ''; + if (!confirm(`Delete list "${l.name || '(untitled)'}"?${warn}`)) return; + try { + await api(`/charades/wordlists/${l.id}`, { method: 'DELETE' }); + setLists(lists.filter((_, i) => i !== idx)); + // Reflect any binding fallback locally. + setBindings(cur => cur ? { + charades: cur.charades === l.id ? 0 : cur.charades, + charadesAprilFoolsDay: cur.charadesAprilFoolsDay === l.id ? 0 : cur.charadesAprilFoolsDay, + icebreakers: cur.icebreakers === l.id ? 0 : cur.icebreakers, + } : cur); + toast.push('Deleted.', 'success'); + } catch (e) { + toast.push((e as Error).message, 'error'); + } + }; + + const importInto = async (idx: number, text: string, defaultDifficulty: number, replace: boolean) => { + if (!lists) return; + const l = lists[idx]; + try { + const saved = await api(`/charades/wordlists/${l.id}/import`, { + method: 'POST', + body: { Text: text, DefaultDifficulty: defaultDifficulty, Replace: replace }, + }); + updateLocal(idx, saved); + toast.push(replace ? 'List replaced from paste.' : 'Cards imported.', 'success'); + } catch (e) { + toast.push((e as Error).message, 'error'); + } + }; + + const saveBindings = async () => { + if (!bindings) return; + setBusy(true); + try { + const saved = await api('/charades/bindings', { + method: 'PUT', + body: { + Charades: bindings.charades, + CharadesAprilFoolsDay: bindings.charadesAprilFoolsDay, + Icebreakers: bindings.icebreakers, + }, + }); + setBindings(saved); + toast.push('Live slots updated — effective on next card-box refresh.', 'success'); + } catch (e) { + toast.push((e as Error).message, 'error'); + } finally { + setBusy(false); + } + }; + + const headerActions = ( +
+ + +
+ ); + + return ( +
+ {embedded ? ( +
{headerActions}
+ ) : ( + + )} + + {err && ( +
{err}
+ )} + + {lists === null && !err && ( +
Loading…
+ )} + + {lists && bindings && ( +
+
+
+

Live card slots

+

+ The client only ever requests these three card sources. Point each at any list in + your library to switch decks — no redeploy. Changes apply the next time a card box + spawns (room rejoin). +

+
+ +
+
+ {SLOTS.map(slot => ( + + ))} +
+
+ )} + + {lists && lists.length === 0 && ( +
+ No lists yet — add one with the button above. +
+ )} + + {lists && ( +
+ {lists.map((l, i) => ( + updateLocal(i, next)} + onSave={() => saveList(i)} + onRemove={() => removeList(i)} + onImport={(text, diff, replace) => importInto(i, text, diff, replace)} + /> + ))} +
+ )} +
+ ); +} + +function liveSlotsFor(id: number, bindings: SlotBindings | null): string[] { + if (!bindings) return []; + const out: string[] = []; + if (bindings.charades === id) out.push('Charades'); + if (bindings.charadesAprilFoolsDay === id) out.push('April Fools'); + if (bindings.icebreakers === id) out.push('Icebreakers'); + return out; +} + +function ListEditor({ + list, + liveSlots, + onChange, + onSave, + onRemove, + onImport, +}: { + list: WordList; + liveSlots: string[]; + onChange: (next: WordList) => void; + onSave: () => void; + onRemove: () => void; + onImport: (text: string, defaultDifficulty: number, replace: boolean) => void; +}) { + const [open, setOpen] = useState(false); + const [pasteText, setPasteText] = useState(''); + const [pasteDifficulty, setPasteDifficulty] = useState(0); + const [pasteReplace, setPasteReplace] = useState(false); + + const counts = useMemo(() => { + const by = new Map(); + for (const w of list.words) by.set(w.difficulty, (by.get(w.difficulty) ?? 0) + 1); + return by; + }, [list.words]); + + const setWord = (idx: number, patch: Partial) => { + const words = list.words.slice(); + words[idx] = { ...words[idx], ...patch }; + onChange({ ...list, words }); + }; + const removeWord = (idx: number) => { + onChange({ ...list, words: list.words.filter((_, i) => i !== idx) }); + }; + const addWord = () => { + onChange({ ...list, words: [...list.words, { text: '', difficulty: 0 }] }); + }; + + const runImport = () => { + if (!pasteText.trim()) return; + onImport(pasteText, pasteDifficulty, pasteReplace); + setPasteText(''); + }; + + return ( +
+
+ onChange({ ...list, name: e.target.value })} + className="input text-sm font-semibold flex-1 min-w-0" + maxLength={128} + /> + {list.isBuiltIn && built-in} + {liveSlots.map(s => live: {s})} + {list.words.length} cards + +
+ +
+ {DIFFICULTIES.map(d => counts.get(d.value) + ? {d.label}: {counts.get(d.value)} + : null)} +
+ + {open && ( +
+
+ {list.words.map((w, i) => ( +
+ setWord(i, { text: e.target.value })} + placeholder="Card phrase" + className="input text-xs flex-1" + maxLength={256} + /> + + +
+ ))} + +
+ +
+

Import from paste

+

+ One phrase per line. Add | easy,{' '} + | hard, | veryhard or{' '} + | icebreaker after a line to set its difficulty. +

+