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