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
@@ -0,0 +1,88 @@
using System.Net;
using System.Text;
using System.Text.Json;
namespace DorkNet.Server.Tests;
/// <summary>
/// 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 <see cref="DorkNetServerFactory"/> and exercises the
/// public wire endpoints the March 2023 client actually calls.
/// </summary>
public sealed class CharadesWordListTests : IClassFixture<DorkNetServerFactory>
{
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());
}
}
@@ -17,6 +17,7 @@
<ItemGroup>
<ProjectReference Include="..\DorkNet.Server\DorkNet.Server.csproj" />
<ProjectReference Include="..\DorkNet.Contracts\DorkNet.Contracts.csproj" />
</ItemGroup>
<ItemGroup>
@@ -0,0 +1,65 @@
using System.Net;
using DorkNet.Contracts;
namespace DorkNet.Server.Tests;
/// <summary>
/// Regression coverage for the 2023 "pull a player into a private instance"
/// flow. The 2023 client renamed the matchmaking surface from <c>goto/*</c>
/// to <c>matchmake/*</c> and added <c>matchmake/chatinvite/{account}/{instance}</c>
/// for chat party invites; the server had neither the routes nor the gateway
/// ownership entry, so those requests 404'd.
/// </summary>
public sealed class MatchmakePullInviteTests : IClassFixture<DorkNetServerFactory>
{
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}
[InlineData("/matchmake/player/2")]
[InlineData("/matchmake/event/999")]
[InlineData("/matchmake/club/999")]
[InlineData("/matchmake/code/5/ABCDEF")] // {roomId}/{code}
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")]
[InlineData("/matchmake/player/2")]
[InlineData("/matchmake/event/999")]
[InlineData("/matchmake/club/999")]
[InlineData("/matchmake/code/5/ABCDEF")]
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);
}
}