Add Charades word lists management, profanity filter, and matchmaking routes (#41)

* 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.

* feat: add identityserver-signing.pfx to .gitignore

* feat: add new matchmake routes for player, event, club, and code
This commit is contained in:
Alexa
2026-07-02 10:11:18 +03:00
committed by GitHub
parent fbfabaf26e
commit 628f430645
20 changed files with 1567 additions and 25 deletions
@@ -1,16 +1,32 @@
using Microsoft.AspNetCore.Mvc;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Activities;
/// <summary>
/// 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
/// <c>api/activities/charades/v1/words/{source}</c>, where <c>{source}</c>
/// is one of three baked <c>CardBox.cardSource</c> slots — <c>Charades</c>,
/// <c>CharadesAprilFoolsDay</c>, or <c>Icebreakers</c> (verified in the
/// 2023.03.21 il2cpp dump). We resolve the admin-bound word list for the
/// requested slot via <see cref="CharadesWordListService"/> and return the
/// wire array of <c>{ EN_US, Difficulty }</c>. The older December route
/// (no <c>{source}</c>) maps to the default Charades slot.
/// </summary>
[ApiController]
public class ActivitiesController : ControllerBase
public class ActivitiesController(CharadesWordListService charades) : ControllerBase
{
[HttpGet("api/activities/charades/v1/words/{source}")]
public async Task<IActionResult> CharadesWords(string source)
=> Ok(await charades.ResolveWireWordsAsync(source));
/// <summary>December 2020 shape — no source segment. Serves the default
/// Charades slot so the older client still gets a populated deck.</summary>
[HttpGet("api/activities/charades/v1/words")]
public IActionResult CharadesWords() => Ok(Array.Empty<object>());
public async Task<IActionResult> CharadesWordsDefault()
=> Ok(await charades.ResolveWireWordsAsync(
nameof(CharadesWordListService.CharadesSlot.Charades)));
}
@@ -10,10 +10,29 @@ namespace DorkNet.Server.Controllers.API.Sanitize;
/// invention name / chat message to be submitted. Delegates to
/// <see cref="ProfanityFilter"/> for the actual scrubbing. Response
/// shape: <c>{Text (purified), IsClean (bool)}</c>.
///
/// When the admin has flipped
/// <see cref="Data.Entities.ServerSettingsEntity.ProfanityFilterDisabled"/>,
/// every route short-circuits to "clean" — input is returned unmodified
/// and purity checks always pass — so nothing gets censored.
/// </summary>
[ApiController]
public class SanitizeController(ILogger<SanitizeController> logger) : ControllerBase
public class SanitizeController(
ILogger<SanitizeController> logger,
ServerSettingsService settings) : ControllerBase
{
/// <summary>Purify unless the filter is admin-disabled, in which case
/// the input is returned untouched.</summary>
private async Task<string> PurifyAsync(string input)
=> await settings.IsProfanityFilterDisabledAsync()
? input
: ProfanityFilter.Purify(input);
/// <summary>Clean-check unless the filter is admin-disabled, in which
/// case everything is treated as clean.</summary>
private async Task<bool> IsCleanAsync(string input)
=> await settings.IsProfanityFilterDisabledAsync() || ProfanityFilter.IsClean(input);
[HttpPost("api/sanitize/v1")]
[HttpPost("api/sanitize/v1/text")]
public async Task<IActionResult> Sanitize()
@@ -73,7 +92,7 @@ public class SanitizeController(ILogger<SanitizeController> 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<SanitizeController> logger) : Controller
/// <c>{Text}</c> with profanity replaced. Wire shape matches
/// <c>StringSanitization.PurifyString</c>.</summary>
[HttpPost("api/sanitize/v1/purifyString")]
public IActionResult PurifyString(
public async Task<IActionResult> PurifyString(
[FromBody] SanitizeBody? body,
[FromForm(Name = "Text")] string? textForm,
[FromForm(Name = "Input")] string? inputForm,
@@ -104,28 +123,28 @@ public class SanitizeController(ILogger<SanitizeController> 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) });
}
/// <summary>POST <c>api/sanitize/v1/requestIsStringPure</c> —
/// returns a bare JSON bool. Watch's <c>ExpectPrimitiveResponse</c>
/// rejects object bodies here.</summary>
[HttpPost("api/sanitize/v1/requestIsStringPure")]
public IActionResult IsStringPure(
public async Task<IActionResult> 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));
}
/// <summary>POST <c>api/sanitize/v1/isPure</c> — 2023 route used
/// by CV2 maker pen text such as port names. It imports an object
/// field named <c>IsPure</c>.</summary>
[HttpPost("api/sanitize/v1/isPure")]
public IActionResult IsPureV1(
public async Task<IActionResult> IsPureV1(
[FromBody] SanitizeBody? body,
[FromForm(Name = "Text")] string? textForm,
[FromForm(Name = "Input")] string? inputForm,
@@ -134,7 +153,7 @@ public class SanitizeController(ILogger<SanitizeController> 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<SanitizeController> logger) : Controller
/// used by room/settings forms. Its importer reads an <c>IsPure</c>
/// object field, unlike the newer bare-bool route above.</summary>
[HttpPost("api/sanitize/isPure")]
public IActionResult IsPure(
public async Task<IActionResult> 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) });
}
}
@@ -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);
/// <summary>POST <c>api/admin/v1/settings/profanity</c> — flip the
/// server-side profanity filter. While disabled, every
/// <c>api/sanitize/*</c> 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).</summary>
[HttpPost("settings/profanity")]
public async Task<ActionResult> 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<CharadesWordDto>? Words);
public sealed record CharadesImportDto(string? Text, int DefaultDifficulty, bool Replace);
public sealed record CharadesBindingsDto(long Charades, long CharadesAprilFoolsDay, long Icebreakers);
private static List<CharadesWordListService.CharadesWord>? MapWords(List<CharadesWordDto>? words)
=> words?.Select(w => new CharadesWordListService.CharadesWord(w.Text, w.Difficulty)).ToList();
/// <summary>GET <c>api/admin/v1/charades/wordlists</c> — 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.</summary>
[HttpGet("charades/wordlists")]
public async Task<ActionResult> ListCharadesWordLists()
{
var lists = await charades.GetAllAsync();
var bindings = await charades.GetBindingsAsync();
return Ok(new { lists, bindings });
}
/// <summary>POST <c>api/admin/v1/charades/wordlists</c> — create a new
/// word list (optionally pre-populated).</summary>
[HttpPost("charades/wordlists")]
public async Task<ActionResult> 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);
}
/// <summary>PUT <c>api/admin/v1/charades/wordlists/{id}</c> — rename
/// and/or replace the words of a list. Null fields are left as-is.</summary>
[HttpPut("charades/wordlists/{id:long}")]
public async Task<ActionResult> 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);
}
/// <summary>DELETE <c>api/admin/v1/charades/wordlists/{id}</c> — remove a
/// list. Any slot pointing at it is unbound (falls back to its built-in).</summary>
[HttpDelete("charades/wordlists/{id:long}")]
public async Task<ActionResult> 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 });
}
/// <summary>POST <c>api/admin/v1/charades/wordlists/{id}/import</c> —
/// bulk-add cards from pasted text (one phrase per line, optional
/// <c>| difficulty</c> suffix). <c>Replace=true</c> overwrites the
/// list; otherwise the cards are appended.</summary>
[HttpPost("charades/wordlists/{id:long}/import")]
public async Task<ActionResult> 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);
}
/// <summary>PUT <c>api/admin/v1/charades/bindings</c> — 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.</summary>
[HttpPut("charades/bindings")]
public async Task<ActionResult> 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);
}
/// <summary>POST <c>api/admin/v1/settings/global-friends</c> — flip the
/// "everyone is friends" toggle. No relationship rows are written; the
@@ -410,14 +410,32 @@ public class GoToController(
};
[HttpPost("/goto/event/{eventId:long}")]
// 2023 rename goto/event → matchmake/event (RecNet.Matchmaking HHDMGIMLBKF).
[HttpPost("/matchmake/event/{eventId:long}")]
public async Task<ActionResult<MatchmakingResponseDto>> GoToEvent(long eventId)
{
var resp = await BuildResponseAsync("DormRoom");
// Matchmake the caller into the event's host room. Falls back to
// the dorm if the event is unknown or has no room set.
var roomId = await db.PlayerEvents.AsNoTracking()
.Where(e => e.Id == eventId)
.Select(e => (long?)e.RoomId)
.FirstOrDefaultAsync();
string? roomName = null;
if (roomId is long rid && rid > 0)
roomName = await db.Rooms.AsNoTracking()
.Where(r => r.Id == rid)
.Select(r => r.Name)
.FirstOrDefaultAsync();
var resp = await BuildResponseAsync(
string.IsNullOrWhiteSpace(roomName) ? "DormRoom" : roomName);
await RecordResponseAsync(resp);
return Ok(resp);
}
[HttpPost("/goto/club/{clubId:long}")]
// 2023 rename goto/club → matchmake/club (RecNet.Matchmaking KPAIGCCHIBN).
[HttpPost("/matchmake/club/{clubId:long}")]
public async Task<ActionResult<MatchmakingResponseDto>> GoToClub(long clubId)
{
var club = await db.Clubs.AsNoTracking().FirstOrDefaultAsync(c => c.Id == clubId);
@@ -430,7 +448,20 @@ public class GoToController(
});
}
var resp = await BuildResponseAsync("RecCenter");
// Land in the club's clubhouse room when it has one set; otherwise
// fall back to the Rec Center. Either way the response carries the
// ClubId so the watch's club UI knows which club it matchmade for.
string roomName = "RecCenter";
if (club.ClubhouseRoomId is long clubhouseId && clubhouseId > 0)
{
var name = await db.Rooms.AsNoTracking()
.Where(r => r.Id == clubhouseId)
.Select(r => r.Name)
.FirstOrDefaultAsync();
if (!string.IsNullOrWhiteSpace(name)) roomName = name;
}
var resp = await BuildResponseAsync(roomName);
resp.RoomInstance!.ClubId = club.Id;
await RecordResponseAsync(resp);
return Ok(resp);
@@ -438,7 +469,12 @@ public class GoToController(
[HttpPost("/goto/code")]
[HttpPost("/goto/code/{code}")]
public async Task<ActionResult<MatchmakingResponseDto>> GoToCode(string? code = null)
// 2023 sends the room code as the second segment behind the room id
// (RecNet.Matchmaking CPINEGCODMD → "matchmake/code/{roomId}/{code}").
// The code alone resolves the private instance, so the roomId is just
// routing context we don't need — resolve by code either way.
[HttpPost("/matchmake/code/{roomId:long}/{code}")]
public async Task<ActionResult<MatchmakingResponseDto>> GoToCode(string? code = null, long roomId = 0)
{
code ??= Request.HasFormContentType
? (await Request.ReadFormAsync())["code"].FirstOrDefault()
@@ -505,6 +541,11 @@ public class GoToController(
/// dorm if the instance was forgotten (server restart, owner
/// already left and nobody re-created it).</summary>
[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<ActionResult<MatchmakingResponseDto>> GoToInstance(long roomInstanceId)
{
var resp = await BuildPrivateInstanceJoinResponseAsync(roomInstanceId);
@@ -512,7 +553,51 @@ public class GoToController(
return Ok(resp);
}
/// <summary>POST <c>matchmake/chatinvite/{accountId}/{roomInstanceId}</c>
/// — 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
/// <c>POST /invite</c> 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.</summary>
[HttpPost("/matchmake/chatinvite/{p0:long}/{p1:long}")]
public async Task<ActionResult<MatchmakingResponseDto>> 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);
}
/// <summary>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.</summary>
private async Task<long> 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<ActionResult<MatchmakingResponseDto>> GoToInvite(long inviteId)
{
var callerId = this.CurrentPlayerId();
@@ -758,6 +843,9 @@ public class GoToController(
/// or on the invite list per <see cref="PrivateInstanceService.CanJoin"/>;
/// otherwise we refuse with errorCode=9 (RoomNotJoinable).</summary>
[HttpPost("/goto/player/{playerId:long}")]
// 2023 rename goto/player → matchmake/player (RecNet.Matchmaking
// FJAFOJHHKPO). Follow a friend into their current room/instance.
[HttpPost("/matchmake/player/{playerId:long}")]
public async Task<ActionResult<MatchmakingResponseDto>> GoToPlayer(long playerId)
{
var theirRoom = presence.GetRoom(playerId);
+1
View File
@@ -60,6 +60,7 @@ public class DorkNetDbContext(DbContextOptions<DorkNetDbContext> options) : DbCo
public DbSet<GameSessionEntity> GameSessions => Set<GameSessionEntity>();
public DbSet<CommunityBoardEntity> CommunityBoardRows => Set<CommunityBoardEntity>();
public DbSet<LoadingScreenTipEntity> LoadingScreenTips => Set<LoadingScreenTipEntity>();
public DbSet<CharadesWordListEntity> CharadesWordLists => Set<CharadesWordListEntity>();
public DbSet<ServerSettingsEntity> ServerSettings => Set<ServerSettingsEntity>();
public DbSet<RoomKeyEntity> RoomKeys => Set<RoomKeyEntity>();
public DbSet<RoomKeyPurchaseEntity> RoomKeyPurchases => Set<RoomKeyPurchaseEntity>();
@@ -0,0 +1,46 @@
namespace DorkNet.Server.Data.Entities;
/// <summary>
/// One admin-authored 3D Charades word list. The March 2023 client
/// pulls a deck at card-box spawn time from
/// <c>api/activities/charades/v1/words/{source}</c>, where
/// <c>{source}</c> is one of three baked <c>CardBox.cardSource</c>
/// slots — <c>Charades</c>, <c>CharadesAprilFoolsDay</c>, or
/// <c>Icebreakers</c> (verified in the 2023.03.21 il2cpp dump:
/// <c>GEFMIBEPMKJ.EODJJDILECA</c>). 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
/// <see cref="ServerSettingsEntity.CharadesSlotBindingsJson"/>. This is
/// the same admin-editable-collection shape as
/// <see cref="LoadingScreenTipEntity"/>: a plain entity CRUD'd from the
/// SPA, created on existing DBs via the idempotent bootstrap (it
/// post-dates the consolidated Initial migration).
/// </summary>
public class CharadesWordListEntity
{
public long Id { get; set; }
/// <summary>Admin-facing display name, e.g. "Default", "Movie night",
/// "April Fools". Not sent to the client.</summary>
public string Name { get; set; } = string.Empty;
/// <summary>Serialized <c>List&lt;CharadesWord&gt;</c> — each entry is
/// <c>{ Text, Difficulty }</c>. <c>Text</c> becomes the wire
/// <c>EN_US</c> field; <c>Difficulty</c> is the client
/// <c>CNMMMNJJDMM</c> 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.</summary>
public string WordsJson { get; set; } = "[]";
/// <summary>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.</summary>
public bool IsBuiltIn { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
@@ -64,5 +64,23 @@ public class ServerSettingsEntity
/// migration each time.</summary>
public string DiscoveredGameConfigsJson { get; set; } = string.Empty;
/// <summary>When true, the server-side profanity filter behind
/// <c>api/sanitize/*</c> 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 <see cref="Services.ProfanityFilter"/>
/// and <c>SanitizeController</c>.</summary>
public bool ProfanityFilterDisabled { get; set; }
/// <summary>Which charades word list (row id in
/// <see cref="CharadesWordListEntity"/>) is live for each of the
/// client's three baked card-source slots. JSON object keyed by the
/// client enum name — <c>{"Charades":id,"CharadesAprilFoolsDay":id,
/// "Icebreakers":id}</c>. 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.</summary>
public string CharadesSlotBindingsJson { get; set; } = string.Empty;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
@@ -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;
/// <summary>
/// Admin-managed library of 3D Charades word lists plus the live
/// slot→list bindings the March 2023 client reads.
///
/// <para>Wire contract (verified in the 2023.03.21 il2cpp dump —
/// <c>CardBox</c> / <c>GEFMIBEPMKJ</c>): the client GETs
/// <c>api/activities/charades/v1/words/{source}</c> at card-box spawn,
/// where <c>{source}</c> is one of three baked <c>cardSource</c> enum
/// slots (<see cref="CharadesSlot"/>). The response is a JSON array of
/// <c>{ "EN_US": string, "Difficulty": int }</c> where Difficulty is the
/// client <c>CNMMMNJJDMM</c> enum (<see cref="DifficultyEasy"/> etc.).</para>
///
/// <para>Storage: each list is a <see cref="CharadesWordListEntity"/> row
/// (unlimited library); the live slot bindings live in
/// <see cref="ServerSettingsEntity.CharadesSlotBindingsJson"/> via
/// <see cref="ServerSettingsService"/>. Switching a slot just repoints its
/// binding — no redeploy, effective on the next card-box refresh.</para>
/// </summary>
public class CharadesWordListService(DorkNetDbContext db, ServerSettingsService settings)
{
/// <summary>The three baked <c>CardBox.cardSource</c> values the client
/// can request. Integer values match the client
/// <c>GEFMIBEPMKJ.EODJJDILECA</c> enum.</summary>
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,
};
/// <summary>One card as stored/edited in the admin UI.</summary>
public sealed record CharadesWord(string Text, int Difficulty);
/// <summary>A word list as returned to the admin SPA.</summary>
public sealed record CharadesWordListDto(
long Id,
string Name,
IReadOnlyList<CharadesWord> Words,
bool IsBuiltIn,
DateTime UpdatedAt);
/// <summary>Wire entry. Property names are pinned to the exact casing
/// the client's LitJson importer reads (<c>EN_US</c>, <c>Difficulty</c>);
/// LitJson key lookup is case-sensitive.</summary>
public sealed record CharadesWordWire(
[property: JsonPropertyName("EN_US")] string EN_US,
[property: JsonPropertyName("Difficulty")] int Difficulty);
// ---- library CRUD ----------------------------------------------------
public async Task<IReadOnlyList<CharadesWordListDto>> GetAllAsync()
{
var rows = await db.CharadesWordLists.AsNoTracking()
.OrderByDescending(l => l.IsBuiltIn)
.ThenBy(l => l.Id)
.ToListAsync();
return rows.Select(ToDto).ToList();
}
public async Task<CharadesWordListDto?> GetAsync(long id)
{
var row = await db.CharadesWordLists.AsNoTracking()
.FirstOrDefaultAsync(l => l.Id == id);
return row is null ? null : ToDto(row);
}
public async Task<CharadesWordListDto> CreateAsync(
string name, IReadOnlyList<CharadesWord>? 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);
}
/// <summary>Rename and/or replace the words of a list. Null args are
/// left untouched.</summary>
public async Task<CharadesWordListDto?> UpdateAsync(
long id, string? name, IReadOnlyList<CharadesWord>? 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<bool> 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;
}
/// <summary>Append (or replace) cards parsed from pasted text.
/// One phrase per line; an optional <c>| difficulty</c> suffix per line
/// (easy/hard/veryhard/icebreaker or a raw int) overrides
/// <paramref name="defaultDifficulty"/>.</summary>
public async Task<CharadesWordListDto?> 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<CharadesSlotBindings> GetBindingsAsync() => settings.GetCharadesSlotBindingsAsync();
public async Task<CharadesSlotBindings> 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 ----------------------------------------------
/// <summary>Resolve the wire deck for a client-requested source. The
/// route param may be the enum name (<c>Charades</c>) or its int
/// (<c>0</c>); 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.</summary>
public async Task<IReadOnlyList<CharadesWordWire>> 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 ---------------------------------------------------------
/// <summary>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.</summary>
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<CharadesWord> ParsePaste(string text, int defaultDifficulty)
{
if (string.IsNullOrWhiteSpace(text)) return [];
var result = new List<CharadesWord>();
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<CharadesSlot>(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;
}
/// <summary>Trim, drop blanks, clamp difficulty to a known value, and
/// de-duplicate case-insensitively (keeping first occurrence).</summary>
private static IReadOnlyList<CharadesWord> NormalizeWords(IReadOnlyList<CharadesWord> words)
{
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var result = new List<CharadesWord>();
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<CharadesWord> words) =>
JsonSerializer.Serialize(words, JsonOptions);
private static IReadOnlyList<CharadesWord> DeserializeWords(string json)
{
if (string.IsNullOrWhiteSpace(json)) return [];
try
{
return JsonSerializer.Deserialize<List<CharadesWord>>(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);
/// <summary>A broad, family-friendly default deck spread across the
/// three charades difficulties (easy / hard / very hard).</summary>
private static readonly IReadOnlyList<CharadesWord> 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"),
];
/// <summary>April Fools deck — concepts that are (nearly) impossible to
/// act out. All flagged very hard.</summary>
private static readonly IReadOnlyList<CharadesWord> 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"),
];
/// <summary>Icebreaker prompts for the Icebreakers card source. All
/// flagged with the icebreaker difficulty so they group correctly.</summary>
private static readonly IReadOnlyList<CharadesWord> 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"),
];
}
/// <summary>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.</summary>
public sealed record CharadesSlotBindings(
long Charades,
long CharadesAprilFoolsDay,
long Icebreakers)
{
public static CharadesSlotBindings Empty => new(0, 0, 0);
}
@@ -151,6 +151,52 @@ public class ServerSettingsService(DorkNetDbContext db)
return ToWeeklySettings(existing);
}
/// <summary>Whether the server-side profanity filter behind
/// <c>api/sanitize/*</c> is bypassed. Off by default (filter active).</summary>
public async Task<bool> IsProfanityFilterDisabledAsync()
{
var row = await db.ServerSettings.AsNoTracking()
.FirstOrDefaultAsync(s => s.Id == RowId);
return row?.ProfanityFilterDisabled ?? false;
}
public async Task<ServerSettingsEntity> SetProfanityFilterDisabledAsync(bool disabled)
{
var existing = await GetTrackedRowAsync();
existing.ProfanityFilterDisabled = disabled;
existing.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return existing;
}
/// <summary>Live charades slot→list bindings. Empty/unparseable JSON
/// resolves to <see cref="CharadesSlotBindings.Empty"/> (every slot
/// falls back to its built-in seed list).</summary>
public async Task<CharadesSlotBindings> GetCharadesSlotBindingsAsync()
{
var row = await GetAsync();
if (string.IsNullOrWhiteSpace(row.CharadesSlotBindingsJson))
return CharadesSlotBindings.Empty;
try
{
return JsonSerializer.Deserialize<CharadesSlotBindings>(
row.CharadesSlotBindingsJson, JsonOptions) ?? CharadesSlotBindings.Empty;
}
catch
{
return CharadesSlotBindings.Empty;
}
}
public async Task<CharadesSlotBindings> 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<ServerSettingsEntity> GetTrackedRowAsync()
{
var existing = await db.ServerSettings.FirstOrDefaultAsync(s => s.Id == RowId);
@@ -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<StoreService>();
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<CharadesWordListService>();
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"");");
}
/// <summary>Idempotently create the CharadesWordLists table on both
/// providers. Post-dates the consolidated Initial migration (same
/// rationale as <see cref="EnsureSignupCodeTablesAsync"/>): 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).</summary>
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;
@@ -217,6 +217,7 @@ public static class ServiceCollectionExtensions
services.AddScoped<PrivateInstanceService>();
services.AddScoped<CommunityBoardService>();
services.AddScoped<ServerSettingsService>();
services.AddScoped<CharadesWordListService>();
// Singletons — these own connectionless state (Redis-backed or
// process-local) and don't need a per-request scope.
services.AddSingleton<NotificationService>();
@@ -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<WordList[] | null>(null);
const [bindings, setBindings] = useState<SlotBindings | null>(null);
const [err, setErr] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const toast = useToast();
const load = async () => {
try {
const data = await get<WordListsResponse>('/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<WordList>('/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<WordList>(`/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<WordList>(`/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<SlotBindings>('/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 = (
<div className="flex gap-2">
<button onClick={load} className="btn-secondary text-xs" disabled={busy}>
<RefreshCw className={busy ? 'animate-spin' : ''} /> Refresh
</button>
<button onClick={addNew} className="btn-primary text-xs" disabled={busy}>
<Plus /> New list
</button>
</div>
);
return (
<div>
{embedded ? (
<div className="flex justify-end mb-3">{headerActions}</div>
) : (
<PageHeader
title="3D Charades words"
blurb="Keep an unlimited library of word lists and pick which one is live for each in-game card box. The March 2023 client fetches the bound list at card-box spawn."
actions={headerActions}
/>
)}
{err && (
<div className="card border-danger/30 bg-danger/5 px-4 py-3 text-sm text-danger mb-4">{err}</div>
)}
{lists === null && !err && (
<div className="card !p-6 text-center text-xs text-ink-400">Loading…</div>
)}
{lists && bindings && (
<div className="card !p-5 mb-5">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<h2 className="text-sm font-semibold text-ink-50">Live card slots</h2>
<p className="mt-1 text-xs text-ink-400">
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).
</p>
</div>
<button onClick={saveBindings} disabled={busy} className="btn-primary text-xs shrink-0">
{busy ? 'Saving…' : 'Save slots'}
</button>
</div>
<div className="mt-4 grid gap-3 md:grid-cols-3">
{SLOTS.map(slot => (
<label key={slot.key} className="block rounded-lg border border-ink-800 bg-ink-950/30 p-3">
<span className="text-sm font-semibold text-ink-100">{slot.title}</span>
<span className="mt-0.5 block text-[11px] text-ink-500">{slot.blurb}</span>
<select
className="input mt-2 text-xs"
value={bindings[slot.key]}
onChange={e => setBindings({ ...bindings, [slot.key]: parseInt(e.target.value) || 0 })}
>
<option value={0}>— built-in fallback —</option>
{lists.map(l => (
<option key={l.id} value={l.id}>
{l.name} ({l.words.length})
</option>
))}
</select>
</label>
))}
</div>
</div>
)}
{lists && lists.length === 0 && (
<div className="card !p-6 text-center text-xs text-ink-400">
No lists yet — add one with the button above.
</div>
)}
{lists && (
<div className="space-y-3">
{lists.map((l, i) => (
<ListEditor
key={l.id}
list={l}
liveSlots={liveSlotsFor(l.id, bindings)}
onChange={next => updateLocal(i, next)}
onSave={() => saveList(i)}
onRemove={() => removeList(i)}
onImport={(text, diff, replace) => importInto(i, text, diff, replace)}
/>
))}
</div>
)}
</div>
);
}
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<number, number>();
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<CharadesWord>) => {
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 (
<div className="card !p-4">
<div className="flex items-center gap-3">
<input
value={list.name}
onChange={e => onChange({ ...list, name: e.target.value })}
className="input text-sm font-semibold flex-1 min-w-0"
maxLength={128}
/>
{list.isBuiltIn && <span className="badge-admin shrink-0">built-in</span>}
{liveSlots.map(s => <span key={s} className="badge-online shrink-0">live: {s}</span>)}
<span className="text-[11px] text-ink-500 shrink-0">{list.words.length} cards</span>
<button onClick={() => setOpen(o => !o)} className="btn-secondary text-xs shrink-0">
{open ? 'Collapse' : 'Edit'}
</button>
</div>
<div className="mt-2 flex flex-wrap gap-1.5 text-[11px] text-ink-400">
{DIFFICULTIES.map(d => counts.get(d.value)
? <span key={d.value} className="badge-neutral">{d.label}: {counts.get(d.value)}</span>
: null)}
</div>
{open && (
<div className="mt-4 space-y-4">
<div className="space-y-2">
{list.words.map((w, i) => (
<div key={i} className="flex items-center gap-2">
<input
value={w.text}
onChange={e => setWord(i, { text: e.target.value })}
placeholder="Card phrase"
className="input text-xs flex-1"
maxLength={256}
/>
<select
value={w.difficulty}
onChange={e => setWord(i, { difficulty: parseInt(e.target.value) })}
className="input text-xs w-32 shrink-0"
>
{DIFFICULTIES.map(d => <option key={d.value} value={d.value}>{d.label}</option>)}
</select>
<button onClick={() => removeWord(i)} className="btn-ghost text-xs text-danger shrink-0" title="Remove card">
<Trash />
</button>
</div>
))}
<button onClick={addWord} className="btn-secondary text-xs"><Plus /> Add card</button>
</div>
<div className="rounded-lg border border-ink-800 bg-ink-950/30 p-3">
<h4 className="text-xs font-semibold text-ink-200">Import from paste</h4>
<p className="mt-0.5 text-[11px] text-ink-500">
One phrase per line. Add <span className="font-mono">| easy</span>,{' '}
<span className="font-mono">| hard</span>, <span className="font-mono">| veryhard</span> or{' '}
<span className="font-mono">| icebreaker</span> after a line to set its difficulty.
</p>
<textarea
value={pasteText}
onChange={e => setPasteText(e.target.value)}
placeholder={'Dancing\nRiding a bike | hard\nThe concept of time | veryhard'}
className="input text-xs w-full mt-2 font-mono"
rows={5}
/>
<div className="mt-2 flex flex-wrap items-center gap-3">
<label className="flex items-center gap-1.5 text-xs text-ink-300">
Default difficulty
<select
value={pasteDifficulty}
onChange={e => setPasteDifficulty(parseInt(e.target.value))}
className="input text-xs w-28"
>
{DIFFICULTIES.map(d => <option key={d.value} value={d.value}>{d.label}</option>)}
</select>
</label>
<label className="flex items-center gap-1.5 text-xs text-ink-300 cursor-pointer">
<input type="checkbox" checked={pasteReplace} onChange={e => setPasteReplace(e.target.checked)} />
Replace existing cards
</label>
<div className="flex-1" />
<button onClick={runImport} disabled={!pasteText.trim()} className="btn-primary text-xs">
{pasteReplace ? 'Replace from paste' : 'Import cards'}
</button>
</div>
</div>
</div>
)}
<div className="mt-4 flex items-center gap-3">
<span className="text-[11px] text-ink-600 flex-1">
Last saved {new Date(list.updatedAt).toLocaleString()}
</span>
<button onClick={onSave} className="btn-primary text-xs">Save</button>
<button onClick={onRemove} className="btn-ghost text-xs text-danger" title="Delete list">
<Trash />
</button>
</div>
</div>
);
}
+10 -5
View File
@@ -1,26 +1,31 @@
import { CommunityBoard } from './CommunityBoard';
import { LoadingTips } from './LoadingTips';
import { Charades } from './Charades';
import { PageHeader } from '../components/PageHeader';
import { Tabs, useTabParam } from '../components/Tabs';
// The two player-facing content surfaces the watch pulls on dorm load —
// the community board and the loading-screen tips — share one page.
// Old /community and /loading-tips routes redirect here.
// The player-facing content surfaces the watch pulls at runtime — the
// community board and loading-screen tips shown on dorm load, plus the 3D
// Charades word lists fetched at card-box spawn — share one page. Old
// /community and /loading-tips routes redirect here.
export function Content() {
const [tab, setTab] = useTabParam<'community' | 'tips'>('community', ['community', 'tips']);
const [tab, setTab] = useTabParam<'community' | 'tips' | 'charades'>(
'community', ['community', 'tips', 'charades']);
return (
<div>
<PageHeader title="Content" blurb="Player-facing content shown on dorm load: the community board and loading-screen tips." />
<PageHeader title="Content" blurb="Player-facing content: the community board, loading-screen tips, and 3D Charades word lists." />
<Tabs
tabs={[
{ key: 'community', label: 'Community board' },
{ key: 'tips', label: 'Loading tips' },
{ key: 'charades', label: 'Charades words' },
]}
active={tab}
onChange={setTab}
/>
{tab === 'community' && <CommunityBoard embedded />}
{tab === 'tips' && <LoadingTips embedded />}
{tab === 'charades' && <Charades embedded />}
</div>
);
}
@@ -8,6 +8,7 @@ interface ServerSettings {
signupsDisabled: boolean;
globalFriendsEnabled: boolean;
weeklyChallengesCompletedRequired: boolean;
profanityFilterDisabled: boolean;
updatedAt: string;
}
@@ -215,6 +216,30 @@ export function Settings({ embedded }: { embedded?: boolean } = {}) {
}
};
const toggleProfanity = async () => {
if (!settings) return;
const next = !settings.profanityFilterDisabled;
const verb = next ? 'turn OFF the profanity filter' : 'turn the profanity filter back on';
if (!confirm(
next
? 'Turn off the profanity filter? Room names, invention names and chat will no longer be censored by the server. Toggle back on any time.'
: `Really ${verb}? Text will be filtered again server-side.`,
)) return;
setBusy(true);
try {
const updated = await api<ServerSettings>('/settings/profanity', {
method: 'POST',
body: { Disabled: next },
});
setSettings(updated);
toast.push(next ? 'Profanity filter disabled.' : 'Profanity filter enabled.', 'success');
} catch (e) {
toast.push((e as Error).message, 'error');
} finally {
setBusy(false);
}
};
const updateWeeklyChallenge = (index: number, patch: Partial<WeeklyChallenge>) => {
setWeekly((current) => {
if (!current) return current;
@@ -485,6 +510,37 @@ export function Settings({ embedded }: { embedded?: boolean } = {}) {
</div>
)}
{settings && (
<div className="card !p-5 max-w-2xl mt-5">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<h2 className="text-sm font-semibold text-ink-50">Profanity filter</h2>
<p className="mt-1 text-xs text-ink-400">
The server-side filter behind <span className="font-mono">api/sanitize/*</span> that
the watch calls before letting a player submit a room name, invention name, or chat
message. Turn it off to stop censoring text entirely — every check returns "clean"
and input is passed through unchanged. Takes effect immediately.
</p>
<p className="mt-2 text-[11px] text-ink-500">
Last changed {new Date(settings.updatedAt).toLocaleString()}.
</p>
</div>
<button
onClick={toggleProfanity}
disabled={busy}
className={(settings.profanityFilterDisabled ? 'btn-primary' : 'btn-danger') + ' text-xs shrink-0'}
>
{busy ? 'Working…' : settings.profanityFilterDisabled ? 'Enable filter' : 'Disable filter'}
</button>
</div>
<div className="mt-4 text-xs">
{settings.profanityFilterDisabled
? <span className="badge-banned">Filter off — nothing censored</span>
: <span className="badge-online">Filter active</span>}
</div>
</div>
)}
{gameConfigs && (
<DiscoveredGameConfigsPanel
settings={gameConfigs}