mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-26 15:38:45 -07:00
6ee1d6f9b3
Every change below was checked against the 2023-03-21 client disassembly;
file:line evidence is in the code comments and in
docs/recroom-2023-client-api-complete.md.
Presence
- matchmake/none means "online, in no room" (its only caller is
TryGoToOfflineRoom, which ignores the reply). The server used to keep the
old room or invent a dorm, so the next heartbeat looked out of sync and
the client cancelled its load to chase that room.
- The join watchdog no longer clears presence 30 s into a long room load;
it is a 3 minute backstop that drops the room only.
- One fan-out (PresenceBroadcastService) for every transition: deferred
joins, matchmake/none, logout, failed joins, visibility changes, SignalR
connect/disconnect. isOnline is sent on its own; pushes never go to the
subject, because a self push moves the player.
- Online-status visibility (Public / Friends / Favorites / Appear offline)
is stored and honoured by GET /player and by the live pushes.
Profile
- PUT account/me/{emoji,personalpronouns,identityflags,bannerimage} were
404; added with the client's real form keys and persisted (new Players
columns plus migration). Every account read now returns them, and edits
push AccountUpdate to everyone.
Outfit menu
- Saved outfits kept OutfitSelectionsV2 and CustomAvatarItems, so loading a
slot restores what was saved instead of a stale outfit.
- isCreationAllowedForAccount answers the {Success, Error} envelope the
client parses; minPriceForPublicItem is a bare int; gifts/generate accepts
form bodies; lockeditems returns the full owned-item shape; colourways no
longer share their base item's AvatarItemId.
RecNetResult writes
- About 40 writes look status-only but parse the body as {Success, Error}.
An empty 200 showed "Failed to ..." after the server applied the change,
and a body without Success threw "Error message cannot be null". Clubs,
announcements, keepsakes, comments/read, subscriptions, purchase offers
and privacy settings now answer RecNetResult.
- Login token and device-code grants are real; remoteauth approves the
device code; createlogintoken returns a bare string.
- New: incentivizedreferrals/referrals, account/bulk/{email,phone},
GET strings, account/me/confirmphone, PlayMenuCarouselOrdering_1.
Route inventory and coverage test extended to match.
README now describes the 2023 branch.
223 lines
11 KiB
C#
223 lines
11 KiB
C#
using System.Net;
|
|
using System.Net.Http.Headers;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using DorkNet.Server.Controllers.API.Avatar.V4;
|
|
using DorkNet.Server.Data;
|
|
using DorkNet.Server.Services;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
|
|
namespace DorkNet.Server.Tests;
|
|
|
|
/// <summary>
|
|
/// Wire contract of the 2023-03-21 client's outfit / wardrobe menu, as read
|
|
/// from RecNet.Runtime ISIL: saved-outfit slots (BEPMJICDNDD via converter
|
|
/// IMMOJHBENFI), the custom-avatar-item gates the menu queries when it opens,
|
|
/// the locked-item lookup, the owned list's per-entry ids, and the gift
|
|
/// generate call the menu's gift flow makes.
|
|
/// </summary>
|
|
public sealed class OutfitMenu2023Tests : IClassFixture<DorkNetServerFactory>
|
|
{
|
|
private readonly DorkNetServerFactory _factory;
|
|
|
|
public OutfitMenu2023Tests(DorkNetServerFactory factory) => _factory = factory;
|
|
|
|
[Fact]
|
|
public async Task Creation_allowed_gate_is_a_success_error_envelope()
|
|
{
|
|
// MPBLNLMCEDL.GGMDLMLJDDK parses DJMHAFPGLLN {Success, Error}
|
|
// (LMCCNLLHLCJ.txt:191-254); a bare boolean is what logged
|
|
// "Failed to query if local account can create Custom Avatar Items".
|
|
using var setup = ApiClient();
|
|
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
|
using var client = ApiClient(player);
|
|
|
|
var body = await GetJsonAsync(client, "/api/customAvatarItems/v1/isCreationAllowedForAccount");
|
|
Assert.Equal(JsonValueKind.Object, body.ValueKind);
|
|
Assert.Equal(JsonValueKind.True, body.GetProperty("Success").ValueKind);
|
|
Assert.Equal(JsonValueKind.Null, body.GetProperty("Error").ValueKind);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Sibling_gates_and_min_price_are_bare_scalars()
|
|
{
|
|
using var client = ApiClient();
|
|
Assert.Equal(JsonValueKind.True, (await GetJsonAsync(client, "/api/customAvatarItems/v1/isCreationEnabled")).ValueKind);
|
|
Assert.Equal(JsonValueKind.True, (await GetJsonAsync(client, "/api/customAvatarItems/v1/isRenderingEnabled")).ValueKind);
|
|
|
|
// <GetPricingConfig>b__69_0(Int32 minimumPrice) — a bare integer.
|
|
var min = await GetJsonAsync(client, "/api/customAvatarItems/v1/minPriceForPublicItem");
|
|
Assert.Equal(JsonValueKind.Number, min.ValueKind);
|
|
Assert.True(min.GetInt32() > 0);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Saved_outfit_round_trips_every_client_key()
|
|
{
|
|
using var setup = ApiClient();
|
|
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
|
using var client = ApiClient(player);
|
|
|
|
var shirt = Guid.NewGuid();
|
|
// Exactly what the client's writer (IMMOJHBENFI.txt:781-910) emits.
|
|
var outfit = new Dictionary<string, object?>
|
|
{
|
|
["Slot"] = 3,
|
|
["PreviewImageName"] = "preview-3.jpg",
|
|
["OutfitSelections"] = "",
|
|
["OutfitSelectionsV2"] = "{\"v\":2,\"items\":[\"a\",\"b\"]}",
|
|
["FaceFeatures"] = "{\"ver\":3}",
|
|
["SkinColor"] = "skin-guid",
|
|
["HairColor"] = "hair-guid",
|
|
["CustomAvatarItems"] = new[] { new { CustomAvatarItemId = shirt, BodyPart = 2 } },
|
|
};
|
|
await PostJsonAsync(client, "/api/avatar/v3/saved/set", outfit);
|
|
|
|
var list = await GetJsonAsync(client, "/api/avatar/v3/saved");
|
|
var saved = list.EnumerateArray().Single(o => o.GetProperty("Slot").GetInt32() == 3);
|
|
Assert.Equal("preview-3.jpg", saved.GetProperty("PreviewImageName").GetString());
|
|
Assert.Equal("{\"v\":2,\"items\":[\"a\",\"b\"]}", saved.GetProperty("OutfitSelectionsV2").GetString());
|
|
Assert.Equal("{\"ver\":3}", saved.GetProperty("FaceFeatures").GetString());
|
|
Assert.Equal("skin-guid", saved.GetProperty("SkinColor").GetString());
|
|
Assert.Equal("hair-guid", saved.GetProperty("HairColor").GetString());
|
|
var items = saved.GetProperty("CustomAvatarItems");
|
|
Assert.Equal(1, items.GetArrayLength());
|
|
Assert.Equal(shirt, items[0].GetProperty("CustomAvatarItemId").GetGuid());
|
|
Assert.Equal(2, items[0].GetProperty("BodyPart").GetInt32());
|
|
|
|
// Overwriting the slot replaces it rather than adding a second row.
|
|
outfit["OutfitSelectionsV2"] = "{\"v\":2,\"items\":[\"c\"]}";
|
|
outfit["CustomAvatarItems"] = Array.Empty<object>();
|
|
await PostJsonAsync(client, "/api/avatar/v3/saved/set", outfit);
|
|
list = await GetJsonAsync(client, "/api/avatar/v3/saved");
|
|
saved = list.EnumerateArray().Single(o => o.GetProperty("Slot").GetInt32() == 3);
|
|
Assert.Equal("{\"v\":2,\"items\":[\"c\"]}", saved.GetProperty("OutfitSelectionsV2").GetString());
|
|
Assert.Equal(0, saved.GetProperty("CustomAvatarItems").GetArrayLength());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Gift_generate_accepts_the_clients_form_body()
|
|
{
|
|
// Live trace: this exact form body was answered 415 under [FromBody].
|
|
using var setup = ApiClient();
|
|
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
|
using var client = ApiClient(player);
|
|
|
|
using var response = await client.PostAsync("/api/avatar/v2/gifts/generate",
|
|
new FormUrlEncodedContent(new Dictionary<string, string>
|
|
{
|
|
["GiftContext"] = "12000",
|
|
["IsGameGift"] = "True",
|
|
["Message"] = "Activity completed!",
|
|
}));
|
|
Assert.NotEqual(HttpStatusCode.UnsupportedMediaType, response.StatusCode);
|
|
response.EnsureSuccessStatusCode();
|
|
using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
|
Assert.True(doc.RootElement.GetProperty("Id").GetInt64() > 0);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Locked_items_carry_the_full_owned_item_shape()
|
|
{
|
|
using var setup = ApiClient();
|
|
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
|
using var client = ApiClient(player);
|
|
|
|
HashSet<string> owned;
|
|
await using (var scope = _factory.Services.CreateAsyncScope())
|
|
{
|
|
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
|
var inv = await db.Avatars.Where(a => a.PlayerId == player.PlayerId)
|
|
.Select(a => a.InventoryJson).FirstAsync();
|
|
owned = (JsonSerializer.Deserialize<List<string>>(inv) ?? new())
|
|
.Select(d => d.Replace("wardrobe-", "").Split(',')[0])
|
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
}
|
|
var notOwned = AvatarItemsController.Catalog.Keys
|
|
.Where(g => Guid.TryParse(g, out _) && !owned.Contains(g)).Take(3).ToList();
|
|
Assert.Equal(3, notOwned.Count);
|
|
|
|
var query = string.Join("&", notOwned.Select(g => "desc=" + Uri.EscapeDataString(g + ",,,")));
|
|
var rows = await GetJsonAsync(client, "/api/avatar/v1/lockeditems?" + query);
|
|
Assert.Equal(3, rows.GetArrayLength());
|
|
var ids = new List<int>();
|
|
foreach (var row in rows.EnumerateArray())
|
|
{
|
|
// Same IFNGBPJGINL keys v4/items carries (CLIANCNHNIE reader).
|
|
Assert.Equal(JsonValueKind.String, row.GetProperty("TagList").ValueKind);
|
|
Assert.True(row.GetProperty("IsBaseAvatarItem").ValueKind is JsonValueKind.True or JsonValueKind.False);
|
|
ids.Add(row.GetProperty("AvatarItemId").GetInt32());
|
|
}
|
|
Assert.DoesNotContain(0, ids);
|
|
Assert.Equal(ids.Count, ids.Distinct().Count());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Owning_a_base_item_and_its_colorways_yields_unique_ids()
|
|
{
|
|
using var setup = ApiClient();
|
|
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
|
|
|
// Two colorways of one base item that the runtime catalog knows.
|
|
var group = StoreService.ColorVariantCatalog.Values
|
|
.Where(v => !string.IsNullOrWhiteSpace(v.descriptor)
|
|
&& AvatarItemsController.Catalog.ContainsKey(v.item_guid))
|
|
.GroupBy(v => v.item_guid, StringComparer.OrdinalIgnoreCase)
|
|
.First(g => g.Select(v => v.descriptor).Distinct().Count() >= 2);
|
|
var baseGuid = group.Key;
|
|
var variants = group.Select(v => v.descriptor).Distinct().Take(2).ToList();
|
|
|
|
await using (var scope = _factory.Services.CreateAsyncScope())
|
|
{
|
|
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
|
var avatar = await db.Avatars.FirstAsync(a => a.PlayerId == player.PlayerId);
|
|
var inv = JsonSerializer.Deserialize<List<string>>(avatar.InventoryJson) ?? new();
|
|
inv.Add(baseGuid);
|
|
inv.AddRange(variants);
|
|
avatar.InventoryJson = JsonSerializer.Serialize(inv);
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
using var client = ApiClient(player);
|
|
var items = await GetJsonAsync(client, "/api/avatar/v4/items");
|
|
var sameBase = items.EnumerateArray()
|
|
.Where(x => x.GetProperty("AvatarItemDesc").GetString()!.Split(',')[0]
|
|
.Equals(baseGuid, StringComparison.OrdinalIgnoreCase))
|
|
.ToList();
|
|
Assert.Equal(3, sameBase.Count);
|
|
|
|
var ids = items.EnumerateArray().Select(x => x.GetProperty("AvatarItemId").GetInt32()).ToList();
|
|
Assert.DoesNotContain(0, ids);
|
|
Assert.Equal(ids.Count, ids.Distinct().Count());
|
|
}
|
|
|
|
private HttpClient ApiClient(GameClientSession? session = null)
|
|
{
|
|
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
|
client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}");
|
|
client.Timeout = TimeSpan.FromSeconds(30);
|
|
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
|
|
client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
|
|
client.DefaultRequestHeaders.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
|
|
if (session is not null)
|
|
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
|
return client;
|
|
}
|
|
|
|
private static async Task PostJsonAsync(HttpClient client, string path, object body)
|
|
{
|
|
using var response = await client.PostAsync(path,
|
|
new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json"));
|
|
response.EnsureSuccessStatusCode();
|
|
}
|
|
|
|
private static async Task<JsonElement> GetJsonAsync(HttpClient client, string path)
|
|
{
|
|
using var response = await client.GetAsync(path);
|
|
response.EnsureSuccessStatusCode();
|
|
using var document = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());
|
|
return document.RootElement.Clone();
|
|
}
|
|
}
|