mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 15:08:47 -07:00
fix: presence, profile edits, outfit menu and RecNetResult writes for the 2023 client
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.
This commit is contained in:
@@ -88,6 +88,36 @@ public class RecNetAccount
|
||||
// Missing → LitJson throws and 2020.12 surfaces "Malformed Response".
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
// 2023-03-21 account reader (RecNet.Runtime CICAKEDCJLA / MJKDDBKGNOP /
|
||||
// AEGCKMKICFG) registers these keys in three casings each, e.g.
|
||||
// "DisplayEmoji"/"displayEmoji"/"displayemoji" (CICAKEDCJLA.txt:1398-1550).
|
||||
// Missing keys default on the client, which is why profile emoji,
|
||||
// banner, pronouns and identity flags never rendered for anyone.
|
||||
private string displayEmoji = string.Empty;
|
||||
private string bannerImage = string.Empty;
|
||||
|
||||
[JsonPropertyName("displayEmoji")]
|
||||
public string DisplayEmoji
|
||||
{
|
||||
get => displayEmoji;
|
||||
set => displayEmoji = value ?? string.Empty;
|
||||
}
|
||||
|
||||
[JsonPropertyName("bannerImage")]
|
||||
public string BannerImage
|
||||
{
|
||||
get => bannerImage;
|
||||
set => bannerImage = value ?? string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>Pronoun flags enum, serialized as its Int32 value.</summary>
|
||||
[JsonPropertyName("personalPronouns")]
|
||||
public int PersonalPronouns { get; set; }
|
||||
|
||||
/// <summary>Identity flags enum, serialized as its Int32 value.</summary>
|
||||
[JsonPropertyName("identityFlags")]
|
||||
public int IdentityFlags { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -112,6 +142,13 @@ public class RecNetSelfAccount : RecNetAccount
|
||||
|
||||
[JsonPropertyName("parentAccountId")]
|
||||
public int? ParentAccountId { get; set; } = null;
|
||||
|
||||
// 2023 self-account reader key (CICAKEDCJLA.txt:1310). An absent key
|
||||
// defaults to 0, which the settings UI renders as "no username changes
|
||||
// left". DorkNet does not rate-limit username changes, so report a
|
||||
// generous allowance.
|
||||
[JsonPropertyName("availableUsernameChanges")]
|
||||
public int AvailableUsernameChanges { get; set; } = 99;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// <c>GET api/incentivizedreferrals/referrals?take=&continuationToken=</c> is
|
||||
/// assembled by the client's URL builder (RecNet.Runtime/LINKIECPDML.txt:677-737),
|
||||
/// so it never showed up as a route literal and had no handler. The reply is the
|
||||
/// GPJIMAHMAIO page: <c>{Referrals:[{InviteeAccountId, CreatedAt, VerifiedAt}],
|
||||
/// ContinuationToken}</c> (KOEEACLMAFE.txt:203-238, ADAFNGNADFH.txt:279-346).
|
||||
/// </summary>
|
||||
public sealed class IncentivizedReferralsTests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public IncentivizedReferralsTests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Referrals_page_lists_credited_invitees_with_a_continuation_token()
|
||||
{
|
||||
using var setup = Client();
|
||||
var referrer = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
var inviteeA = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
var inviteeB = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
// Same row LinkController.CreditReferralAsync writes on the invitee.
|
||||
foreach (var invitee in new[] { inviteeA, inviteeB })
|
||||
db.PlayerSettings.Add(new PlayerSettingEntity
|
||||
{
|
||||
PlayerId = invitee.PlayerId,
|
||||
Key = $"referral:credited:{referrer.PlayerId}:{invitee.PlayerId}",
|
||||
Value = DateTime.UtcNow.ToString("O"),
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
using var client = Client(referrer);
|
||||
var first = JsonDocument.Parse(
|
||||
await client.GetStringAsync("/api/incentivizedreferrals/referrals?take=1")).RootElement;
|
||||
var firstPage = Get(first, "Referrals");
|
||||
Assert.Equal(1, firstPage.GetArrayLength());
|
||||
var token = Get(first, "ContinuationToken").GetString();
|
||||
Assert.False(string.IsNullOrEmpty(token));
|
||||
|
||||
var second = JsonDocument.Parse(await client.GetStringAsync(
|
||||
$"/api/incentivizedreferrals/referrals?take=1&continuationToken={token}")).RootElement;
|
||||
var secondPage = Get(second, "Referrals");
|
||||
Assert.Equal(1, secondPage.GetArrayLength());
|
||||
Assert.Equal(JsonValueKind.Null, Get(second, "ContinuationToken").ValueKind);
|
||||
|
||||
var ids = new[] { firstPage[0], secondPage[0] }
|
||||
.Select(e => Get(e, "InviteeAccountId").GetInt64())
|
||||
.OrderBy(x => x)
|
||||
.ToArray();
|
||||
Assert.Equal(new[] { inviteeA.PlayerId, inviteeB.PlayerId }.OrderBy(x => x), ids);
|
||||
// CreatedAt is a non-nullable DateTime on the client: it must be present.
|
||||
Assert.Equal(JsonValueKind.String, Get(firstPage[0], "CreatedAt").ValueKind);
|
||||
}
|
||||
|
||||
// The client formatter registers PascalCase, camelCase and lowercase.
|
||||
private static JsonElement Get(JsonElement e, string pascal) =>
|
||||
e.TryGetProperty(pascal, out var v) ? v : e.GetProperty(char.ToLowerInvariant(pascal[0]) + pascal[1..]);
|
||||
|
||||
private HttpClient Client(GameClientSession? session = null)
|
||||
{
|
||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}");
|
||||
if (session is not null)
|
||||
client.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||
return client;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Controllers.Match;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Presence must follow the player through every transition, for the player
|
||||
/// themselves (the heartbeat) and for everyone else (bulk /player and the
|
||||
/// PresenceUpdate push).
|
||||
///
|
||||
/// The heartbeat side is decided by the client's compare
|
||||
/// (RecNet.Runtime/RecNet/Matchmaking.txt:18415-18467): a cached instance with
|
||||
/// a negative id — the offline room — is in sync ONLY with roomInstance null,
|
||||
/// and anything else makes the client cancel what it is doing and chase the
|
||||
/// server's room. matchmake/none is how the client tells us it went to that
|
||||
/// offline room, so after it the heartbeat must stay null.
|
||||
/// </summary>
|
||||
public sealed class PresenceTransitionTests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public PresenceTransitionTests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Leaving_to_the_offline_room_makes_the_heartbeat_report_no_room()
|
||||
{
|
||||
using var client = await SignedInMatchClientAsync();
|
||||
|
||||
// Enter the dorm for real: matchmake, then report the join.
|
||||
var dormInstance = await MatchmakeDormAsync(client);
|
||||
await ReportJoinAsync(client, dormInstance);
|
||||
var before = await HeartbeatAsync(client);
|
||||
Assert.Equal(dormInstance, FindInstanceId(RoomInstanceOf(before)));
|
||||
|
||||
using var none = await client.PostAsync("/matchmake/none", Form());
|
||||
Assert.True(none.IsSuccessStatusCode, $"POST /matchmake/none -> {(int)none.StatusCode}");
|
||||
|
||||
// Twice: the first beat must not re-seed a dorm the second one returns.
|
||||
for (var i = 0; i < 2; i++)
|
||||
{
|
||||
var beat = await HeartbeatAsync(client);
|
||||
var room = RoomInstanceOf(beat);
|
||||
Assert.True(room.ValueKind == JsonValueKind.Null,
|
||||
$"""
|
||||
After matchmake/none the client holds its offline room (instance -1)
|
||||
and is only in sync with a null roomInstance. The heartbeat answered
|
||||
with a room, which makes the client cancel the offline load and
|
||||
chase it:
|
||||
|
||||
{beat.GetRawText()}
|
||||
""");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Leaving_while_a_join_is_pending_abandons_the_join()
|
||||
{
|
||||
using var client = await SignedInMatchClientAsync();
|
||||
|
||||
// The client gave up on this dorm join (e.g. a Photon timeout) and fell
|
||||
// back to the offline room before reporting a result.
|
||||
await MatchmakeDormAsync(client);
|
||||
using var none = await client.PostAsync("/matchmake/none", Form());
|
||||
Assert.True(none.IsSuccessStatusCode);
|
||||
|
||||
var beat = await HeartbeatAsync(client);
|
||||
Assert.True(RoomInstanceOf(beat).ValueKind == JsonValueKind.Null,
|
||||
$"heartbeat still echoed the abandoned join target: {beat.GetRawText()}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Matchmaking_again_after_leaving_restores_the_room()
|
||||
{
|
||||
using var client = await SignedInMatchClientAsync();
|
||||
|
||||
using var none = await client.PostAsync("/matchmake/none", Form());
|
||||
Assert.True(none.IsSuccessStatusCode);
|
||||
Assert.True(RoomInstanceOf(await HeartbeatAsync(client)).ValueKind == JsonValueKind.Null);
|
||||
|
||||
var dormInstance = await MatchmakeDormAsync(client);
|
||||
await ReportJoinAsync(client, dormInstance);
|
||||
|
||||
var beat = await HeartbeatAsync(client);
|
||||
Assert.Equal(dormInstance, FindInstanceId(RoomInstanceOf(beat)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Friends_see_a_player_in_the_offline_room_as_online_with_no_room()
|
||||
{
|
||||
using var subject = await SignedInMatchClientAsync();
|
||||
using var viewer = await SignedInMatchClientAsync();
|
||||
var subjectId = await PlayerIdOfAsync(subject);
|
||||
|
||||
var dormInstance = await MatchmakeDormAsync(subject);
|
||||
await ReportJoinAsync(subject, dormInstance);
|
||||
using var none = await subject.PostAsync("/matchmake/none", Form());
|
||||
Assert.True(none.IsSuccessStatusCode);
|
||||
|
||||
var row = await BulkPresenceAsync(viewer, subjectId);
|
||||
Assert.True(row.GetProperty("isOnline").GetBoolean(),
|
||||
$"a player sitting in the offline room is online: {row.GetRawText()}");
|
||||
Assert.Equal(JsonValueKind.Null, row.GetProperty("roomInstance").ValueKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Logging_out_shows_the_player_offline_and_roomless()
|
||||
{
|
||||
using var subject = await SignedInMatchClientAsync();
|
||||
using var viewer = await SignedInMatchClientAsync();
|
||||
var subjectId = await PlayerIdOfAsync(subject);
|
||||
|
||||
var dormInstance = await MatchmakeDormAsync(subject);
|
||||
await ReportJoinAsync(subject, dormInstance);
|
||||
Assert.True((await BulkPresenceAsync(viewer, subjectId)).GetProperty("isOnline").GetBoolean());
|
||||
|
||||
using var logout = await subject.PostAsync("/player/logout", Form());
|
||||
Assert.True(logout.IsSuccessStatusCode);
|
||||
|
||||
var row = await BulkPresenceAsync(viewer, subjectId);
|
||||
Assert.False(row.GetProperty("isOnline").GetBoolean(), row.GetRawText());
|
||||
Assert.Equal(JsonValueKind.Null, row.GetProperty("roomInstance").ValueKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bulk_presence_reports_the_stored_status_visibility()
|
||||
{
|
||||
using var subject = await SignedInMatchClientAsync();
|
||||
using var viewer = await SignedInMatchClientAsync();
|
||||
var subjectId = await PlayerIdOfAsync(subject);
|
||||
|
||||
using var put = await subject.PutAsync("/player/statusvisibility",
|
||||
Form(("statusVisibility", "2")));
|
||||
Assert.True(put.IsSuccessStatusCode, $"PUT statusvisibility -> {(int)put.StatusCode}");
|
||||
|
||||
var row = await BulkPresenceAsync(viewer, subjectId);
|
||||
Assert.Equal(2, row.GetProperty("statusVisibility").GetInt32());
|
||||
|
||||
// And the player's own heartbeat agrees.
|
||||
var beat = await HeartbeatAsync(subject);
|
||||
Assert.Equal(2, beat.GetProperty("statusVisibility").GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Presence_push_carries_online_state_independently_of_the_room()
|
||||
{
|
||||
var room = new RoomInstanceDto
|
||||
{
|
||||
RoomInstanceId = 42,
|
||||
RoomId = 7,
|
||||
Name = "DormRoom",
|
||||
PhotonRoomId = "^dormroom_p1",
|
||||
};
|
||||
|
||||
// Online, no room: the offline-room state. Used to be sent as OFFLINE
|
||||
// because isOnline was derived from the room.
|
||||
var onlineNoRoom = Msg(NotificationService.BuildPresenceUpdatePayload(5, null, isOnline: true));
|
||||
Assert.True(onlineNoRoom.GetProperty("isOnline").GetBoolean());
|
||||
Assert.Equal(JsonValueKind.Null, onlineNoRoom.GetProperty("roomInstance").ValueKind);
|
||||
|
||||
// Offline never leaks a room, even if one is still cached.
|
||||
var offline = Msg(NotificationService.BuildPresenceUpdatePayload(5, room, isOnline: false));
|
||||
Assert.False(offline.GetProperty("isOnline").GetBoolean());
|
||||
Assert.Equal(JsonValueKind.Null, offline.GetProperty("roomInstance").ValueKind);
|
||||
|
||||
// Online in a room: redacted join coordinates, visibility passed through,
|
||||
// appVersion a STRING.
|
||||
var inRoom = Msg(NotificationService.BuildPresenceUpdatePayload(5, room, isOnline: true, statusVisibility: 1));
|
||||
Assert.True(inRoom.GetProperty("isOnline").GetBoolean());
|
||||
Assert.Equal(42, inRoom.GetProperty("roomInstance").GetProperty("roomInstanceId").GetInt64());
|
||||
Assert.Equal(string.Empty, inRoom.GetProperty("roomInstance").GetProperty("photonRoomId").GetString());
|
||||
Assert.Equal(1, inRoom.GetProperty("statusVisibility").GetInt32());
|
||||
Assert.Equal(JsonValueKind.String, inRoom.GetProperty("appVersion").ValueKind);
|
||||
|
||||
static JsonElement Msg(string payload)
|
||||
{
|
||||
var root = JsonDocument.Parse(payload).RootElement;
|
||||
// String-keyed channel: the client registers "PresenceUpdate".
|
||||
Assert.Equal("PresenceUpdate", root.GetProperty("Id").GetString());
|
||||
return root.GetProperty("Msg");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Presence_pushes_honour_the_players_status_visibility()
|
||||
{
|
||||
// Live PresenceUpdate frames must hide a player exactly as GET /player
|
||||
// does, or "appear offline" leaks every room change to friends.
|
||||
long[] friends = [10, 11, 12];
|
||||
var favorited = new HashSet<long> { 11 };
|
||||
|
||||
var pub = PresenceBroadcastService.SplitRecipients(friends, PresenceVisibility.Public, favorited);
|
||||
Assert.Equal(friends, pub.VisibleTo);
|
||||
Assert.Empty(pub.HiddenFrom);
|
||||
|
||||
// Every recipient is already a friend, so FriendsOnly hides nobody.
|
||||
var friendsOnly = PresenceBroadcastService.SplitRecipients(friends, PresenceVisibility.FriendsOnly, favorited);
|
||||
Assert.Equal(friends, friendsOnly.VisibleTo);
|
||||
Assert.Empty(friendsOnly.HiddenFrom);
|
||||
|
||||
var favoritesOnly = PresenceBroadcastService.SplitRecipients(friends, PresenceVisibility.FavoriteFriendsOnly, favorited);
|
||||
Assert.Equal([11L], favoritesOnly.VisibleTo);
|
||||
Assert.Equal([10L, 12L], favoritesOnly.HiddenFrom);
|
||||
|
||||
var offline = PresenceBroadcastService.SplitRecipients(friends, PresenceVisibility.Offline, favorited);
|
||||
Assert.Empty(offline.VisibleTo);
|
||||
Assert.Equal(friends, offline.HiddenFrom);
|
||||
}
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────
|
||||
|
||||
private async Task<HttpClient> SignedInMatchClientAsync()
|
||||
{
|
||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://match.{_factory.ApexDomain}");
|
||||
var session = await GameClientSessionFactory.CreateAsync(client, _factory.ApexDomain);
|
||||
client.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||
return client;
|
||||
}
|
||||
|
||||
private static async Task<long> PlayerIdOfAsync(HttpClient client)
|
||||
{
|
||||
var beat = await HeartbeatAsync(client);
|
||||
return beat.GetProperty("playerId").GetInt64();
|
||||
}
|
||||
|
||||
private static async Task<string> MatchmakeDormAsync(HttpClient client)
|
||||
{
|
||||
using var resp = await client.PostAsync("/matchmake/dorm", Form(("MaxPersistenceVersion", "45")));
|
||||
var body = await resp.Content.ReadAsStringAsync();
|
||||
Assert.True(resp.IsSuccessStatusCode, $"POST /matchmake/dorm -> {(int)resp.StatusCode}: {body}");
|
||||
var id = FindInstanceId(JsonDocument.Parse(body).RootElement);
|
||||
Assert.False(string.IsNullOrEmpty(id), $"no roomInstanceId in the dorm reply: {body}");
|
||||
return id!;
|
||||
}
|
||||
|
||||
private static async Task ReportJoinAsync(HttpClient client, string instanceId)
|
||||
{
|
||||
using var resp = await client.PostAsync($"/roominstance/{instanceId}/reportjoinresult", Form(("result", "0")));
|
||||
Assert.True(resp.IsSuccessStatusCode, $"reportjoinresult -> {(int)resp.StatusCode}");
|
||||
}
|
||||
|
||||
private static async Task<JsonElement> HeartbeatAsync(HttpClient client)
|
||||
{
|
||||
using var resp = await client.PostAsync("/player/heartbeat", Form(("LoginLock", Guid.NewGuid().ToString())));
|
||||
var body = await resp.Content.ReadAsStringAsync();
|
||||
Assert.True(resp.IsSuccessStatusCode, $"POST /player/heartbeat -> {(int)resp.StatusCode}: {body}");
|
||||
return JsonDocument.Parse(body).RootElement.Clone();
|
||||
}
|
||||
|
||||
private static async Task<JsonElement> BulkPresenceAsync(HttpClient viewer, long subjectId)
|
||||
{
|
||||
using var resp = await viewer.GetAsync($"/player?{subjectId}");
|
||||
var body = await resp.Content.ReadAsStringAsync();
|
||||
Assert.True(resp.IsSuccessStatusCode, $"GET /player -> {(int)resp.StatusCode}: {body}");
|
||||
foreach (var row in JsonDocument.Parse(body).RootElement.EnumerateArray())
|
||||
{
|
||||
if (row.GetProperty("playerId").GetInt64() == subjectId) return row.Clone();
|
||||
}
|
||||
Assert.Fail($"no row for {subjectId}: {body}");
|
||||
return default;
|
||||
}
|
||||
|
||||
private static JsonElement RoomInstanceOf(JsonElement presence)
|
||||
{
|
||||
Assert.True(presence.TryGetProperty("roomInstance", out var room),
|
||||
$"no roomInstance key: {presence.GetRawText()}");
|
||||
return room;
|
||||
}
|
||||
|
||||
private static FormUrlEncodedContent Form(params (string Key, string Value)[] fields) =>
|
||||
new(fields.Select(f => new KeyValuePair<string, string>(f.Key, f.Value)));
|
||||
|
||||
private static string? FindInstanceId(JsonElement element)
|
||||
{
|
||||
if (element.ValueKind != JsonValueKind.Object) return null;
|
||||
foreach (var property in element.EnumerateObject())
|
||||
{
|
||||
if (property.Name.Equals("roomInstanceId", StringComparison.OrdinalIgnoreCase))
|
||||
return property.Value.ToString();
|
||||
if (property.Value.ValueKind == JsonValueKind.Object
|
||||
&& FindInstanceId(property.Value) is { } nested)
|
||||
return nested;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using DorkNet.Server.Controllers.Match;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// In-game profile edits for the 2023-03-21 client. Every edit goes through
|
||||
/// OPMAPIOEIFG.OBAGJBBIFGB → PUT "account/me/" + suffix with a single
|
||||
/// urlencoded field whose name differs from the suffix (displayEmoji,
|
||||
/// pronounFlags, identityFlags, imageName). None of these routes existed, so
|
||||
/// every edit failed with "Failed to modify <suffix>", and no account
|
||||
/// DTO emitted the fields, so nothing showed up for anyone. Online-status
|
||||
/// visibility (PUT player/statusvisibility) was persisted but GET /player
|
||||
/// always reported Public and never hid the player.
|
||||
/// </summary>
|
||||
public sealed class ProfileEdit2023Tests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public ProfileEdit2023Tests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Emoji_edit_persists_and_is_visible_to_other_players()
|
||||
{
|
||||
var (owner, viewer) = await TwoPlayersAsync();
|
||||
using var ownerClient = AccountsClient(owner);
|
||||
using var viewerClient = AccountsClient(viewer);
|
||||
|
||||
await PutFormOkAsync(ownerClient, "/account/me/emoji", "displayEmoji", "🤪");
|
||||
|
||||
var me = await GetJsonAsync(ownerClient, "/account/me");
|
||||
Assert.Equal("🤪", me.GetProperty("displayEmoji").GetString());
|
||||
|
||||
var seen = await GetJsonAsync(viewerClient, $"/account/{owner.PlayerId}");
|
||||
Assert.Equal("🤪", seen.GetProperty("displayEmoji").GetString());
|
||||
|
||||
// Empty clears it.
|
||||
await PutFormOkAsync(ownerClient, "/account/me/emoji", "displayEmoji", "");
|
||||
me = await GetJsonAsync(ownerClient, "/account/me");
|
||||
Assert.Equal("", me.GetProperty("displayEmoji").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Emoji_edit_rejects_non_whitelisted_and_canonicalizes_variation_selector()
|
||||
{
|
||||
var (owner, _) = await TwoPlayersAsync();
|
||||
using var client = AccountsClient(owner);
|
||||
|
||||
using (var bad = await PutFormAsync(client, "/account/me/emoji", "displayEmoji", "not-an-emoji"))
|
||||
Assert.Equal(HttpStatusCode.BadRequest, bad.StatusCode);
|
||||
|
||||
// "✌" without U+FE0F resolves to the list's "✌️".
|
||||
await PutFormOkAsync(client, "/account/me/emoji", "displayEmoji", "✌");
|
||||
var me = await GetJsonAsync(client, "/account/me");
|
||||
Assert.Equal("✌️", me.GetProperty("displayEmoji").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Pronouns_identity_flags_and_banner_round_trip_on_every_account_read()
|
||||
{
|
||||
var (owner, viewer) = await TwoPlayersAsync();
|
||||
using var ownerClient = AccountsClient(owner);
|
||||
using var viewerClient = AccountsClient(viewer);
|
||||
|
||||
await PutFormOkAsync(ownerClient, "/account/me/personalpronouns", "pronounFlags", "5");
|
||||
await PutFormOkAsync(ownerClient, "/account/me/identityflags", "identityFlags", "3");
|
||||
await PutFormOkAsync(ownerClient, "/account/me/bannerimage", "imageName", "banner-test.jpg");
|
||||
|
||||
void AssertProfile(JsonElement a)
|
||||
{
|
||||
Assert.Equal(5, a.GetProperty("personalPronouns").GetInt32());
|
||||
Assert.Equal(3, a.GetProperty("identityFlags").GetInt32());
|
||||
Assert.Equal("banner-test.jpg", a.GetProperty("bannerImage").GetString());
|
||||
}
|
||||
|
||||
AssertProfile(await GetJsonAsync(ownerClient, "/account/me"));
|
||||
AssertProfile(await GetJsonAsync(viewerClient, $"/account/{owner.PlayerId}"));
|
||||
|
||||
var bulk = await GetJsonAsync(viewerClient, $"/account/bulk?id={owner.PlayerId}");
|
||||
AssertProfile(bulk.EnumerateArray().Single(a => a.GetProperty("accountId").GetInt64() == owner.PlayerId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Flag_edits_reject_non_numeric_values()
|
||||
{
|
||||
var (owner, _) = await TwoPlayersAsync();
|
||||
using var client = AccountsClient(owner);
|
||||
|
||||
using (var r = await PutFormAsync(client, "/account/me/personalpronouns", "pronounFlags", "HeHim"))
|
||||
Assert.Equal(HttpStatusCode.BadRequest, r.StatusCode);
|
||||
using (var r = await PutFormAsync(client, "/account/me/identityflags", "identityFlags", "-1"))
|
||||
Assert.Equal(HttpStatusCode.BadRequest, r.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Self_account_reports_username_changes_remaining()
|
||||
{
|
||||
var (owner, _) = await TwoPlayersAsync();
|
||||
using var client = AccountsClient(owner);
|
||||
var me = await GetJsonAsync(client, "/account/me");
|
||||
// Absent/0 makes the settings UI say no username changes are left.
|
||||
Assert.True(me.GetProperty("availableUsernameChanges").GetInt32() > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bio_edit_round_trips_through_public_bio_lookup()
|
||||
{
|
||||
var (owner, viewer) = await TwoPlayersAsync();
|
||||
using var ownerClient = AccountsClient(owner);
|
||||
using var viewerClient = AccountsClient(viewer);
|
||||
|
||||
await PutFormOkAsync(ownerClient, "/account/me/bio", "bio", "hello from the profile");
|
||||
var bio = await GetJsonAsync(viewerClient, $"/account/{owner.PlayerId}/bio");
|
||||
Assert.Equal("hello from the profile", bio.GetProperty("bio").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Status_visibility_is_reported_to_self_and_hides_appear_offline_player()
|
||||
{
|
||||
var (owner, viewer) = await TwoPlayersAsync();
|
||||
PutOnline(owner.PlayerId);
|
||||
|
||||
using var ownerMatch = MatchClient(owner);
|
||||
using var viewerMatch = MatchClient(viewer);
|
||||
|
||||
// Baseline: Public → viewer sees owner online.
|
||||
var before = await PresenceOfAsync(viewerMatch, owner.PlayerId);
|
||||
Assert.True(before.GetProperty("isOnline").GetBoolean());
|
||||
|
||||
await PutFormOkAsync(ownerMatch, "/player/statusvisibility", "statusVisibility", "3");
|
||||
|
||||
// Self keeps its own choice (the dialog reads the cached self presence).
|
||||
var self = await PresenceOfAsync(ownerMatch, owner.PlayerId);
|
||||
Assert.Equal(3, self.GetProperty("statusVisibility").GetInt32());
|
||||
Assert.True(self.GetProperty("isOnline").GetBoolean());
|
||||
|
||||
// Others see an offline player with no room.
|
||||
var other = await PresenceOfAsync(viewerMatch, owner.PlayerId);
|
||||
Assert.False(other.GetProperty("isOnline").GetBoolean());
|
||||
Assert.Equal(JsonValueKind.Null, other.GetProperty("roomInstance").ValueKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Friends_only_visibility_shows_friends_and_hides_strangers()
|
||||
{
|
||||
var (owner, friend) = await TwoPlayersAsync();
|
||||
using var setup = AccountsClient();
|
||||
var stranger = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
PutOnline(owner.PlayerId);
|
||||
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
db.Relationships.Add(new RelationshipEntity
|
||||
{
|
||||
RequesterId = owner.PlayerId,
|
||||
TargetId = friend.PlayerId,
|
||||
Status = RelationshipStatus.Friend,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
using var ownerMatch = MatchClient(owner);
|
||||
await PutFormOkAsync(ownerMatch, "/player/statusvisibility", "statusVisibility", "1");
|
||||
|
||||
using var friendMatch = MatchClient(friend);
|
||||
using var strangerMatch = MatchClient(stranger);
|
||||
Assert.True((await PresenceOfAsync(friendMatch, owner.PlayerId)).GetProperty("isOnline").GetBoolean());
|
||||
Assert.False((await PresenceOfAsync(strangerMatch, owner.PlayerId)).GetProperty("isOnline").GetBoolean());
|
||||
Assert.Equal(1, (await PresenceOfAsync(strangerMatch, owner.PlayerId)).GetProperty("statusVisibility").GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Presence_pref_key_matches_status_visibility_writer()
|
||||
{
|
||||
// MatchPlayerController writes the pref; PlayerPresenceController reads it.
|
||||
var field = typeof(MatchPlayerController).GetField(
|
||||
"PrefStatusVisibility",
|
||||
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
|
||||
Assert.NotNull(field);
|
||||
Assert.Equal(PlayerPresenceController.PrefStatusVisibility, (string)field!.GetRawConstantValue()!);
|
||||
}
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
private void PutOnline(long playerId)
|
||||
{
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var presence = scope.ServiceProvider.GetRequiredService<PlayerPresenceService>();
|
||||
presence.SetRoom(playerId, new RoomInstanceDto
|
||||
{
|
||||
RoomInstanceId = 900_000 + playerId,
|
||||
RoomId = 1,
|
||||
SubRoomId = 0,
|
||||
PhotonRoomId = $"^profiletest{playerId}",
|
||||
PhotonRegionId = "us",
|
||||
MaxCapacity = 8,
|
||||
});
|
||||
presence.MarkActive(playerId);
|
||||
}
|
||||
|
||||
private static async Task<JsonElement> PresenceOfAsync(HttpClient client, long playerId)
|
||||
{
|
||||
var list = await GetJsonAsync(client, $"/player?{playerId}");
|
||||
return list.EnumerateArray().Single(p => p.GetProperty("playerId").GetInt64() == playerId);
|
||||
}
|
||||
|
||||
private async Task<(GameClientSession, GameClientSession)> TwoPlayersAsync()
|
||||
{
|
||||
using var setup = AccountsClient();
|
||||
var a = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
var b = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
return (a, b);
|
||||
}
|
||||
|
||||
private static async Task<HttpResponseMessage> PutFormAsync(HttpClient client, string path, string field, string value)
|
||||
=> await client.PutAsync(path, new FormUrlEncodedContent(new Dictionary<string, string> { [field] = value }));
|
||||
|
||||
private static async Task PutFormOkAsync(HttpClient client, string path, string field, string value)
|
||||
{
|
||||
using var response = await PutFormAsync(client, path, field, value);
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.True(response.IsSuccessStatusCode, $"PUT {path} → {(int)response.StatusCode}: {body}");
|
||||
}
|
||||
|
||||
private HttpClient AccountsClient(GameClientSession? session = null) => Client("accounts", session);
|
||||
|
||||
private HttpClient MatchClient(GameClientSession session) => Client("match", session);
|
||||
|
||||
private HttpClient Client(string host, GameClientSession? session)
|
||||
{
|
||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://{host}.{_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<JsonElement> GetJsonAsync(HttpClient client, string path)
|
||||
{
|
||||
using var response = await client.GetAsync(path);
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.True(response.IsSuccessStatusCode, $"GET {path} → {(int)response.StatusCode}: {body}");
|
||||
using var document = JsonDocument.Parse(body);
|
||||
return document.RootElement.Clone();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Writes whose client method returns the untyped promise (<c>LDGADANDBIO</c>)
|
||||
/// but whose reply is still parsed — as <c>DJMHAFPGLLN</c>, the
|
||||
/// <c>{Success, Error}</c> RecNetResult — by <c>BFDGEKKNAPB.PEFKBAEAOPD</c>
|
||||
/// (ExpandRecNetResult, RecNet.Runtime/BFDGEKKNAPB.txt:353).
|
||||
///
|
||||
/// The failure modes are silent server-side: an empty 200 parses to null and
|
||||
/// null-derefs, so the player sees "Failed to …" after the change was applied;
|
||||
/// a body without <c>Success</c> parses as a failure whose <c>Error</c> is null,
|
||||
/// and <c>Promise.Rejected(null)</c> throws "Error message cannot be null".
|
||||
/// So the assertion that matters is the body, read the way the client's
|
||||
/// formatter reads it (LMCCNLLHLCJ.txt: Success/success, Error/error).
|
||||
/// </summary>
|
||||
public sealed class RecNetResultEnvelopeTests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public RecNetResultEnvelopeTests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Club_member_writes_answer_a_success_result_not_an_empty_body()
|
||||
{
|
||||
using var setup = Client("clubs");
|
||||
var owner = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
var joiner = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
var clubId = await SeedOpenClubAsync(owner.PlayerId);
|
||||
|
||||
using var joinerClient = Client("clubs", joiner);
|
||||
// requesttojoin is verb 3 (PUT) through IKMMOCKDKAF.JNFEEDLCGHH, form-encoded.
|
||||
using var join = await joinerClient.PutAsync(
|
||||
$"/club/{clubId}/members/requesttojoin", new FormUrlEncodedContent([]));
|
||||
await AssertRecNetSuccessAsync(join, "requesttojoin");
|
||||
|
||||
using var leave = await joinerClient.PostAsync(
|
||||
$"/club/{clubId}/members/leave", new FormUrlEncodedContent([]));
|
||||
await AssertRecNetSuccessAsync(leave, "leave");
|
||||
|
||||
using var clearHome = await joinerClient.DeleteAsync("/club/home/me");
|
||||
await AssertRecNetSuccessAsync(clearHome, "DELETE club/home/me");
|
||||
|
||||
using var ownerClient = Client("clubs", owner);
|
||||
using var delete = await ownerClient.DeleteAsync($"/club/{clubId}");
|
||||
await AssertRecNetSuccessAsync(delete, "DELETE club/{id}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Read_markers_and_privacy_toggle_answer_a_success_result()
|
||||
{
|
||||
using var setup = Client("api");
|
||||
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
using var client = Client("api", player);
|
||||
|
||||
using var commentRead = await client.PutAsync("/comments/read/1/1", content: null);
|
||||
await AssertRecNetSuccessAsync(commentRead, "PUT comments/read");
|
||||
|
||||
using var accounts = Client("accounts", player);
|
||||
using var privacy = await accounts.PutAsync(
|
||||
"/accountprivacysettings/recenthistoryvisibility",
|
||||
new FormUrlEncodedContent([new("isRecentHistoryVisible", "false")]));
|
||||
await AssertRecNetSuccessAsync(privacy, "PUT recenthistoryvisibility");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_refused_warning_carries_an_error_message()
|
||||
{
|
||||
using var setup = Client("api");
|
||||
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
using var client = Client("api", player);
|
||||
|
||||
using var response = await client.PostAsync("/api/playerwarnings",
|
||||
new FormUrlEncodedContent([new("WarnedPlayerId", player.PlayerId.ToString())]));
|
||||
var root = JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement;
|
||||
Assert.False(ReadSuccess(root));
|
||||
// Without Error the client's Promise.Rejected(null) throws instead of
|
||||
// surfacing the refusal.
|
||||
Assert.False(string.IsNullOrEmpty(ReadError(root)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Login_token_is_a_bare_string_redeemed_once_for_its_own_account()
|
||||
{
|
||||
using var setup = Client("auth");
|
||||
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
using var auth = Client("auth", player);
|
||||
|
||||
using var mint = await auth.PostAsync("/account/me/createlogintoken", content: null);
|
||||
var raw = await mint.Content.ReadAsStringAsync();
|
||||
Assert.True(mint.IsSuccessStatusCode, raw);
|
||||
// FGLDKEJLAKB<String> LGKLFFPACGL(): a JSON string, not an object.
|
||||
var token = JsonSerializer.Deserialize<string>(raw);
|
||||
Assert.False(string.IsNullOrEmpty(token));
|
||||
|
||||
using var anon = Client("auth");
|
||||
var form = new Dictionary<string, string>
|
||||
{
|
||||
["grant_type"] = "login_token",
|
||||
["account_id"] = player.PlayerId.ToString(),
|
||||
["login_token"] = token!,
|
||||
};
|
||||
using var redeemed = await anon.PostAsync("/connect/token", new FormUrlEncodedContent(form));
|
||||
var redeemedBody = await redeemed.Content.ReadAsStringAsync();
|
||||
Assert.True(redeemed.IsSuccessStatusCode, redeemedBody);
|
||||
Assert.Equal(player.PlayerId, await SubjectOfAsync(redeemedBody));
|
||||
|
||||
using var again = await anon.PostAsync("/connect/token", new FormUrlEncodedContent(form));
|
||||
Assert.Equal(HttpStatusCode.BadRequest, again.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Device_code_poll_waits_for_remoteauth_approval_then_signs_in_the_approver()
|
||||
{
|
||||
using var anon = Client("auth");
|
||||
var approver = await GameClientSessionFactory.CreateAsync(anon, _factory.ApexDomain);
|
||||
|
||||
using var start = await anon.PostAsync("/connect/deviceauthorization", content: null);
|
||||
var started = JsonDocument.Parse(await start.Content.ReadAsStringAsync()).RootElement;
|
||||
var deviceCode = started.GetProperty("device_code").GetString()!;
|
||||
var userCode = started.GetProperty("user_code").GetString()!;
|
||||
|
||||
var poll = new Dictionary<string, string>
|
||||
{
|
||||
["grant_type"] = "urn:ietf:params:oauth:grant-type:device_code",
|
||||
["device_code"] = deviceCode,
|
||||
};
|
||||
|
||||
// Before approval the client must be told to keep polling: a 400
|
||||
// whose error is authorization_pending (JAEKFNKDNCP.txt:97-107).
|
||||
using (var pending = await anon.PostAsync("/connect/token", new FormUrlEncodedContent(poll)))
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.BadRequest, pending.StatusCode);
|
||||
var body = JsonDocument.Parse(await pending.Content.ReadAsStringAsync()).RootElement;
|
||||
Assert.Equal("authorization_pending", body.GetProperty("error").GetString());
|
||||
}
|
||||
|
||||
using var approverClient = Client("auth", approver);
|
||||
using (var bad = await approverClient.PostAsync("/account/me/remoteauth",
|
||||
new FormUrlEncodedContent([new("code", "NOPE0000")])))
|
||||
{
|
||||
var root = JsonDocument.Parse(await bad.Content.ReadAsStringAsync()).RootElement;
|
||||
Assert.False(ReadSuccess(root));
|
||||
Assert.False(string.IsNullOrEmpty(ReadError(root)));
|
||||
}
|
||||
|
||||
using var approve = await approverClient.PostAsync("/account/me/remoteauth",
|
||||
new FormUrlEncodedContent([new("code", userCode.ToLowerInvariant())]));
|
||||
await AssertRecNetSuccessAsync(approve, "remoteauth");
|
||||
|
||||
using var granted = await anon.PostAsync("/connect/token", new FormUrlEncodedContent(poll));
|
||||
var grantedBody = await granted.Content.ReadAsStringAsync();
|
||||
Assert.True(granted.IsSuccessStatusCode, grantedBody);
|
||||
Assert.Equal(approver.PlayerId, await SubjectOfAsync(grantedBody));
|
||||
|
||||
// Single use.
|
||||
using var reused = await anon.PostAsync("/connect/token", new FormUrlEncodedContent(poll));
|
||||
Assert.Equal(HttpStatusCode.BadRequest, reused.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Gameconfig_carries_the_play_menu_carousel_ordering_in_the_parsed_format()
|
||||
{
|
||||
using var client = Client("api");
|
||||
var rows = JsonDocument.Parse(await client.GetStringAsync("/api/gameconfigs/v1/all")).RootElement;
|
||||
var row = rows.EnumerateArray().FirstOrDefault(r =>
|
||||
string.Equals(Prop(r, "Key"), "PlayMenuCarouselOrdering_1", StringComparison.Ordinal));
|
||||
Assert.NotEqual(JsonValueKind.Undefined, row.ValueKind);
|
||||
|
||||
// PlayMenuCarouselModel.TryGetDisplayPriorityMap: Split(';'), then each
|
||||
// entry Split('=') must give exactly 2 parts with an Int32 priority, or
|
||||
// the entry is logged as "Failed to parse priority mapping".
|
||||
var entries = Prop(row, "Value")!.Split(';');
|
||||
Assert.All(entries, e =>
|
||||
{
|
||||
var parts = e.Split('=');
|
||||
Assert.Equal(2, parts.Length);
|
||||
Assert.True(int.TryParse(parts[1].Trim(), out _), e);
|
||||
});
|
||||
Assert.Contains(entries, e => e.StartsWith("hotrooms=", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Server_strings_download_answers_an_empty_table()
|
||||
{
|
||||
using var client = Client("strings");
|
||||
using var response = await client.GetAsync("/strings?locale=en-US&scopePrefix=client.&eTag=");
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.True(response.IsSuccessStatusCode, body);
|
||||
Assert.Equal(JsonValueKind.Object, JsonDocument.Parse(body).RootElement.ValueKind);
|
||||
}
|
||||
|
||||
/// <summary>PUT playerevents/v2/{id}/multiinstance sends the two broadcast
|
||||
/// permission fields as boxed enums, i.e. member names (BNDIAONDFFF.txt:947
|
||||
/// falls back to Object.ToString). Parsing them as ints stored 0 every time.</summary>
|
||||
[Theory]
|
||||
[InlineData("RoomOwners", 256)]
|
||||
[InlineData("All", int.MaxValue)]
|
||||
[InlineData("None", 0)]
|
||||
[InlineData("256", 256)]
|
||||
[InlineData("roomowners, None", 256)]
|
||||
public void Broadcast_permission_names_parse_to_their_values(string raw, int expected) =>
|
||||
Assert.Equal(expected,
|
||||
DorkNet.Server.Controllers.API.PlayerEvents.PlayerEventsController.ParseBroadcastPermissions(raw));
|
||||
|
||||
[Fact]
|
||||
public void Unknown_broadcast_permission_name_keeps_the_stored_value() =>
|
||||
Assert.Null(DorkNet.Server.Controllers.API.PlayerEvents.PlayerEventsController
|
||||
.ParseBroadcastPermissions("SomethingElse"));
|
||||
|
||||
private async Task<long> SeedOpenClubAsync(long ownerId)
|
||||
{
|
||||
await using var scope = _factory.Services.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
var club = new ClubEntity
|
||||
{
|
||||
Name = $"Env{Guid.NewGuid():N}"[..16],
|
||||
CreatorPlayerId = ownerId,
|
||||
Joinability = 0, // Open
|
||||
};
|
||||
db.Clubs.Add(club);
|
||||
await db.SaveChangesAsync();
|
||||
return club.Id;
|
||||
}
|
||||
|
||||
private async Task<long> SubjectOfAsync(string tokenBody)
|
||||
{
|
||||
var access = JsonDocument.Parse(tokenBody).RootElement.GetProperty("access_token").GetString();
|
||||
using var client = Client("auth");
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", access);
|
||||
var info = JsonDocument.Parse(await client.GetStringAsync("/connect/userinfo")).RootElement;
|
||||
return long.Parse(info.GetProperty("sub").GetString()!);
|
||||
}
|
||||
|
||||
private static async Task AssertRecNetSuccessAsync(HttpResponseMessage response, string what)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.True(response.IsSuccessStatusCode, $"{what} -> {(int)response.StatusCode}: {body}");
|
||||
Assert.False(string.IsNullOrWhiteSpace(body), $"{what} answered an empty body");
|
||||
var root = JsonDocument.Parse(body).RootElement;
|
||||
Assert.True(ReadSuccess(root), $"{what} body has no Success=true: {body}");
|
||||
}
|
||||
|
||||
// The client formatter registers exactly these spellings.
|
||||
private static bool ReadSuccess(JsonElement root) =>
|
||||
(root.TryGetProperty("Success", out var s) || root.TryGetProperty("success", out s))
|
||||
&& s.ValueKind == JsonValueKind.True;
|
||||
|
||||
private static string? ReadError(JsonElement root) =>
|
||||
root.TryGetProperty("Error", out var e) || root.TryGetProperty("error", out e)
|
||||
? e.ValueKind == JsonValueKind.String ? e.GetString() : null
|
||||
: null;
|
||||
|
||||
private static string? Prop(JsonElement row, string name) =>
|
||||
row.TryGetProperty(name, out var v) || row.TryGetProperty(char.ToLowerInvariant(name[0]) + name[1..], out v)
|
||||
? v.GetString()
|
||||
: null;
|
||||
|
||||
private HttpClient Client(string host, GameClientSession? session = null)
|
||||
{
|
||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://{host}.{_factory.ApexDomain}");
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -523,3 +523,45 @@ POST thread/{0}/snooze social-clubs-chat
|
||||
GET unity_assets/{0}/{1}/{2} inventions-media
|
||||
GET voice/config moderation-safety
|
||||
GET voice/requiresModeration moderation-safety
|
||||
# Requests the plain route-literal sweep missed. Each verified in the ISIL:
|
||||
# - leading-slash literals (RecNet.Runtime): LCOCDKINDMN_NestedType_CIPKPLFLIPC.txt:218,
|
||||
# PBOGNAPEBAI_NestedType_JKNCNOHGPHK.txt:251 (GET under 100 ids, POST at 100+),
|
||||
# NHILNDLLLHH.txt:329/801, OKOOJMAOKFE.txt:561, MNANGNGKKDA_NestedType_JAMKPNKPJPD.txt:179
|
||||
# - Assembly-CSharp CDN fetch: JFDPIJFDHAD.txt:1432 (/sigs/<id>, boot file-signature check)
|
||||
# - DELETE subscription/{0}: IKMMOCKDKAF.txt LPFOPPHGDHG (verb 4)
|
||||
# - account/me/{field}: composed at runtime by OPMAPIOEIFG.OBAGJBBIFGB("account/me/" + field);
|
||||
# verbs from each caller (username/displayname/bio/birthday/emoji/personalpronouns/identityflags/bannerimage PUT=3,
|
||||
# email/phone/confirmphone POST=2).
|
||||
GET api/playerReputation/v2/bulk identity-account
|
||||
POST api/playerReputation/v2/bulk identity-account
|
||||
GET api/players/v2/progression/bulk identity-account
|
||||
POST api/players/v2/progression/bulk identity-account
|
||||
GET strings config-telemetry
|
||||
GET config/{0} config-telemetry
|
||||
GET data/{0} config-telemetry
|
||||
GET sigs/{0} config-telemetry
|
||||
DELETE subscription/{0} commerce-subscription
|
||||
PUT account/me/username identity-account
|
||||
PUT account/me/displayname identity-account
|
||||
PUT account/me/bio identity-account
|
||||
PUT account/me/birthday identity-account
|
||||
POST account/me/email identity-account
|
||||
POST account/me/phone identity-account
|
||||
PUT account/me/emoji identity-account
|
||||
PUT account/me/personalpronouns identity-account
|
||||
PUT account/me/identityflags identity-account
|
||||
PUT account/me/bannerimage identity-account
|
||||
POST account/me/confirmphone identity-account
|
||||
# Routes composed from a base literal plus a format/segment (BPAIBFBKKCN.txt:51-53, HPEBLAHNBLC.txt:38-40,
|
||||
# and the LPPEFJMCJFN URL builder in LINKIECPDML.txt:677-737).
|
||||
GET api/itemWishlists/v1/wishlist/me avatar-inventory
|
||||
GET api/itemWishlists/v1/wishlist/{0} avatar-inventory
|
||||
PUT api/itemWishlists/v1/wishlist/me/{0} avatar-inventory
|
||||
DELETE api/itemWishlists/v1/wishlist/me/{0} avatar-inventory
|
||||
GET api/roomEarningsDistributions/v1/earningsDistribution/{0} progression-misc
|
||||
GET api/incentivizedreferrals/referrals progression-misc
|
||||
# Contact lookup (OPMAPIOEIFG.txt:11590-11620) and composed paths (FFFIMAGLKEG.txt:137, NOGJJIIENIL.txt:29).
|
||||
POST account/bulk/email identity-account
|
||||
POST account/bulk/phone identity-account
|
||||
POST api/avatar/v2/gifts/consume avatar-inventory
|
||||
GET video/{0} config-telemetry
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 532 and column 73.
|
@@ -57,17 +57,7 @@ public class AccountApiController(
|
||||
{
|
||||
Success = true,
|
||||
Error = string.Empty,
|
||||
Account = new RecNetAccount
|
||||
{
|
||||
AccountId = (int)player.Id,
|
||||
RawUsername = player.Username,
|
||||
Username = player.Username,
|
||||
DisplayName = player.DisplayName ?? player.Username,
|
||||
ProfileImage = string.Empty,
|
||||
TreatAsJunior = false,
|
||||
HasBirthday = true,
|
||||
Platforms = 1,
|
||||
},
|
||||
Account = AccountDtos.Public(player),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -77,22 +67,7 @@ public class AccountApiController(
|
||||
{
|
||||
var player = await GetCurrentPlayerAsync();
|
||||
if (player is null) return Unauthorized();
|
||||
return Ok(new RecNetSelfAccount
|
||||
{
|
||||
AccountId = (int)player.Id,
|
||||
RawUsername = player.Username,
|
||||
Username = player.Username,
|
||||
DisplayName = player.DisplayName ?? player.Username,
|
||||
ProfileImage = player.ProfileImageName ?? string.Empty,
|
||||
TreatAsJunior = false,
|
||||
HasBirthday = true,
|
||||
Platforms = 1,
|
||||
Email = player.Email ?? string.Empty,
|
||||
Phone = string.Empty,
|
||||
Birthday = new DateTime(2000, 1, 1),
|
||||
JuniorState = 0,
|
||||
ParentAccountId = null,
|
||||
});
|
||||
return Ok(AccountDtos.Self(player));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -50,7 +50,18 @@ public class AvatarGiftsController(
|
||||
return Ok(rows.Select(ToWire));
|
||||
}
|
||||
|
||||
public sealed record GenerateGiftRequest(int? GiftContext, bool? IsGameGift, int? AlternateGiftContext, string? Message);
|
||||
/// <summary>Body keys are the client's AFGEDDANEKP param literals
|
||||
/// (GiftContext / IsGameGift / AlternateGiftContext / Message). A class
|
||||
/// with settable properties, not a positional record, because
|
||||
/// <see cref="Binding.FormOrJsonModelBinder"/> constructs it empty and
|
||||
/// fills it.</summary>
|
||||
public sealed class GenerateGiftRequest
|
||||
{
|
||||
public int? GiftContext { get; set; }
|
||||
public bool? IsGameGift { get; set; }
|
||||
public int? AlternateGiftContext { get; set; }
|
||||
public string? Message { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/avatar/v2/gifts/generate</c> — the end-of-
|
||||
/// activity gift box (chest). For a game/quest gift (IsGameGift) the
|
||||
@@ -64,8 +75,13 @@ public class AvatarGiftsController(
|
||||
// POST 404'd, breaking reward chests on that path). Same request body.
|
||||
[HttpPost("api/avatar/v2/gifts/generate")]
|
||||
[HttpPost("api/avatar/v3/gifts/generate")]
|
||||
// The client adds each field with AFGEDDANEKP (FFFIMAGLKEG.txt:1930-2013,
|
||||
// POST = ordinal 2), which goes out form-urlencoded: a live trace showed
|
||||
// `GiftContext=12000&IsGameGift=True&Message=…` answered 415 while this
|
||||
// was [FromBody]. The form-or-JSON binder accepts either encoding.
|
||||
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
|
||||
public async Task<IActionResult> Generate([FromBody] GenerateGiftRequest? req)
|
||||
public async Task<IActionResult> Generate(
|
||||
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] GenerateGiftRequest? req)
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var isGameGift = req?.IsGameGift ?? false;
|
||||
|
||||
@@ -4,16 +4,21 @@ using Microsoft.EntityFrameworkCore;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Controllers.API.Avatar.V2;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.Avatar.V3;
|
||||
|
||||
/// <summary>
|
||||
/// api.rec.net/api/avatar/v{2,3}/saved — saved-outfit slots. Wire
|
||||
/// type <c>RecNet.SavedOutfit</c> (per agent ISIL extraction):
|
||||
/// <c>Slot(int), PreviewImageName(string), OutfitSelections(string),
|
||||
/// FaceFeatures(string), SkinColor(string), HairColor(string)</c>.
|
||||
/// api.rec.net/api/avatar/v{2,3}/saved — saved-outfit slots. Client wire
|
||||
/// type BEPMJICDNDD, whose converter IMMOJHBENFI registers exactly
|
||||
/// <c>Slot, PreviewImageName, OutfitSelections, OutfitSelectionsV2,
|
||||
/// FaceFeatures, SkinColor, HairColor, CustomAvatarItems</c>
|
||||
/// (RecNet.Runtime/IMMOJHBENFI.txt:587-766 reader, :781-910 writer).
|
||||
/// CustomAvatarItems is a list of BGNNOMBFMLH
|
||||
/// <c>{CustomAvatarItemId, BodyPart}</c>, the same element the avatar
|
||||
/// settings (<c>api/avatar/v2</c>) use.
|
||||
///
|
||||
/// State persists in <see cref="AvatarEntity.SavedOutfitsJson"/> as
|
||||
/// a JSON list of <see cref="SavedOutfitDto"/> records keyed by
|
||||
@@ -38,12 +43,27 @@ public class AvatarSavedOutfitsController(DorkNetDbContext db) : ControllerBase
|
||||
}
|
||||
|
||||
/// <summary>POST <c>v3/saved/set</c> — upsert one outfit by Slot.
|
||||
/// Body is the SavedOutfit shape directly.</summary>
|
||||
/// Verb ordinal 2 = POST with a raw JSON body of one serialised
|
||||
/// BEPMJICDNDD (RecNet.Runtime/FFFIMAGLKEG.txt:4071-4095, NCILAADFJMM).
|
||||
///
|
||||
/// Parsed by hand rather than <c>[FromBody] SavedOutfitDto</c>: the old
|
||||
/// DTO silently dropped OutfitSelectionsV2 and CustomAvatarItems, and the
|
||||
/// 2023 client wears OutfitSelectionsV2 whenever it is non-empty
|
||||
/// (BEPMJICDNDD.NLGCNDMLACA, BEPMJICDNDD.txt:237-266, falls back to
|
||||
/// OutfitSelections only when V2 is empty). Loading a saved slot
|
||||
/// therefore restored a stale V1 selection (or nothing) and lost every
|
||||
/// custom shirt in it.</summary>
|
||||
[HttpPost("api/avatar/v3/saved/set")]
|
||||
[HttpPost("api/avatar/v2/saved/set")]
|
||||
public async Task<IActionResult> Set([FromBody] SavedOutfitDto body)
|
||||
public async Task<IActionResult> Set()
|
||||
{
|
||||
string raw;
|
||||
using (var reader = new StreamReader(Request.Body))
|
||||
raw = await reader.ReadToEndAsync();
|
||||
|
||||
var body = ParseOutfit(raw);
|
||||
if (body is null) return BadRequest("missing body");
|
||||
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var avatar = await db.Avatars.FirstOrDefaultAsync(a => a.PlayerId == pid);
|
||||
if (avatar is null)
|
||||
@@ -59,6 +79,7 @@ public class AvatarSavedOutfitsController(DorkNetDbContext db) : ControllerBase
|
||||
if (list.Count >= MaxSlots) return BadRequest($"max {MaxSlots} saved outfits per player");
|
||||
list.Add(body);
|
||||
}
|
||||
list.Sort((a, b) => a.Slot.CompareTo(b.Slot));
|
||||
avatar.SavedOutfitsJson = JsonSerializer.Serialize(list);
|
||||
avatar.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
@@ -81,12 +102,91 @@ public class AvatarSavedOutfitsController(DorkNetDbContext db) : ControllerBase
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>Read one outfit object, matching keys case-insensitively
|
||||
/// (the client reader accepts Pascal/camel/lower spellings, so any of
|
||||
/// them is a valid write). String fields tolerate null; a nested
|
||||
/// object/array is kept as its raw JSON text, mirroring how
|
||||
/// <c>avatar/v2/set</c> treats the same fields. Returns null for a body
|
||||
/// that is not a JSON object.</summary>
|
||||
internal static SavedOutfitDto? ParseOutfit(string? raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw)) return null;
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(raw);
|
||||
var root = doc.RootElement;
|
||||
if (root.ValueKind != JsonValueKind.Object) return null;
|
||||
|
||||
var dto = new SavedOutfitDto();
|
||||
foreach (var prop in root.EnumerateObject())
|
||||
{
|
||||
switch (prop.Name.ToLowerInvariant())
|
||||
{
|
||||
case "slot":
|
||||
if (prop.Value.ValueKind == JsonValueKind.Number && prop.Value.TryGetInt32(out var slot)) dto.Slot = slot;
|
||||
else if (prop.Value.ValueKind == JsonValueKind.String && int.TryParse(prop.Value.GetString(), out slot)) dto.Slot = slot;
|
||||
break;
|
||||
case "previewimagename": dto.PreviewImageName = AsString(prop.Value); break;
|
||||
case "outfitselections": dto.OutfitSelections = AsString(prop.Value); break;
|
||||
case "outfitselectionsv2": dto.OutfitSelectionsV2 = AsString(prop.Value); break;
|
||||
case "facefeatures": dto.FaceFeatures = AsString(prop.Value); break;
|
||||
case "skincolor": dto.SkinColor = AsString(prop.Value); break;
|
||||
case "haircolor": dto.HairColor = AsString(prop.Value); break;
|
||||
case "customavataritems": dto.CustomAvatarItems = AsCustomItems(prop.Value); break;
|
||||
}
|
||||
}
|
||||
return dto;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string AsString(JsonElement v) => v.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => v.GetString() ?? string.Empty,
|
||||
JsonValueKind.Null or JsonValueKind.Undefined => string.Empty,
|
||||
_ => v.GetRawText(),
|
||||
};
|
||||
|
||||
/// <summary>BGNNOMBFMLH list. Elements without a parseable guid are
|
||||
/// dropped instead of failing the whole save.</summary>
|
||||
private static List<CustomAvatarItemRefDto> AsCustomItems(JsonElement v)
|
||||
{
|
||||
var result = new List<CustomAvatarItemRefDto>();
|
||||
if (v.ValueKind != JsonValueKind.Array) return result;
|
||||
foreach (var el in v.EnumerateArray())
|
||||
{
|
||||
if (el.ValueKind != JsonValueKind.Object) continue;
|
||||
Guid? id = null;
|
||||
byte bodyPart = 0;
|
||||
foreach (var p in el.EnumerateObject())
|
||||
{
|
||||
if (p.Name.Equals("CustomAvatarItemId", StringComparison.OrdinalIgnoreCase)
|
||||
&& p.Value.ValueKind == JsonValueKind.String
|
||||
&& Guid.TryParse(p.Value.GetString(), out var g))
|
||||
id = g;
|
||||
else if (p.Name.Equals("BodyPart", StringComparison.OrdinalIgnoreCase)
|
||||
&& p.Value.ValueKind == JsonValueKind.Number
|
||||
&& p.Value.TryGetByte(out var b))
|
||||
bodyPart = b;
|
||||
}
|
||||
if (id is { } guid) result.Add(new CustomAvatarItemRefDto { CustomAvatarItemId = guid, BodyPart = bodyPart });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static List<SavedOutfitDto> ParseList(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json)) return new();
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<List<SavedOutfitDto>>(json) ?? new();
|
||||
var list = JsonSerializer.Deserialize<List<SavedOutfitDto>>(json) ?? new();
|
||||
// Rows written before CustomAvatarItems existed carry no key;
|
||||
// keep the wire array non-null.
|
||||
foreach (var o in list) o.CustomAvatarItems ??= new();
|
||||
return list;
|
||||
}
|
||||
catch { return new(); }
|
||||
}
|
||||
@@ -97,7 +197,14 @@ public sealed class SavedOutfitDto
|
||||
[JsonPropertyName("Slot")] public int Slot { get; set; }
|
||||
[JsonPropertyName("PreviewImageName")] public string PreviewImageName { get; set; } = string.Empty;
|
||||
[JsonPropertyName("OutfitSelections")] public string OutfitSelections { get; set; } = string.Empty;
|
||||
/// <summary>The selection the 2023 client actually wears when non-empty
|
||||
/// (BEPMJICDNDD.NLGCNDMLACA). Rows saved before this field existed
|
||||
/// deserialize to "" and the client falls back to OutfitSelections.</summary>
|
||||
[JsonPropertyName("OutfitSelectionsV2")] public string OutfitSelectionsV2 { get; set; } = string.Empty;
|
||||
[JsonPropertyName("FaceFeatures")] public string FaceFeatures { get; set; } = string.Empty;
|
||||
[JsonPropertyName("SkinColor")] public string SkinColor { get; set; } = string.Empty;
|
||||
[JsonPropertyName("HairColor")] public string HairColor { get; set; } = string.Empty;
|
||||
/// <summary>Custom shirts worn in this slot. Always an array on the wire
|
||||
/// (the client's ctor allocates an empty list, BEPMJICDNDD.txt:321-360).</summary>
|
||||
[JsonPropertyName("CustomAvatarItems")] public List<CustomAvatarItemRefDto> CustomAvatarItems { get; set; } = new();
|
||||
}
|
||||
|
||||
@@ -173,7 +173,12 @@ public class AvatarItemsController(
|
||||
{
|
||||
AvatarItemType = item.AvatarItemType,
|
||||
AvatarItemDesc = ToWatchDesc(entry.FullDesc),
|
||||
AvatarItemId = item.AvatarItemId,
|
||||
// Hash the FULL desc, as the toggle-on path does: a
|
||||
// player can own the base item and several colorways of
|
||||
// it at once (entries dedupe by FullDesc), and reusing the
|
||||
// base id made them collide in the client's id-keyed
|
||||
// wardrobe.
|
||||
AvatarItemId = StableAvatarItemIdFull(ToWatchDesc(entry.FullDesc)),
|
||||
PlatformMask = item.PlatformMask,
|
||||
FriendlyName = item.FriendlyName,
|
||||
Tooltip = item.Tooltip,
|
||||
@@ -328,20 +333,19 @@ public class AvatarItemsController(
|
||||
owned.Add(guid);
|
||||
}
|
||||
|
||||
// The client parses this as List<IFNGBPJGINL> — the SAME element as
|
||||
// v4/items (FFFIMAGLKEG.txt:3222-3465, NBLIDNCEELM), read by
|
||||
// CLIANCNHNIE which registers TagList / AvatarItemId /
|
||||
// IsBaseAvatarItem too (CLIANCNHNIE.txt). The old 5-key subset left
|
||||
// every locked row at AvatarItemId 0 with a null TagList, so ship
|
||||
// the full catalog DTO.
|
||||
var catalog = _catalog.Value.Items;
|
||||
var rows = requested
|
||||
.Where(guid => !owned.Contains(guid))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.Select(guid => catalog.TryGetValue(guid, out var item) ? item : null)
|
||||
.Where(item => item is not null)
|
||||
.Select(item => new
|
||||
{
|
||||
item!.AvatarItemType,
|
||||
item.AvatarItemDesc,
|
||||
item.FriendlyName,
|
||||
item.Tooltip,
|
||||
item.Rarity,
|
||||
})
|
||||
.Select(item => item!)
|
||||
.ToArray();
|
||||
return Ok(rows);
|
||||
}
|
||||
@@ -561,9 +565,8 @@ public class AvatarItemsController(
|
||||
/// <summary>Stable, deterministic, non-zero 31-bit id for an owned
|
||||
/// avatar item, derived from its base GUID (FNV-1a). The 2023 client
|
||||
/// keys the wardrobe by <c>AvatarItemId</c>, so it must be unique per
|
||||
/// item and stable across restarts. Colored variants reuse their base
|
||||
/// item's id (a player owns the base OR a variant, never both in the
|
||||
/// list), so no collision.</summary>
|
||||
/// item and stable across restarts. Colored variants must NOT use this — a player can own the base and
|
||||
/// several colorways together; see <see cref="StableAvatarItemIdFull"/>.</summary>
|
||||
public static int StableAvatarItemId(string? avatarItemDescOrGuid)
|
||||
{
|
||||
var guid = (avatarItemDescOrGuid ?? string.Empty).Split(',')[0];
|
||||
|
||||
@@ -268,8 +268,11 @@ public class CommentsController(DorkNetDbContext db) : ControllerBase
|
||||
/// <see cref="UnreadCounts"/> return non-zero numbers for everyone else).
|
||||
///
|
||||
/// <c>OPELMBNJHNO.MCOIDIBFHIP(Int64 roomId, Int64 commentId)</c>
|
||||
/// (OPELMBNJHNO.txt:1375) returns the status-only <c>LDGADANDBIO</c>, not a
|
||||
/// DTO — the body is never parsed, only the status code. Verb is <b>PUT</b>
|
||||
/// (OPELMBNJHNO.txt:1375) returns the untyped <c>LDGADANDBIO</c> promise, but
|
||||
/// it is built from the body parsed as a RecNetResult: the continuation
|
||||
/// <c><UpdateLatestReadComment>b__9_1(DJMHAFPGLLN)</c>
|
||||
/// (OPELMBNJHNO_NestedType___c.txt:333) null-derefs an empty body and
|
||||
/// rejects on <c>Success</c>=false — see <see cref="RecNetResults"/>. Verb is <b>PUT</b>
|
||||
/// (rdx=3 at instr 059, OPELMBNJHNO.txt:1637); both ids are path segments
|
||||
/// formatted into "comments/read/{0}/{1}" (:1627) and no field is added, so
|
||||
/// the request carries no body at all.
|
||||
@@ -280,7 +283,7 @@ public class CommentsController(DorkNetDbContext db) : ControllerBase
|
||||
{
|
||||
await SetLastReadAsync(Me, roomId, commentId);
|
||||
await db.SaveChangesAsync();
|
||||
return Ok();
|
||||
return RecNetResults.Ok();
|
||||
}
|
||||
|
||||
/// <summary>Deletes a comment.
|
||||
|
||||
@@ -129,14 +129,19 @@ public class ConfigController(ConfigService configService) : ControllerBase
|
||||
/// Returns a broad default set so the picker is populated.</summary>
|
||||
[HttpGet("/emojiConfig/whitelistedEmojis")]
|
||||
[Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||
public IActionResult WhitelistedEmojis() => Ok(new[]
|
||||
public IActionResult WhitelistedEmojis() => Ok(WhitelistedEmojiList);
|
||||
|
||||
/// <summary>The emoji allow-list served above. Also the validation set
|
||||
/// for <c>PUT account/me/emoji</c>, so a player can only pick an emoji
|
||||
/// the client's own picker offered.</summary>
|
||||
public static readonly IReadOnlyList<string> WhitelistedEmojiList = new[]
|
||||
{
|
||||
"😀", "😁", "😂", "🤣", "😊", "😇", "🙂", "😉", "😍", "😘",
|
||||
"😜", "🤪", "😎", "🤩", "🥳", "😏", "😢", "😭", "😡", "🤯",
|
||||
"😱", "😴", "🤔", "🤗", "🙄", "😬", "👍", "👎", "👏", "🙌",
|
||||
"🙏", "💪", "✌️", "🤞", "👋", "🤝", "❤️", "🔥", "⭐", "✨",
|
||||
"🎉", "🎊", "💯", "😈", "👻", "🤖", "🎮", "🏆",
|
||||
});
|
||||
};
|
||||
|
||||
/// <summary>GET <c>api/config/v1/freegiftbutton</c> — the
|
||||
/// 2020.12 client deserializes this endpoint as a raw Boolean.
|
||||
|
||||
@@ -371,11 +371,22 @@ public class CustomAvatarItemsController(
|
||||
var allowed = player is not null
|
||||
&& !player.IsJunior
|
||||
&& (player.BannedUntil is null || player.BannedUntil <= DateTime.UtcNow);
|
||||
// Bare JSON boolean, like the two sibling gates below: the client's
|
||||
// issuing method is FGLDKEJLAKB<System.Boolean> GGMDLMLJDDK()
|
||||
// (RecNet.Runtime/MPBLNLMCEDL.txt:6048, route literal on :6205), so an
|
||||
// object body threw and the can-create gate blocked shirt creation.
|
||||
return Content(allowed ? "true" : "false", "application/json");
|
||||
// NOT a bare boolean, unlike the two sibling gates below. The issuing
|
||||
// method GGMDLMLJDDK (RecNet.Runtime/MPBLNLMCEDL.txt:6048, route on
|
||||
// :6205) parses the body as DJMHAFPGLLN — {Boolean @0x10, String} —
|
||||
// (generic token 0x188477F70 at :6216; Func<DJMHAFPGLLN,…> at :6223)
|
||||
// and its success continuation HONEEPBHNLB reads the bool at +0x10
|
||||
// (:9155-9216).
|
||||
// DJMHAFPGLLN's converter LMCCNLLHLCJ registers exactly the keys
|
||||
// "Success" and "Error" (LMCCNLLHLCJ.txt:191-254). A bare `true`
|
||||
// failed that parse, so the client logged "Failed to query if local
|
||||
// account can create Custom Avatar Items: An error occurred" and
|
||||
// treated the account as unable to create shirts.
|
||||
return Ok(new Dictionary<string, object?>
|
||||
{
|
||||
["Success"] = allowed,
|
||||
["Error"] = allowed ? null : "Custom avatar item creation is not available for this account.",
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("isCreationEnabled")]
|
||||
@@ -386,22 +397,17 @@ public class CustomAvatarItemsController(
|
||||
[AllowAnonymous]
|
||||
public IActionResult IsRenderingEnabled() => Content("true", "application/json");
|
||||
|
||||
/// <summary>The client DTO (IHLMDLKOLNB) is an object with exactly one
|
||||
/// Int32 property, but its accessor name (GFGCCNBDKAK) appears nowhere
|
||||
/// else in the dump, so — unlike Price/BaseAvatarItemId/Color, which are
|
||||
/// pinned by accessors shared with NOAAFNFJPFB — the JSON key is NOT
|
||||
/// recoverable. All four spellings below are UNPROVEN hedges; Newtonsoft
|
||||
/// ignores the ones that don't match, and a total miss falls back to the
|
||||
/// DTO's static default (IHLMDLKOLNB.txt:75-117) rather than throwing.</summary>
|
||||
/// <summary>Bare JSON integer. The client's IHLMDLKOLNB pricing config is
|
||||
/// built CLIENT-side: HCBOHHEACPK sends through FDKKOPAPDGF<Int32>
|
||||
/// (RecNet.Runtime/MPBLNLMCEDL.txt:6922 — the same generic token
|
||||
/// 0x18847B0B8 as the bare-Int32 itemOwnershipLimit call at :2981) and
|
||||
/// continues with <c><GetPricingConfig>b__69_0(Int32 minimumPrice)</c>
|
||||
/// (MPBLNLMCEDL_NestedType___c.txt:286). An object body failed the Int32
|
||||
/// parse, so the designer's public-price floor never loaded.</summary>
|
||||
[HttpGet("minPriceForPublicItem")]
|
||||
[AllowAnonymous]
|
||||
public IActionResult MinPriceForPublicItem() => Ok(new
|
||||
{
|
||||
MinPrice = MinPublicItemPrice,
|
||||
Price = MinPublicItemPrice,
|
||||
MinimumPrice = MinPublicItemPrice,
|
||||
MinPriceForPublicItem = MinPublicItemPrice,
|
||||
});
|
||||
public IActionResult MinPriceForPublicItem()
|
||||
=> Content(MinPublicItemPrice.ToString(System.Globalization.CultureInfo.InvariantCulture), "application/json");
|
||||
|
||||
/// <summary>GET api/customAvatarItems/v1/design — hand back the caller's
|
||||
/// saved in-progress shirt design. Verb ordinal 0 = GET
|
||||
|
||||
+52
@@ -34,6 +34,58 @@ public class IncentivizedReferralsController(DorkNetDbContext db, LevelService l
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>The caller's referred players, paged.
|
||||
///
|
||||
/// <c>FGLDKEJLAKB<GPJIMAHMAIO> EGEEFJHAGAM(Int32? take, String continuationToken)</c>
|
||||
/// (RecNet.Runtime/LINKIECPDML.txt:337) builds this through the URL builder
|
||||
/// LPPEFJMCJFN ("api/incentivizedreferrals/" + "referrals", :677-701, with
|
||||
/// <c>take</c> and <c>continuationToken</c> query params at :716/:737) — which
|
||||
/// is why the plain route-literal sweep never saw it — and issues a GET
|
||||
/// (:164-170). Reply formatter KOEEACLMAFE.txt:203-238:
|
||||
/// <c>{Referrals: [...], ContinuationToken: string}</c>; each entry
|
||||
/// (ADAFNGNADFH.txt:279-346) is <c>{InviteeAccountId: Int32, CreatedAt:
|
||||
/// DateTime, VerifiedAt: DateTime?}</c>.
|
||||
///
|
||||
/// A referral is recorded when a brand-new player redeems the caller's
|
||||
/// referral code (LinkController.CreditReferralAsync), and that redemption
|
||||
/// is what counts it, so it is verified at the moment it is created. The
|
||||
/// continuation token is the next offset; null once the list is exhausted.</summary>
|
||||
[HttpGet("referrals")]
|
||||
public async Task<IActionResult> Referrals([FromQuery] int? take, [FromQuery] string? continuationToken)
|
||||
{
|
||||
var size = Math.Clamp(take ?? 20, 1, 100);
|
||||
var offset = int.TryParse(continuationToken, out var parsed) && parsed > 0 ? parsed : 0;
|
||||
var prefix = $"referral:credited:{Me}:";
|
||||
|
||||
var rows = await db.PlayerSettings
|
||||
.Where(s => s.Key.StartsWith(prefix))
|
||||
.OrderByDescending(s => s.Id)
|
||||
.Skip(offset)
|
||||
.Take(size + 1)
|
||||
.Select(s => new { s.PlayerId, s.Value })
|
||||
.ToListAsync();
|
||||
|
||||
var page = rows.Take(size).Select(r =>
|
||||
{
|
||||
var at = DateTime.TryParse(r.Value, null,
|
||||
System.Globalization.DateTimeStyles.RoundtripKind, out var credited)
|
||||
? credited
|
||||
: DateTime.UnixEpoch;
|
||||
return new
|
||||
{
|
||||
InviteeAccountId = (int)r.PlayerId,
|
||||
CreatedAt = at,
|
||||
VerifiedAt = (DateTime?)at,
|
||||
};
|
||||
}).ToList();
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
Referrals = page,
|
||||
ContinuationToken = rows.Count > size ? (offset + size).ToString() : null,
|
||||
});
|
||||
}
|
||||
|
||||
// Client contract: the claim response deserializes as a top-level
|
||||
// List<RewardSelection> where each entry is
|
||||
// { rewardSelectionId: long, rewardType: string } (RecNet.Runtime
|
||||
|
||||
@@ -208,8 +208,10 @@ public class KeepsakesController(DorkNetDbContext db, DomainConfig domain) : Con
|
||||
/// <c>DELETE api/keepsakes/{guid}</c> - <c>NCCLEJPIABA.JBKENCNIEPA(System.Guid)</c> builds
|
||||
/// <c>String.Format("{0}/{1}", "api/keepsakes", guid)</c> and passes verb 4 (DELETE) to the
|
||||
/// BNDIAONDFFF ctor (NCCLEJPIABA.txt:1544, 1687-1698 - <c>Move rdx, 4</c>). Its return type is
|
||||
/// the non-generic <c>LDGADANDBIO</c> promise, so no response body is deserialised; a bare 200
|
||||
/// is enough. Client-side the tool refuses unless the caller is at least a room co-owner
|
||||
/// the non-generic <c>LDGADANDBIO</c> promise, but that promise comes from
|
||||
/// <c>BFDGEKKNAPB.PEFKBAEAOPD</c> (ExpandRecNetResult) — the body IS parsed, as a
|
||||
/// <c>{success,error}</c> RecNetResult, and an empty 200 null-derefs into a failed delete.
|
||||
/// See <see cref="RecNetResults"/>. Client-side the tool refuses unless the caller is at least a room co-owner
|
||||
/// ("Must be at least a room co-owner to modify keepsakes.",
|
||||
/// RecRoom.Keepsakes.Runtime/PDFJLLECNBE.txt:3363), which we re-check server-side.
|
||||
/// </summary>
|
||||
@@ -235,7 +237,7 @@ public class KeepsakesController(DorkNetDbContext db, DomainConfig domain) : Con
|
||||
db.Keepsakes.RemoveRange(orphanedCollections
|
||||
.Where(k => TryParseCollectionKey(k.EventKey, out _, out var id) && id == keepsakeInstanceId));
|
||||
await db.SaveChangesAsync();
|
||||
return Ok();
|
||||
return RecNetResults.Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1145,10 +1145,17 @@ public class PlayerEventsController(
|
||||
extras.SupportMultiInstanceRoomChat =
|
||||
await ReadBoolFieldAsync("supportsMultiInstanceRoomChat", "supportMultiInstanceRoomChat",
|
||||
"SupportMultiInstanceRoomChat") ?? false;
|
||||
extras.DefaultBroadcastPermissions =
|
||||
await ReadIntFieldAsync("defaultBroadcastPermissions", "DefaultBroadcastPermissions") ?? 0;
|
||||
extras.CanRequestBroadcastPermissions =
|
||||
await ReadIntFieldAsync("canRequestBroadcastPermissions", "CanRequestBroadcastPermissions") ?? 0;
|
||||
// These two are boxed enums (typeof(AKFCIMLMAKA) at
|
||||
// CBKANFIOBCF_NestedType_EONGJBMLEFO.txt:117-165), and a boxed non-string
|
||||
// field goes through Object.ToString (BNDIAONDFFF.txt:947, ISIL 107-110),
|
||||
// so they arrive as member NAMES. int.TryParse failed on every one and
|
||||
// the settings were always stored as 0.
|
||||
extras.DefaultBroadcastPermissions = ParseBroadcastPermissions(
|
||||
await ReadStringFieldAsync("defaultBroadcastPermissions", "DefaultBroadcastPermissions"))
|
||||
?? extras.DefaultBroadcastPermissions;
|
||||
extras.CanRequestBroadcastPermissions = ParseBroadcastPermissions(
|
||||
await ReadStringFieldAsync("canRequestBroadcastPermissions", "CanRequestBroadcastPermissions"))
|
||||
?? extras.CanRequestBroadcastPermissions;
|
||||
|
||||
await SaveExtrasAsync(evt, extras);
|
||||
return Ok(await WrapperAsync(evt));
|
||||
@@ -1437,6 +1444,33 @@ public class PlayerEventsController(
|
||||
: null;
|
||||
}
|
||||
|
||||
/// <summary>Broadcast-permission mode as a number. Members per the
|
||||
/// 2023.06.21 dump (enum KJJFEBEMPKM, mapped by
|
||||
/// EventsConfig.BroadcastPermissionsModeFriendlyNameMapping): None = 0,
|
||||
/// RoomOwners = 256, All = Int32.MaxValue. Accepts the number itself (JSON
|
||||
/// bodies send numbers), a member name, or a comma-joined flags string.
|
||||
/// Null when the value is absent or unrecognised, so the stored setting is
|
||||
/// kept rather than silently zeroed.</summary>
|
||||
public static int? ParseBroadcastPermissions(string? raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw)) return null;
|
||||
if (int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n)) return n;
|
||||
var total = 0;
|
||||
foreach (var part in raw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
int? value = part.ToLowerInvariant() switch
|
||||
{
|
||||
"none" => 0,
|
||||
"roomowners" => 256,
|
||||
"all" => int.MaxValue,
|
||||
_ => null,
|
||||
};
|
||||
if (value is null) return null;
|
||||
total |= value.Value;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
private async Task<int?> ReadIntFieldAsync(params string[] names)
|
||||
{
|
||||
var value = await ReadStringFieldAsync(names);
|
||||
|
||||
@@ -48,14 +48,18 @@ public class PlayerWarningsController(DorkNetDbContext db) : ControllerBase
|
||||
.Where(p => p.Id == me)
|
||||
.Select(p => (bool?)(p.IsAdmin || p.IsDeveloper || p.IsCommunityTeam))
|
||||
.FirstOrDefaultAsync();
|
||||
// Both calls are read as DJMHAFPGLLN = {Success, Error} (the formatter,
|
||||
// RecNet.Runtime/LMCCNLLHLCJ.txt:191-218, knows no "Message" key). A
|
||||
// failure without Error reached Promise.Rejected(null) and threw
|
||||
// "Error message cannot be null" instead of showing the reason.
|
||||
if (moderator != true)
|
||||
return Ok(new { Success = false, Message = "not_authorized" });
|
||||
return Ok(new { Success = false, Error = "not_authorized", Message = "not_authorized" });
|
||||
|
||||
var target = warnedPlayerId
|
||||
?? (long.TryParse(Request.Query["WarnedPlayerId"].FirstOrDefault(), out var q) ? q : 0);
|
||||
if (target <= 0) return Ok(new { Success = false, Message = "missing_player" });
|
||||
if (target <= 0) return Ok(new { Success = false, Error = "missing_player", Message = "missing_player" });
|
||||
if (!await db.Players.AnyAsync(p => p.Id == target))
|
||||
return Ok(new { Success = false, Message = "player_not_found" });
|
||||
return Ok(new { Success = false, Error = "player_not_found", Message = "player_not_found" });
|
||||
|
||||
// Same "warning:{id}|message|category|createdAt|acknowledged" layout the
|
||||
// GET list handler parses in ToWarningWire.
|
||||
@@ -75,7 +79,7 @@ public class PlayerWarningsController(DorkNetDbContext db) : ControllerBase
|
||||
"false"),
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { Success = true, Message = string.Empty, WarningId = id });
|
||||
return Ok(new { Success = true, Error = string.Empty, Message = string.Empty, WarningId = id });
|
||||
}
|
||||
|
||||
/// <summary>The 2023-03-21 client's acknowledge call takes NO arguments —
|
||||
@@ -104,19 +108,19 @@ public class PlayerWarningsController(DorkNetDbContext db) : ControllerBase
|
||||
.OrderByDescending(s => s.Id)
|
||||
.ToListAsync();
|
||||
row = rows.FirstOrDefault(s => !ToWarningWire(s).Acknowledged);
|
||||
if (row is null) return Ok(new { Success = true, Message = string.Empty });
|
||||
if (row is null) return Ok(new { Success = true, Error = string.Empty, Message = string.Empty });
|
||||
}
|
||||
else
|
||||
{
|
||||
row = await db.PlayerSettings
|
||||
.FirstOrDefaultAsync(s => s.PlayerId == me && s.Key == $"warning:{id}");
|
||||
if (row is null) return Ok(new { Success = true, Message = string.Empty });
|
||||
if (row is null) return Ok(new { Success = true, Error = string.Empty, Message = string.Empty });
|
||||
}
|
||||
|
||||
var parts = row.Value.Split('|');
|
||||
row.Value = string.Join('|', parts.Take(3).Append("true"));
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { Success = true, Message = string.Empty, WarningId = row.Key["warning:".Length..] });
|
||||
return Ok(new { Success = true, Error = string.Empty, Message = string.Empty, WarningId = row.Key["warning:".Length..] });
|
||||
}
|
||||
|
||||
private static WarningWire ToWarningWire(DorkNet.Server.Data.Entities.PlayerSettingEntity row)
|
||||
|
||||
@@ -284,7 +284,11 @@ public class RoomCurrenciesController(DorkNetDbContext db, LevelService level) :
|
||||
offer.IsDeleted = true;
|
||||
offer.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(ToOfferWire(offer, currency));
|
||||
// MIHKAJFMIPE.OHCONDGELJB hands this body to BFDGEKKNAPB.PEFKBAEAOPD
|
||||
// (ExpandRecNetResult): it is a {success,error} RecNetResult, not the
|
||||
// offer. The offer wire has no Success key, so it parsed as a failure
|
||||
// with a null Error and the delete toast threw instead of closing.
|
||||
return RecNetResults.Ok();
|
||||
}
|
||||
|
||||
/// <summary>Both verbs are mapped on purpose: the client picks GET or POST at
|
||||
|
||||
@@ -177,7 +177,10 @@ public class SubscriptionsController(
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
return Ok();
|
||||
// The client reads this body as DJMHAFPGLLN, the {success,error}
|
||||
// RecNetResult (FGLDKEJLAKB<DJMHAFPGLLN> at IKMMOCKDKAF.txt LPFOPPHGDHG,
|
||||
// instr 098) — answer in that shape rather than an empty 200.
|
||||
return RecNetResults.Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -61,7 +61,7 @@ public class AccountsController(
|
||||
{
|
||||
var player = await GetCurrentPlayerAsync();
|
||||
if (player is null) return Unauthorized();
|
||||
return Ok(BuildSelfAccount(player.Id, player.Username, player.DisplayName, player.ProfileImageName));
|
||||
return Ok(AccountDtos.Self(player));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -194,7 +194,7 @@ public class AccountsController(
|
||||
{
|
||||
var player = await playerService.GetByIdAsync(accountId);
|
||||
if (player is null) return NotFound();
|
||||
return Ok(BuildAccount(player.Id, player.Username, player.DisplayName, player.ProfileImageName));
|
||||
return Ok(AccountDtos.Public(player));
|
||||
}
|
||||
|
||||
// Accounts.GetAccountByUsername
|
||||
@@ -203,7 +203,7 @@ public class AccountsController(
|
||||
{
|
||||
var player = await playerService.GetByUsernameAsync(username);
|
||||
if (player is null) return NotFound();
|
||||
return Ok(BuildAccount(player.Id, player.Username, player.DisplayName, player.ProfileImageName));
|
||||
return Ok(AccountDtos.Public(player));
|
||||
}
|
||||
|
||||
// Accounts.CreateAccount — actual URL observed in server logs:
|
||||
@@ -263,7 +263,7 @@ public class AccountsController(
|
||||
{
|
||||
Success = true,
|
||||
Error = string.Empty,
|
||||
Account = BuildAccount(player.Id, player.Username, player.DisplayName, player.ProfileImageName),
|
||||
Account = AccountDtos.Public(player),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -286,7 +286,7 @@ public class AccountsController(
|
||||
var clamped = Math.Clamp(take, 1, 50);
|
||||
var matches = await playerService.SearchAsync(term.Trim(), clamped);
|
||||
return Ok(matches
|
||||
.Select(p => BuildAccount(p.Id, p.Username, p.DisplayName, p.ProfileImageName))
|
||||
.Select(p => AccountDtos.Public(p))
|
||||
.ToList());
|
||||
}
|
||||
|
||||
@@ -302,6 +302,12 @@ public class AccountsController(
|
||||
[HttpPost("/account/v1/emails")]
|
||||
[HttpPost("/account/v1/phones")]
|
||||
[HttpPost("/account/v1/byplatformid")]
|
||||
// 2023 contact lookup: OPMAPIOEIFG.HHAAAPKCLMN (RecNet.Runtime/OPMAPIOEIFG.txt:11864)
|
||||
// POSTs "account/bulk/" + "email" or "phone" (:11590-11620, verb 2) with the
|
||||
// values as repeated form fields of the same name, and reads the same account
|
||||
// list (List<BLOJAFNJPJI>) as account/bulk. Both suffixes 404'd.
|
||||
[HttpPost("/account/bulk/email")]
|
||||
[HttpPost("/account/bulk/phone")]
|
||||
public async Task<IActionResult> Bulk()
|
||||
{
|
||||
// The watch calls this route family with payloads keyed by
|
||||
@@ -401,7 +407,7 @@ public class AccountsController(
|
||||
{
|
||||
var p = await playerService.GetByIdAsync(id);
|
||||
if (p is not null)
|
||||
accounts.Add(BuildAccount(p.Id, p.Username, p.DisplayName, p.ProfileImageName));
|
||||
accounts.Add(AccountDtos.Public(p));
|
||||
}
|
||||
return Ok(accounts);
|
||||
}
|
||||
@@ -433,8 +439,11 @@ public class AccountsController(
|
||||
}
|
||||
|
||||
// Phone-confirm is the one mutation we don't persist — there's no
|
||||
// SMS provider in the loop. Acknowledge silently.
|
||||
// SMS provider in the loop. Acknowledge silently. The 2023 client
|
||||
// composes the route as "account/me/" + "confirmphone" with POST
|
||||
// (OPMAPIOEIFG.OBAGJBBIFGB) and reads the RecNetResult body.
|
||||
[HttpPost("/account/v1/confirmphone")]
|
||||
[HttpPost("/account/me/confirmphone")]
|
||||
public IActionResult ConfirmPhone() =>
|
||||
Ok(new RecNetResult { Success = true, Error = string.Empty });
|
||||
|
||||
@@ -629,6 +638,126 @@ public class AccountsController(
|
||||
return Ok(new { success = true, error = "" });
|
||||
}
|
||||
|
||||
// ── 2023 profile edits ────────────────────────────────────────────
|
||||
// All four go through OPMAPIOEIFG.OBAGJBBIFGB, which builds
|
||||
// "account/me/" + suffix (RecNet.Runtime OPMAPIOEIFG.txt:8579) and
|
||||
// reports "Failed to modify <suffix>" on any non-2xx (:8609). Every call
|
||||
// site passes verb 3 = PUT. The form field name is NOT the suffix — each
|
||||
// comes from the lambda that fills the request:
|
||||
// emoji → "displayEmoji" (OPMAPIOEIFG_NestedType_KPGNAEPGLAC.txt:57)
|
||||
// personalpronouns → "pronounFlags" (…_LCCEHLBIOPK.txt:67, boxed Int32)
|
||||
// identityflags → "identityFlags" (…_HHDFMJPLFHG.txt:67, boxed Int32)
|
||||
// bannerimage → "imageName" (…_NJKHNGEPODH.txt:57)
|
||||
// The response body is never parsed (LDGADANDBIO handle), so the new
|
||||
// value reaches the editor's UI and other players only through the
|
||||
// AccountUpdate / SelfAccountUpdate push in NotificationService.
|
||||
// None of these routes existed before, so every edit 404'd.
|
||||
|
||||
/// <summary>PUT <c>/account/me/emoji</c> — form <c>displayEmoji</c>.
|
||||
/// Empty clears the emoji; anything else must be on the
|
||||
/// <c>emojiConfig/whitelistedEmojis</c> list the picker was filled
|
||||
/// from, and is stored in that list's canonical spelling.</summary>
|
||||
[HttpPut("/account/me/emoji")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
|
||||
public async Task<IActionResult> ChangeEmoji([FromForm(Name = "displayEmoji")] string? displayEmoji)
|
||||
{
|
||||
var player = await GetCurrentPlayerAsync();
|
||||
if (player is null) return Unauthorized();
|
||||
|
||||
var submitted = displayEmoji?.Trim() ?? string.Empty;
|
||||
string stored;
|
||||
if (submitted.Length == 0)
|
||||
{
|
||||
stored = string.Empty;
|
||||
}
|
||||
else
|
||||
{
|
||||
var canonical = ResolveWhitelistedEmoji(submitted);
|
||||
if (canonical is null)
|
||||
return BadRequest(new RecNetResult { Success = false, Error = "emoji_not_allowed" });
|
||||
stored = canonical;
|
||||
}
|
||||
|
||||
player.DisplayEmoji = stored;
|
||||
await db.SaveChangesAsync();
|
||||
await notifications.AccountChanged(player);
|
||||
return Ok(new RecNetResult { Success = true, Error = string.Empty });
|
||||
}
|
||||
|
||||
/// <summary>Whitelist match that ignores U+FE0F variation selectors, so
|
||||
/// "❤" and "❤️" resolve to the same entry. Returns the list's spelling
|
||||
/// (the client highlights the current pick by string equality against
|
||||
/// the list it fetched).</summary>
|
||||
internal static string? ResolveWhitelistedEmoji(string value)
|
||||
{
|
||||
static string Strip(string s) => s.Replace("️", string.Empty, StringComparison.Ordinal);
|
||||
var needle = Strip(value);
|
||||
foreach (var e in API.Config.V1.ConfigController.WhitelistedEmojiList)
|
||||
if (string.Equals(Strip(e), needle, StringComparison.Ordinal)) return e;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>PUT <c>/account/me/personalpronouns</c> — form
|
||||
/// <c>pronounFlags</c> (pronoun flags enum as Int32).</summary>
|
||||
[HttpPut("/account/me/personalpronouns")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
|
||||
public async Task<IActionResult> ChangePersonalPronouns([FromForm(Name = "pronounFlags")] string? pronounFlags)
|
||||
{
|
||||
var player = await GetCurrentPlayerAsync();
|
||||
if (player is null) return Unauthorized();
|
||||
if (!TryParseFlags(pronounFlags, out var flags))
|
||||
return BadRequest(new RecNetResult { Success = false, Error = "invalid_pronouns" });
|
||||
|
||||
player.PersonalPronouns = flags;
|
||||
await db.SaveChangesAsync();
|
||||
await notifications.AccountChanged(player);
|
||||
return Ok(new RecNetResult { Success = true, Error = string.Empty });
|
||||
}
|
||||
|
||||
/// <summary>PUT <c>/account/me/identityflags</c> — form
|
||||
/// <c>identityFlags</c> (identity flags enum as Int32).</summary>
|
||||
[HttpPut("/account/me/identityflags")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
|
||||
public async Task<IActionResult> ChangeIdentityFlags([FromForm(Name = "identityFlags")] string? identityFlags)
|
||||
{
|
||||
var player = await GetCurrentPlayerAsync();
|
||||
if (player is null) return Unauthorized();
|
||||
if (!TryParseFlags(identityFlags, out var flags))
|
||||
return BadRequest(new RecNetResult { Success = false, Error = "invalid_identity_flags" });
|
||||
|
||||
player.IdentityFlags = flags;
|
||||
await db.SaveChangesAsync();
|
||||
await notifications.AccountChanged(player);
|
||||
return Ok(new RecNetResult { Success = true, Error = string.Empty });
|
||||
}
|
||||
|
||||
/// <summary>Flags arrive as the boxed enum's Int32 ToString(). Negative
|
||||
/// values are not valid bitmasks for either enum.</summary>
|
||||
private static bool TryParseFlags(string? raw, out int flags)
|
||||
=> int.TryParse(raw?.Trim(), System.Globalization.NumberStyles.Integer,
|
||||
System.Globalization.CultureInfo.InvariantCulture, out flags)
|
||||
&& flags >= 0;
|
||||
|
||||
/// <summary>PUT <c>/account/me/bannerimage</c> — form <c>imageName</c>,
|
||||
/// the storage name of an image the player already uploaded (the
|
||||
/// portfolio-banner picker offers the player's own photos). Empty clears
|
||||
/// the banner.</summary>
|
||||
[HttpPut("/account/me/bannerimage")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
|
||||
public async Task<IActionResult> ChangeBannerImage([FromForm(Name = "imageName")] string? imageName)
|
||||
{
|
||||
var player = await GetCurrentPlayerAsync();
|
||||
if (player is null) return Unauthorized();
|
||||
var name = imageName?.Trim() ?? string.Empty;
|
||||
if (name.Length > 256)
|
||||
return BadRequest(new RecNetResult { Success = false, Error = "invalid_image" });
|
||||
|
||||
player.BannerImageName = name.Length == 0 ? null : name;
|
||||
await db.SaveChangesAsync();
|
||||
await notifications.AccountChanged(player);
|
||||
return Ok(new RecNetResult { Success = true, Error = string.Empty });
|
||||
}
|
||||
|
||||
// ParentalControl.Deserialize at RVA 0x14543A0 — required keys:
|
||||
// accountId (int, GetKey)
|
||||
// disallowInAppPurchases (bool, GetKey)
|
||||
@@ -680,63 +809,56 @@ public class AccountsController(
|
||||
|
||||
var token = Guid.NewGuid().ToString("N");
|
||||
var expiresAt = DateTime.UtcNow.AddMinutes(10);
|
||||
await SetPlayerSettingAsync(player.Id, "remote_login_token", $"{token}|{expiresAt:O}");
|
||||
return Ok(new
|
||||
{
|
||||
Success = true,
|
||||
Error = string.Empty,
|
||||
Token = token,
|
||||
LoginToken = token,
|
||||
ExpiresAt = expiresAt,
|
||||
});
|
||||
await SetPlayerSettingAsync(player.Id, RemoteLoginTokenKey, $"{token}|{expiresAt:O}");
|
||||
// FGLDKEJLAKB<System.String> LGKLFFPACGL() (RecNet.Runtime/
|
||||
// LBNJFPOLCDL.txt:8382) deserialises the reply as a BARE JSON string.
|
||||
// The old {Success, Token, ...} object hit the string reader's '"'
|
||||
// check and every "open on the web" link failed with "Failed to
|
||||
// CreateLoginToken". The token is redeemed by connect/token
|
||||
// grant_type=login_token (AuthController.Token).
|
||||
return Content(System.Text.Json.JsonSerializer.Serialize(token), "application/json");
|
||||
}
|
||||
|
||||
/// <summary>PlayerSettings key of the one-shot token minted above.
|
||||
/// Value is <c>{token}|{expiresAtUtc:O}</c>.</summary>
|
||||
public const string RemoteLoginTokenKey = "remote_login_token";
|
||||
|
||||
/// <summary>In-game approval of another device's login code.
|
||||
///
|
||||
/// <c>LDGADANDBIO BJMMDKNPCLD(String)</c> (RecNet.Runtime/LBNJFPOLCDL.txt:7896)
|
||||
/// POSTs the single form field <c>code</c> and reads the reply through
|
||||
/// <c>BFDGEKKNAPB.PEFKBAEAOPD</c> as a <c>{success,error}</c> RecNetResult
|
||||
/// (failure toast "Failed to authorize login"). The code is the
|
||||
/// <c>user_code</c> that <c>connect/deviceauthorization</c> handed the other
|
||||
/// device, which meanwhile polls <c>connect/token</c> with
|
||||
/// <c>grant_type=urn:ietf:params:oauth:grant-type:device_code</c>
|
||||
/// (LBNJFPOLCDL_NestedType_DDNJDALGIME.txt:444-451). Approving binds the
|
||||
/// pending authorization to the CALLER, and that device's next poll is
|
||||
/// issued the caller's tokens (<see cref="DeviceAuthorizations"/>).
|
||||
///
|
||||
/// This used to look the code up among createlogintoken tokens — which a
|
||||
/// user_code never matches, so every approval 401'd — and answered with a
|
||||
/// token pair, which is not a RecNetResult either.</summary>
|
||||
[HttpPost("/account/me/remoteauth")]
|
||||
[HttpGet("/account/me/remoteauth")]
|
||||
public async Task<IActionResult> RemoteAuth()
|
||||
{
|
||||
var token = Request.Query["token"].FirstOrDefault()
|
||||
?? Request.Query["loginToken"].FirstOrDefault()
|
||||
?? Request.Query["code"].FirstOrDefault();
|
||||
if (string.IsNullOrWhiteSpace(token) && Request.HasFormContentType)
|
||||
var approver = await GetCurrentPlayerAsync();
|
||||
if (approver is null) return Unauthorized();
|
||||
|
||||
var code = Request.Query["code"].FirstOrDefault()
|
||||
?? Request.Query["user_code"].FirstOrDefault();
|
||||
if (string.IsNullOrWhiteSpace(code) && Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
token = form["token"].FirstOrDefault()
|
||||
?? form["loginToken"].FirstOrDefault()
|
||||
?? form["code"].FirstOrDefault();
|
||||
code = form["code"].FirstOrDefault() ?? form["user_code"].FirstOrDefault();
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(code))
|
||||
return RecNetResults.Fail("missing_code");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
var player = await GetCurrentPlayerAsync();
|
||||
if (player is null) return Unauthorized();
|
||||
var pair = authService.GenerateTokenPair(player.Id);
|
||||
return Ok(new LoginResponse
|
||||
{
|
||||
AccessToken = pair.AccessToken,
|
||||
RefreshToken = pair.RefreshToken,
|
||||
});
|
||||
}
|
||||
|
||||
var prefix = token.Trim() + "|";
|
||||
var row = await db.PlayerSettings
|
||||
.Where(s => s.Key == "remote_login_token" && s.Value.StartsWith(prefix))
|
||||
.OrderByDescending(s => s.Id)
|
||||
.FirstOrDefaultAsync();
|
||||
if (row is null) return Unauthorized();
|
||||
|
||||
var rawExpiry = row.Value[prefix.Length..];
|
||||
if (!DateTime.TryParse(rawExpiry, out var expiresAt) || expiresAt < DateTime.UtcNow)
|
||||
return Unauthorized();
|
||||
|
||||
db.PlayerSettings.Remove(row);
|
||||
await db.SaveChangesAsync();
|
||||
var (access, refresh) = authService.GenerateTokenPair(row.PlayerId);
|
||||
return Ok(new LoginResponse
|
||||
{
|
||||
AccessToken = access,
|
||||
RefreshToken = refresh,
|
||||
});
|
||||
return await DeviceAuthorizations.ApproveAsync(db, code, approver.Id, DateTime.UtcNow)
|
||||
? RecNetResults.Ok()
|
||||
: RecNetResults.Fail("invalid_or_expired_code");
|
||||
}
|
||||
|
||||
[HttpPost("/account/recoverpassword")]
|
||||
@@ -953,8 +1075,15 @@ public class AccountsController(
|
||||
var me = this.RequireCurrentPlayerId();
|
||||
var visible = await ReadRecentHistoryVisibleBodyAsync();
|
||||
await SetPlayerSettingAsync(me, RecentHistoryVisibleKey, visible ? "true" : "false");
|
||||
// OPMAPIOEIFG.PNEDPCJCKHI passes this body to BFDGEKKNAPB.PEFKBAEAOPD
|
||||
// (ExpandRecNetResult) — it is read as {Success, Error}. Without a
|
||||
// Success key the toggle parsed as a failure with a null Error, which
|
||||
// throws "Error message cannot be null" instead of saving quietly.
|
||||
// The settings keys stay for any reader of the old shape.
|
||||
return Ok(new
|
||||
{
|
||||
Success = true,
|
||||
Error = string.Empty,
|
||||
AccountId = (int)me,
|
||||
IsRecentHistoryVisible = visible,
|
||||
});
|
||||
@@ -995,35 +1124,4 @@ public class AccountsController(
|
||||
// Previously had a /{*path} catch-all that returned [] / {}. Removed —
|
||||
// unknown accounts URLs now 404 so we notice them in logs and wire
|
||||
// them properly rather than silently masking missing endpoints.
|
||||
|
||||
private static RecNetAccount BuildAccount(long id, string username, string? displayName, string? profileImage = null)
|
||||
=> new()
|
||||
{
|
||||
AccountId = (int)id,
|
||||
RawUsername = username,
|
||||
Username = username,
|
||||
DisplayName = displayName ?? username,
|
||||
ProfileImage = profileImage ?? string.Empty,
|
||||
TreatAsJunior = false,
|
||||
HasBirthday = true,
|
||||
Platforms = 1,
|
||||
};
|
||||
|
||||
private static RecNetSelfAccount BuildSelfAccount(long id, string username, string? displayName, string? profileImage = null)
|
||||
=> new()
|
||||
{
|
||||
AccountId = (int)id,
|
||||
RawUsername = username,
|
||||
Username = username,
|
||||
DisplayName = displayName ?? username,
|
||||
ProfileImage = profileImage ?? string.Empty,
|
||||
TreatAsJunior = false,
|
||||
HasBirthday = true,
|
||||
Platforms = 1,
|
||||
Email = string.Empty,
|
||||
Phone = string.Empty,
|
||||
Birthday = new DateTime(2000, 1, 1),
|
||||
JuniorState = 0,
|
||||
ParentAccountId = null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -101,7 +101,9 @@ public class AuthController(
|
||||
[FromForm] int platform = 0,
|
||||
[FromForm] string? platformId = null,
|
||||
[FromForm(Name = "platform_id")] string? platform_id_snake = null,
|
||||
[FromForm] long? account_id = null)
|
||||
[FromForm] long? account_id = null,
|
||||
[FromForm] string? device_code = null,
|
||||
[FromForm] string? login_token = null)
|
||||
{
|
||||
// The client mixes camelCase and snake_case parameter naming
|
||||
// depending on grant type — accept both and merge.
|
||||
@@ -139,6 +141,55 @@ public class AuthController(
|
||||
return TokenOk(acc1, refr1);
|
||||
}
|
||||
|
||||
// Device-authorization poll (LBNJFPOLCDL_NestedType_DDNJDALGIME.txt:444-451).
|
||||
// The client keeps polling on HTTP 400 with error authorization_pending
|
||||
// or slow_down (LBNJFPOLCDL_NestedType_JAEKFNKDNCP.txt:97-116); any other
|
||||
// error ends the attempt. This grant used to fall through to the
|
||||
// device-id fallback, which signed the polling device straight in as
|
||||
// whatever account its device id mapped to — no approval involved.
|
||||
if (string.Equals(grant_type, DeviceCodeGrantType, StringComparison.Ordinal))
|
||||
{
|
||||
var (status, approverId) = await DeviceAuthorizations.RedeemAsync(db, device_code, DateTime.UtcNow);
|
||||
switch (status)
|
||||
{
|
||||
case DeviceAuthorizations.RedeemStatus.Pending:
|
||||
return BadRequest(new { error = "authorization_pending" });
|
||||
case DeviceAuthorizations.RedeemStatus.Expired:
|
||||
return BadRequest(new { error = "expired_token" });
|
||||
case DeviceAuthorizations.RedeemStatus.Invalid:
|
||||
return BadRequest(new { error = "invalid_grant" });
|
||||
}
|
||||
var approver = await playerService.GetByIdAsync(approverId);
|
||||
if (approver is null) return BadRequest(new { error = "invalid_grant" });
|
||||
await TryAwardLoginXpAsync(approver.Id);
|
||||
var (accD, refrD) = authService.GenerateTokenPair(approver.Id);
|
||||
return TokenOk(accD, refrD);
|
||||
}
|
||||
|
||||
// One-shot token from account/me/createlogintoken, redeemed with the
|
||||
// account it was minted for (LBNJFPOLCDL.txt:7593-7606: grant_type,
|
||||
// account_id, login_token). Also used to fall through to the device-id
|
||||
// fallback, ignoring both account_id and the token.
|
||||
if (string.Equals(grant_type, "login_token", StringComparison.Ordinal))
|
||||
{
|
||||
if (account_id is not long tokenAccountId || string.IsNullOrWhiteSpace(login_token))
|
||||
return BadRequest(new { error = "invalid_request", error_description = "invalid login_token" });
|
||||
var prefix = login_token.Trim() + "|";
|
||||
var row = await db.PlayerSettings.FirstOrDefaultAsync(s =>
|
||||
s.PlayerId == tokenAccountId
|
||||
&& s.Key == Accounts.AccountsController.RemoteLoginTokenKey
|
||||
&& s.Value.StartsWith(prefix));
|
||||
if (row is null
|
||||
|| !DateTime.TryParse(row.Value[prefix.Length..], null,
|
||||
System.Globalization.DateTimeStyles.RoundtripKind, out var tokenExpiry)
|
||||
|| tokenExpiry < DateTime.UtcNow)
|
||||
return BadRequest(new { error = "invalid_grant", error_description = "invalid login_token" });
|
||||
db.PlayerSettings.Remove(row);
|
||||
await db.SaveChangesAsync();
|
||||
var (accL, refrL) = authService.GenerateTokenPair(tokenAccountId);
|
||||
return TokenOk(accL, refrL);
|
||||
}
|
||||
|
||||
if (string.Equals(grant_type, "refresh_token", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(refresh_token))
|
||||
@@ -364,26 +415,20 @@ public class AuthController(
|
||||
[HttpGet("/connect/deviceauthorization")]
|
||||
public async Task<IActionResult> DeviceAuthorization()
|
||||
{
|
||||
var deviceCode = Guid.NewGuid().ToString("N");
|
||||
var userCode = deviceCode[..8].ToUpperInvariant();
|
||||
db.PlayerSettings.Add(new PlayerSettingEntity
|
||||
{
|
||||
PlayerId = RelationshipQueries.SystemAccountId,
|
||||
Key = $"deviceauthorization:{deviceCode}",
|
||||
Value = $"{userCode}|{DateTime.UtcNow.AddMinutes(15):O}",
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
var created = await DeviceAuthorizations.CreateAsync(db, DateTime.UtcNow);
|
||||
return Ok(new
|
||||
{
|
||||
device_code = deviceCode,
|
||||
user_code = userCode,
|
||||
device_code = created.DeviceCode,
|
||||
user_code = created.UserCode,
|
||||
verification_uri = $"https://{domain.Sub("auth")}/device",
|
||||
verification_uri_complete = $"https://{domain.Sub("auth")}/device?user_code={userCode}",
|
||||
expires_in = 900,
|
||||
verification_uri_complete = $"https://{domain.Sub("auth")}/device?user_code={created.UserCode}",
|
||||
expires_in = created.ExpiresInSeconds,
|
||||
interval = 5,
|
||||
});
|
||||
}
|
||||
|
||||
public const string DeviceCodeGrantType = "urn:ietf:params:oauth:grant-type:device_code";
|
||||
|
||||
private static CachedLogin ToCachedLogin(PlayerEntity p, int platform, string platformId) => new()
|
||||
{
|
||||
Platform = platform,
|
||||
|
||||
@@ -91,11 +91,13 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
||||
/// 2023 client sends the target club in a form-urlencoded
|
||||
/// <c>clubId</c> field (boxed <c>Nullable<Int64></c> pushed through
|
||||
/// <c>BNDIAONDFFF.AFGEDDANEKP</c> at <c>IKMMOCKDKAF.txt:16923-16927</c>)
|
||||
/// and parses NO response DTO — the issuing method is
|
||||
/// <c>LDGADANDBIO JOPMBFIFFBB(Nullable<Int64>)</c>, the status-only
|
||||
/// promise type, so an empty 200 is the contract. Verb ordinal 3 (PUT)
|
||||
/// is the HasValue branch of the same method at :16911; the null branch
|
||||
/// is the DELETE below (:16838).</summary>
|
||||
/// — the issuing method is <c>LDGADANDBIO JOPMBFIFFBB(Nullable<Int64>)</c>.
|
||||
/// Despite the untyped promise the body IS parsed: both branches hand
|
||||
/// the response to <c>BFDGEKKNAPB.PEFKBAEAOPD</c> (:16858, :16943), the
|
||||
/// <c>{success,error}</c> RecNetResult reader, so an empty 200 surfaced as
|
||||
/// a failure (<see cref="RecNetResults"/>). Verb ordinal 3 (PUT) is the
|
||||
/// HasValue branch of the same method at :16911; the null branch is the
|
||||
/// DELETE below (:16838).</summary>
|
||||
[HttpPut("/club/home/me")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> HomeMeSet()
|
||||
@@ -116,11 +118,11 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
||||
clubId = fromQuery;
|
||||
if (clubId <= 0) return BadRequest(new { error = "missing_club_id" });
|
||||
if (!await clubs.SetHomeClubAsync(pid, clubId)) return NotFound();
|
||||
return Ok();
|
||||
return RecNetResults.Ok();
|
||||
}
|
||||
|
||||
/// <summary>DELETE <c>/club/home/me</c> — "Remove Home Clubhouse Room".
|
||||
/// No body, no response DTO (<c>IKMMOCKDKAF.txt:16830-16841</c>: route
|
||||
/// No request body; RecNetResult reply (<c>IKMMOCKDKAF.txt:16830-16858</c>: route
|
||||
/// literal, host 13, verb ordinal 4, error string "Failed to Remove Home
|
||||
/// Clubhouse Room"). Idempotent so a double-tap is harmless.</summary>
|
||||
[HttpDelete("/club/home/me")]
|
||||
@@ -128,7 +130,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
||||
public async Task<IActionResult> HomeMeClear()
|
||||
{
|
||||
await clubs.ClearHomeClubAsync(Me);
|
||||
return Ok();
|
||||
return RecNetResults.Ok();
|
||||
}
|
||||
|
||||
[HttpGet("/club/mine/member")]
|
||||
@@ -347,7 +349,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
||||
{
|
||||
var pid = Me;
|
||||
await clubs.MarkAnnouncementReadAsync(pid, announcementId);
|
||||
return Ok(new { Read = true });
|
||||
return RecNetResults.Ok();
|
||||
}
|
||||
|
||||
// ── Single-club reads ─────────────────────────────────────────────
|
||||
@@ -369,8 +371,11 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
||||
/// the club NAME used only for the client-side confirmation copy and is
|
||||
/// never transmitted; the request is a bare DELETE with no body
|
||||
/// (<c>IKMMOCKDKAF.txt:17107-17120</c>, verb ordinal 4, host 13, error
|
||||
/// string "Failed to Delete Club"). <c>LDGADANDBIO</c> is the status-only
|
||||
/// promise so an empty 200 is the whole contract.
|
||||
/// string "Failed to Delete Club"). The promise is built by
|
||||
/// <c>BFDGEKKNAPB.PEFKBAEAOPD</c> (ExpandRecNetResult, :17137), so the
|
||||
/// body is a <c>{success,error}</c> RecNetResult — an empty 200 made the
|
||||
/// client report "Failed to Delete Club" after the club was gone. See
|
||||
/// <see cref="RecNetResults"/>.
|
||||
///
|
||||
/// Owner-only, and a soft delete — see
|
||||
/// <see cref="ClubService.DisbandAsync"/>.</summary>
|
||||
@@ -382,7 +387,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
||||
{
|
||||
var disbanded = await clubs.DisbandAsync(clubId, Me);
|
||||
if (disbanded is null) return NotFound();
|
||||
return Ok();
|
||||
return RecNetResults.Ok();
|
||||
}
|
||||
catch (UnauthorizedAccessException) { return Forbid(); }
|
||||
}
|
||||
@@ -1144,7 +1149,15 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
||||
/// is reachable — see <see cref="ToWireMemberRequest"/>.</summary>
|
||||
private static int RequestStatusFor(ClubMembershipEntity row) => 1;
|
||||
|
||||
// ── Member mutations (void / IPromise) ───────────────────────────
|
||||
// ── Member mutations ─────────────────────────────────────────────
|
||||
//
|
||||
// Every one of these goes through the client's shared club-write helper
|
||||
// IKMMOCKDKAF.JNFEEDLCGHH ("Failed to " + action), which hands the
|
||||
// response to BFDGEKKNAPB.PEFKBAEAOPD (IKMMOCKDKAF.txt:26696) — the
|
||||
// RecNetResult reader. They are not status-only: an empty 200 parses to
|
||||
// null and the client shows "Failed to request to join club" (etc.) after
|
||||
// the server has applied the change. Success bodies are
|
||||
// RecNetResults.Ok().
|
||||
|
||||
/// <summary>POST <c>/club/{id}/members/requesttojoin</c> — caller
|
||||
/// asks to join. For Open clubs we promote straight to Member; for
|
||||
@@ -1169,7 +1182,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
||||
};
|
||||
if (perms == -1) return Forbid();
|
||||
await clubs.UpsertMembershipAsync(clubId, Me, perms);
|
||||
return Ok();
|
||||
return RecNetResults.Ok();
|
||||
}
|
||||
|
||||
/// <summary>POST <c>/club/{id}/members/directJoin</c> — same as
|
||||
@@ -1183,7 +1196,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
||||
var club = await clubs.GetByIdAsync(clubId);
|
||||
if (club is null) return NotFound();
|
||||
await clubs.UpsertMembershipAsync(clubId, Me, 0);
|
||||
return Ok();
|
||||
return RecNetResults.Ok();
|
||||
}
|
||||
|
||||
/// <summary>POST <c>/club/{id}/members/leave</c> — caller leaves
|
||||
@@ -1198,7 +1211,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
||||
if (club.CreatorPlayerId == Me)
|
||||
return BadRequest(new { error = "owner_must_transfer_first" });
|
||||
await clubs.RemoveMembershipAsync(clubId, Me);
|
||||
return Ok();
|
||||
return RecNetResults.Ok();
|
||||
}
|
||||
|
||||
/// <summary>POST <c>/club/{id}/members/invite</c> — caller invites
|
||||
@@ -1220,7 +1233,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
||||
var target = req.PlayerId ?? req.AccountId ?? 0;
|
||||
if (target == 0) return BadRequest(new { error = "missing_player_id" });
|
||||
await clubs.UpsertMembershipAsync(clubId, target, 128); // Pending
|
||||
return Ok();
|
||||
return RecNetResults.Ok();
|
||||
}
|
||||
|
||||
/// <summary>POST <c>/club/{id}/members/invitemembers</c> — bulk
|
||||
@@ -1241,7 +1254,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
||||
var ids = (req.PlayerIds ?? req.AccountIds ?? new List<int>()).Distinct().ToList();
|
||||
foreach (var pid in ids)
|
||||
await clubs.UpsertMembershipAsync(clubId, pid, 128);
|
||||
return Ok();
|
||||
return RecNetResults.Ok();
|
||||
}
|
||||
|
||||
/// <summary>POST <c>/club/{id}/members/acceptinvite</c> — caller
|
||||
@@ -1254,7 +1267,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
||||
if (row is null) return NotFound();
|
||||
if ((row.Permissions & 128) == 0) return BadRequest(new { error = "no_pending_invite" });
|
||||
await clubs.UpsertMembershipAsync(clubId, Me, 0); // Member
|
||||
return Ok();
|
||||
return RecNetResults.Ok();
|
||||
}
|
||||
|
||||
/// <summary>POST <c>/club/{id}/members/declineinvite</c> — caller
|
||||
@@ -1264,7 +1277,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
||||
public async Task<IActionResult> MemberDeclineInvite(long clubId)
|
||||
{
|
||||
await clubs.RemoveMembershipAsync(clubId, Me);
|
||||
return Ok();
|
||||
return RecNetResults.Ok();
|
||||
}
|
||||
|
||||
public sealed class MemberTargetRequest
|
||||
@@ -1288,7 +1301,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
||||
if (row is null) return NotFound();
|
||||
if ((row.Permissions & 128) == 0) return BadRequest(new { error = "no_pending_request" });
|
||||
await clubs.UpsertMembershipAsync(clubId, target, 0);
|
||||
return Ok();
|
||||
return RecNetResults.Ok();
|
||||
}
|
||||
|
||||
public sealed class BulkTargetRequest
|
||||
@@ -1309,7 +1322,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
||||
if (row is null || (row.Permissions & 128) == 0) continue;
|
||||
await clubs.UpsertMembershipAsync(clubId, pid, 0);
|
||||
}
|
||||
return Ok();
|
||||
return RecNetResults.Ok();
|
||||
}
|
||||
|
||||
[HttpPost("/club/{clubId:long}/members/denyrequest")]
|
||||
@@ -1320,7 +1333,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
||||
var target = req.PlayerId ?? req.AccountId ?? 0;
|
||||
if (target == 0) return BadRequest(new { error = "missing_player_id" });
|
||||
await clubs.RemoveMembershipAsync(clubId, target);
|
||||
return Ok();
|
||||
return RecNetResults.Ok();
|
||||
}
|
||||
|
||||
[HttpPost("/club/{clubId:long}/members/denyrequests")]
|
||||
@@ -1331,7 +1344,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
||||
var ids = (req.PlayerIds ?? req.AccountIds ?? new List<int>()).Distinct().ToList();
|
||||
foreach (var pid in ids)
|
||||
await clubs.RemoveMembershipAsync(clubId, pid);
|
||||
return Ok();
|
||||
return RecNetResults.Ok();
|
||||
}
|
||||
|
||||
[HttpPost("/club/{clubId:long}/members/remove")]
|
||||
@@ -1346,7 +1359,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
||||
if (target == club.CreatorPlayerId)
|
||||
return BadRequest(new { error = "cannot_remove_owner" });
|
||||
await clubs.RemoveMembershipAsync(clubId, target);
|
||||
return Ok();
|
||||
return RecNetResults.Ok();
|
||||
}
|
||||
|
||||
/// <summary>POST <c>/club/{id}/members/ban</c> — kick + prevent
|
||||
@@ -1364,7 +1377,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
||||
if (target == club.CreatorPlayerId)
|
||||
return BadRequest(new { error = "cannot_ban_owner" });
|
||||
await clubs.UpsertMembershipAsync(clubId, target, 256); // Ban marker
|
||||
return Ok();
|
||||
return RecNetResults.Ok();
|
||||
}
|
||||
|
||||
[HttpPost("/club/{clubId:long}/members/unban")]
|
||||
@@ -1375,7 +1388,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
||||
var target = req.PlayerId ?? req.AccountId ?? 0;
|
||||
if (target == 0) return BadRequest(new { error = "missing_player_id" });
|
||||
await clubs.RemoveMembershipAsync(clubId, target);
|
||||
return Ok();
|
||||
return RecNetResults.Ok();
|
||||
}
|
||||
|
||||
/// <summary>POST <c>/club/{id}/members/changetype</c> — promote /
|
||||
@@ -1396,7 +1409,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
||||
if (target == club.CreatorPlayerId)
|
||||
return BadRequest(new { error = "cannot_change_owner_role" });
|
||||
await clubs.UpsertMembershipAsync(clubId, target, ClubService.PermsFromMembershipType(newType));
|
||||
return Ok();
|
||||
return RecNetResults.Ok();
|
||||
}
|
||||
|
||||
// ── members/bulk + clubreporting ─────────────────────────────────
|
||||
@@ -1513,7 +1526,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
||||
public async Task<IActionResult> AnnouncementDelete(long clubId, long announcementId)
|
||||
{
|
||||
if (!await clubs.DeleteAnnouncementAsync(clubId, announcementId, Me)) return Forbid();
|
||||
return Ok();
|
||||
return RecNetResults.Ok();
|
||||
}
|
||||
|
||||
/// <summary>PUT <c>/announcements/club/{clubId}/{announcementId}</c> — EDIT
|
||||
@@ -1529,7 +1542,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
||||
var (title, body, imageName) = await ReadAnnouncementFieldsAsync();
|
||||
if (!await clubs.UpdateAnnouncementAsync(clubId, announcementId, Me, title, body, imageName))
|
||||
return Forbid();
|
||||
return Ok();
|
||||
return RecNetResults.Ok();
|
||||
}
|
||||
|
||||
/// <summary>POST <c>/announcements/club/{clubId}</c> — CREATE an
|
||||
|
||||
@@ -41,6 +41,7 @@ public class GoToController(
|
||||
NotificationService notifications,
|
||||
JoinTimeoutService joinTimeouts,
|
||||
ServerSettingsService serverSettings,
|
||||
PresenceBroadcastService presenceFanout,
|
||||
ILogger<GoToController> logger) : ControllerBase
|
||||
{
|
||||
[HttpPost("/goto/room/{roomName}")]
|
||||
@@ -1294,15 +1295,14 @@ public class GoToController(
|
||||
|
||||
private async Task NotifyFriendsOfMoveAsync(long playerId, RoomInstanceDto room)
|
||||
{
|
||||
// Honors the global-friends toggle: with it on, every other player is
|
||||
// a "friend" and gets the presence ping (so the whole small server
|
||||
// sees each other's room moves).
|
||||
var friendIds = await RelationshipQueries.EffectiveFriendIdsAsync(db, serverSettings, playerId);
|
||||
// Honors the global-friends toggle (every other player is a "friend"
|
||||
// when it is on). Goes through the shared fan-out so the frame
|
||||
// carries the player's real status visibility and the same
|
||||
// online/room state every other presence transition reports.
|
||||
logger.LogInformation(
|
||||
"[goto-fanout] player={PlayerId} moved to room={RoomId}/{RoomName} → notifying {FriendCount} friend(s)",
|
||||
playerId, room.RoomId, room.Name, friendIds.Count);
|
||||
if (friendIds.Count == 0) return;
|
||||
await notifications.PlayerEnteredRoom(friendIds, playerId, room);
|
||||
"[goto-fanout] player={PlayerId} moved to room={RoomId}/{RoomName}",
|
||||
playerId, room.RoomId, room.Name);
|
||||
await presenceFanout.PublishCurrentAsync(playerId);
|
||||
}
|
||||
|
||||
private async Task<MatchmakingResponseDto> BuildResponseAsync(string roomName)
|
||||
|
||||
@@ -22,6 +22,7 @@ public class MatchController(
|
||||
PlayerPresenceService presence,
|
||||
PrivateInstanceService privateInstances,
|
||||
JoinTimeoutService joinTimeouts,
|
||||
PresenceBroadcastService presenceFanout,
|
||||
RoomService rooms,
|
||||
DorkNetDbContext db,
|
||||
ILogger<MatchController> logger) : ControllerBase
|
||||
@@ -67,6 +68,12 @@ public class MatchController(
|
||||
completed.Value.TargetRoom.Name,
|
||||
completed.Value.TargetRoom.RoomInstanceId,
|
||||
completed.Value.DeferPresenceCommit);
|
||||
// A deferred join (matchmake/dorm, private-instance hop)
|
||||
// commits HERE, not at matchmake time, so this is the only
|
||||
// point friends can learn about the move. Immediate commits
|
||||
// were already announced by /goto.
|
||||
if (completed.Value.DeferPresenceCommit)
|
||||
await presenceFanout.PublishCurrentAsync(pid);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -88,36 +95,48 @@ public class MatchController(
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>POST <c>matchmake/none</c> — "leave room". The 2023 client's
|
||||
/// only caller is TryGoToOfflineRoom
|
||||
/// (RecNet.Runtime/RecNet/Matchmaking.txt:12677-13279): it has already
|
||||
/// switched its own cached presence to the local offline instance
|
||||
/// (RoomInstanceId -1) before posting this, and it discards the reply
|
||||
/// (<c><TryGoToOfflineRoom>b__119_0</c> returns a constant).
|
||||
///
|
||||
/// So the player is now online in no room, and that is what presence must
|
||||
/// say. This used to re-commit the previous room (or the dorm) instead:
|
||||
/// the next heartbeat then answered with a room while the client held the
|
||||
/// offline instance, the client logged "presence heartbeat response
|
||||
/// indicates local presence is out-of-sync", cancelled its offline load
|
||||
/// ("OnPlayerPresenceUpdate is cancelling room load for -1") and chased the
|
||||
/// server's room — and friends kept seeing the room the player had left.
|
||||
///
|
||||
/// A join still pending is abandoned too: the client only goes offline
|
||||
/// after giving up on it (fallback chain after a Photon timeout), and
|
||||
/// echoing its target from the heartbeat would pull the player back.</summary>
|
||||
[HttpPost("/matchmake/none")]
|
||||
public async Task<IActionResult> MatchmakeNone()
|
||||
{
|
||||
var pid = this.CurrentPlayerId() ?? 0;
|
||||
if (pid == 0) return Unauthorized();
|
||||
|
||||
var previous = presence.GetRoom(pid);
|
||||
var pending = joinTimeouts.GetPending(pid);
|
||||
if (pending is { DeferPresenceCommit: true })
|
||||
{
|
||||
presence.MarkActive(pid);
|
||||
logger.LogInformation(
|
||||
"[matchmake-none] player={Player} has deferred pending room={Room} instance={Instance}; not committing presence",
|
||||
pid,
|
||||
pending.Value.TargetRoom.RoomId,
|
||||
pending.Value.TargetRoom.RoomInstanceId);
|
||||
return Ok(new MatchmakingResponseDto
|
||||
{
|
||||
ErrorCode = 0,
|
||||
RoomInstance = pending.Value.TargetRoom,
|
||||
});
|
||||
}
|
||||
|
||||
var current = presence.GetRoom(pid) ?? await BuildDormRoomInstanceAsync(pid);
|
||||
presence.SetRoom(pid, current);
|
||||
joinTimeouts.Cancel(pid, "matchmake-none");
|
||||
presence.SetNoRoom(pid);
|
||||
presence.MarkActive(pid);
|
||||
logger.LogInformation("[matchmake-none] player={Player}", pid);
|
||||
presence.EndBootDormGrace(pid);
|
||||
logger.LogInformation(
|
||||
"[matchmake-none] player={Player} left room={Room} instance={Instance} abandonedPending={Pending}; online with no room",
|
||||
pid, previous?.RoomId, previous?.RoomInstanceId, pending?.TargetRoom.RoomInstanceId);
|
||||
|
||||
await presenceFanout.PublishCurrentAsync(pid);
|
||||
|
||||
// The client ignores this body; keep a well-formed instance in it so
|
||||
// the shared MatchmakingResponse reader never sees a null object.
|
||||
return Ok(new MatchmakingResponseDto
|
||||
{
|
||||
ErrorCode = 0,
|
||||
RoomInstance = current,
|
||||
RoomInstance = previous ?? pending?.TargetRoom ?? await BuildDormRoomInstanceAsync(pid),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ public class MatchPlayerController(
|
||||
RoomService rooms,
|
||||
PrivateInstanceService privateInstances,
|
||||
JoinTimeoutService joinTimeouts,
|
||||
PresenceBroadcastService presenceFanout,
|
||||
IConfiguration config,
|
||||
DorkNetDbContext db,
|
||||
ILogger<MatchPlayerController> log) : ControllerBase
|
||||
@@ -87,13 +88,20 @@ public class MatchPlayerController(
|
||||
/// <summary>
|
||||
/// POST /player/logout — fires when the client gives up on a session
|
||||
/// (e.g. Photon connection couldn't be established, client aborts).
|
||||
/// Clear the cached presence so the next /goto starts fresh.
|
||||
/// Clear the cached presence so the next /goto starts fresh, and tell
|
||||
/// friends right away — otherwise they keep seeing the player in their
|
||||
/// last room until a bulk poll happens to notice.
|
||||
/// </summary>
|
||||
[HttpPost("/player/logout")]
|
||||
public IActionResult PlayerLogout()
|
||||
public async Task<IActionResult> PlayerLogout()
|
||||
{
|
||||
var playerId = TryGetCurrentPlayerId();
|
||||
if (playerId != 0) presence.Clear(playerId);
|
||||
if (playerId != 0)
|
||||
{
|
||||
joinTimeouts.Cancel(playerId, "logout");
|
||||
presence.Clear(playerId);
|
||||
await presenceFanout.PublishOfflineAsync(playerId);
|
||||
}
|
||||
return Ok(new { success = true, error = "" });
|
||||
}
|
||||
|
||||
@@ -178,7 +186,15 @@ public class MatchPlayerController(
|
||||
public async Task<ActionResult<PresenceDto>> SetStatusVisibility([FromForm(Name = "statusVisibility")] int? value)
|
||||
{
|
||||
var playerId = TryGetCurrentPlayerId();
|
||||
if (value is int v) await SetPlayerPrefAsync(playerId, PrefStatusVisibility, v.ToString());
|
||||
if (value is int v)
|
||||
{
|
||||
await SetPlayerPrefAsync(playerId, PrefStatusVisibility, v.ToString());
|
||||
// Friends only. The client does not apply this response to its own
|
||||
// cached presence, and a PresenceUpdate about the local player runs
|
||||
// the same apply-and-rejoin path as an out-of-sync heartbeat
|
||||
// (Matchmaking.EGPABEDPEJO -> FLAOOBNJAAD, Matchmaking.txt:6917), so no self push.
|
||||
await presenceFanout.PublishCurrentAsync(playerId);
|
||||
}
|
||||
var room = await GetPresenceRoomForResponseAsync(playerId);
|
||||
return Ok(new PresenceDto
|
||||
{
|
||||
@@ -212,7 +228,7 @@ public class MatchPlayerController(
|
||||
// back in the response and was then dropped, so the setting reverted on the
|
||||
// next heartbeat and never survived a restart. They live in PlayerSettings
|
||||
// (no schema change needed) and are read back wherever presence is built.
|
||||
private const string PrefStatusVisibility = "pref:statusVisibility";
|
||||
private const string PrefStatusVisibility = PresenceBroadcastService.StatusVisibilityPrefKey;
|
||||
private const string PrefVrMovementMode = "pref:vrMovementMode";
|
||||
private const string PrefAvoidJuniors = "pref:avoidJuniors";
|
||||
|
||||
@@ -395,6 +411,21 @@ public class MatchPlayerController(
|
||||
|
||||
if (room is null)
|
||||
{
|
||||
// The player went to the offline room (matchmake/none). Their
|
||||
// client now holds RoomInstanceId -1, which the heartbeat compare
|
||||
// treats as "no room" (HCHDEHIGEBE.GIICPDHJOOH, sign bit of the id;
|
||||
// RecNet.Runtime/RecNet/Matchmaking.txt:18415-18437) and is only in
|
||||
// sync with a null roomInstance. The dorm fallback below would
|
||||
// read as out-of-sync and drag the player out of the offline room.
|
||||
if (presence.IsInNoRoom(playerId))
|
||||
{
|
||||
presence.TouchNoRoom(playerId);
|
||||
log.LogInformation(
|
||||
"[player-heartbeat] player={PlayerId} online in no room (left via matchmake/none); returning null roomInstance",
|
||||
playerId);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (presence.IsBootDormGraceActive(playerId))
|
||||
{
|
||||
log.LogInformation(
|
||||
@@ -426,6 +457,7 @@ public class MatchPlayerController(
|
||||
playerId, fallback.RoomId, fallback.DataBlob);
|
||||
presence.SetRoom(playerId, fallback);
|
||||
presence.MarkActive(playerId);
|
||||
await presenceFanout.PublishCurrentAsync(playerId);
|
||||
return fallback;
|
||||
}
|
||||
log.LogInformation("[player-heartbeat] player={PlayerId} room=null", playerId);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.Match;
|
||||
@@ -36,10 +38,19 @@ namespace DorkNet.Server.Controllers.Match;
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
public class PlayerPresenceController(
|
||||
PlayerPresenceService presence) : ControllerBase
|
||||
PlayerPresenceService presence,
|
||||
PresenceBroadcastService presenceFanout,
|
||||
JoinTimeoutService joinTimeouts,
|
||||
DorkNetDbContext db,
|
||||
ServerSettingsService settings) : ControllerBase
|
||||
{
|
||||
/// <summary>PlayerSettings key written by <c>PUT /player/statusvisibility</c>
|
||||
/// (MatchPlayerController.PrefStatusVisibility — same literal; the
|
||||
/// round-trip is pinned by a regression test).</summary>
|
||||
public const string PrefStatusVisibility = PresenceBroadcastService.StatusVisibilityPrefKey;
|
||||
|
||||
[HttpGet("/player")]
|
||||
public ActionResult<List<PlayerPresenceDto>> GetPresences()
|
||||
public async Task<ActionResult<List<PlayerPresenceDto>>> GetPresences()
|
||||
{
|
||||
// The watch sends two URL flavours for this endpoint:
|
||||
// 1. Bulk: <c>/player?{id1}&{id2}&{id3}</c> — bare keys,
|
||||
@@ -81,31 +92,39 @@ public class PlayerPresenceController(
|
||||
// mid-coroutine. Only redact for OTHER players where the
|
||||
// privacy concern actually applies.
|
||||
var callerId = this.CurrentPlayerId();
|
||||
var callerRoom = callerId is long cid ? presence.GetRoom(cid) : null;
|
||||
var callerRoom = callerId is long cid ? SelfRoom(cid) : null;
|
||||
var visibility = await LoadVisibilityAsync(ids);
|
||||
var hiddenFromCaller = await ResolveHiddenAsync(callerId, ids, visibility);
|
||||
var result = ids.Select(id =>
|
||||
{
|
||||
var room = presence.GetRoom(id);
|
||||
// Other players need both a room presence and an active
|
||||
// live signal. Use the recent-heartbeat marker as the
|
||||
// truth source: SignalR can drop while the player is still
|
||||
// heartbeating, or linger after the game is gone. Presence
|
||||
// keys intentionally live for minutes to survive long room
|
||||
// loads, so using them alone leaves friends stuck online
|
||||
// after a graceful disconnect.
|
||||
var isSelf = callerId == id;
|
||||
var active = isSelf || presence.IsRecentlyActive(id);
|
||||
var isOnline = room is not null && active;
|
||||
// The caller's own row must say exactly what their heartbeat
|
||||
// says (pending-join target, or null in the offline room): the
|
||||
// client compares its cached self presence field by field
|
||||
// (HCHDEHIGEBE.DCIJHIPJHOB) and treats any difference as
|
||||
// out-of-sync.
|
||||
var room = isSelf ? callerRoom : presence.GetRoom(id);
|
||||
// Online = a live signal (recent heartbeat, or a SignalR
|
||||
// connection), NOT "has a room". Presence keys live for minutes
|
||||
// to survive long room loads, so a room alone would leave friends
|
||||
// stuck online after the game is gone; and a player in the offline
|
||||
// room after matchmake/none has no room but is very much online
|
||||
// (the 2023 DTO carries isOnline on its own; see
|
||||
// NotificationService.BuildPresenceUpdatePayload).
|
||||
// A player who chose "appear offline" (or friends/favorites
|
||||
// only, when the caller doesn't qualify) is reported exactly
|
||||
// like a genuinely offline player: no room, isOnline=false.
|
||||
var isOnline = (isSelf || presenceFanout.IsOnline(id)) && !hiddenFromCaller.Contains(id);
|
||||
if (!isOnline) room = null;
|
||||
return new PlayerPresenceDto
|
||||
{
|
||||
PlayerId = (int)id,
|
||||
// StatusVisibility is the player's *preference* for who may
|
||||
// see them online. The enum is Everyone=0 / FriendsOnly=1 /
|
||||
// Nobody=2 — so the previous value 1 (mislabeled "Visible")
|
||||
// actually meant FriendsOnly, and anyone who wasn't your
|
||||
// friend saw you as OFFLINE. On a private server everyone
|
||||
// should see everyone, so report Everyone (0). IsOnline=false
|
||||
// still gates the "Online" badge for offline players.
|
||||
StatusVisibility = 0, // Everyone
|
||||
// The player's stored online-status visibility. Self MUST
|
||||
// see its own stored value: the profile dialog's current
|
||||
// selection is read from the cached self presence, and a
|
||||
// hard-coded 0 here snapped it back to Public after every
|
||||
// presence refresh.
|
||||
StatusVisibility = visibility.GetValueOrDefault(id, PresenceVisibility.Public),
|
||||
DeviceClass = 0, // Desktop / unknown — not VR
|
||||
VrMovementMode = 0,
|
||||
// Full instance details are safe, and necessary, when the
|
||||
@@ -124,6 +143,58 @@ public class PlayerPresenceController(
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>The caller's own room as their heartbeat reports it.</summary>
|
||||
private RoomInstanceDto? SelfRoom(long playerId)
|
||||
{
|
||||
if (joinTimeouts.GetPending(playerId) is { DeferPresenceCommit: true } pending)
|
||||
return pending.TargetRoom;
|
||||
if (presence.IsInNoRoom(playerId)) return null;
|
||||
return presence.GetRoom(playerId);
|
||||
}
|
||||
|
||||
private Task<Dictionary<long, int>> LoadVisibilityAsync(HashSet<long> ids) =>
|
||||
PresenceBroadcastService.GetStatusVisibilitiesAsync(db, ids);
|
||||
|
||||
/// <summary>Ids whose online status the caller may not see. The caller
|
||||
/// always sees itself; anonymous callers only see Public players.</summary>
|
||||
private async Task<HashSet<long>> ResolveHiddenAsync(
|
||||
long? callerId, HashSet<long> ids, Dictionary<long, int> visibility)
|
||||
{
|
||||
var hidden = new HashSet<long>();
|
||||
var restricted = visibility
|
||||
.Where(kv => kv.Value != PresenceVisibility.Public && kv.Key != callerId)
|
||||
.ToList();
|
||||
if (restricted.Count == 0) return hidden;
|
||||
|
||||
if (callerId is not long me)
|
||||
{
|
||||
foreach (var kv in restricted) hidden.Add(kv.Key);
|
||||
return hidden;
|
||||
}
|
||||
|
||||
HashSet<long>? friends = null;
|
||||
HashSet<long>? favoritedMe = null;
|
||||
foreach (var (target, vis) in restricted)
|
||||
{
|
||||
if (vis == PresenceVisibility.Offline) { hidden.Add(target); continue; }
|
||||
|
||||
friends ??= (await RelationshipQueries.EffectiveFriendIdsAsync(db, settings, me)).ToHashSet();
|
||||
if (!friends.Contains(target)) { hidden.Add(target); continue; }
|
||||
|
||||
if (vis == PresenceVisibility.FavoriteFriendsOnly)
|
||||
{
|
||||
// Favorited lives on the favoriter's own row
|
||||
// (RequesterId = favoriter), as RelationshipsController.BuildMerged reads it.
|
||||
favoritedMe ??= (await db.Relationships
|
||||
.Where(r => r.TargetId == me && r.Favorited && ids.Contains(r.RequesterId))
|
||||
.Select(r => r.RequesterId)
|
||||
.ToListAsync()).ToHashSet();
|
||||
if (!favoritedMe.Contains(target)) hidden.Add(target);
|
||||
}
|
||||
}
|
||||
return hidden;
|
||||
}
|
||||
|
||||
private static bool ShouldExposeFullRoom(
|
||||
long? callerId,
|
||||
RoomInstanceDto? callerRoom,
|
||||
@@ -151,8 +222,8 @@ public class PlayerPresenceDto
|
||||
[JsonPropertyName("playerId")]
|
||||
public int PlayerId { get; set; }
|
||||
|
||||
/// <summary>PlayerStatusVisibility enum (int):
|
||||
/// 0 = Hidden / Offline-appearing, 1 = Visible, 2 = FriendsOnly.</summary>
|
||||
/// <summary>Online-status visibility enum (int, 2023 client):
|
||||
/// 0 = Public, 1 = FriendsOnly, 2 = FavoriteFriendsOnly, 3 = Offline.</summary>
|
||||
[JsonPropertyName("statusVisibility")]
|
||||
public int StatusVisibility { get; set; }
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
using DorkNet.Models.Auth;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace DorkNet.Server.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Bodies for the 2023 client's <c>RecNetResult</c> writes.
|
||||
///
|
||||
/// A lot of the client's fire-and-forget writes look status-only from their
|
||||
/// signature (they return <c>LDGADANDBIO</c>, the untyped promise), but they
|
||||
/// are not: they go through <c>BFDGEKKNAPB.PEFKBAEAOPD</c>
|
||||
/// (<c>RecNet.Runtime/BFDGEKKNAPB.txt:353</c>, "ExpandRecNetResult"), which
|
||||
/// deserialises the body as <c>DJMHAFPGLLN</c> = <c>{Success, Error}</c>
|
||||
/// (formatter <c>RecNet.Runtime/LMCCNLLHLCJ.txt:191-218</c> registers
|
||||
/// <c>Success</c>/<c>success</c> and <c>Error</c>/<c>error</c>) and then runs
|
||||
/// the <c><ExpandRecNetResult>b__2_0</c> lambda
|
||||
/// (<c>RecNet.Runtime/BFDGEKKNAPB_NestedType___c.txt:61</c>):
|
||||
/// <list type="bullet">
|
||||
/// <item>body null (an empty 200 or <c>null</c> — <c>COLNKALCBOK.txt</c>
|
||||
/// JIKCMNMKNPH returns default for both) → NullReferenceException, the write
|
||||
/// shows "Failed to …" although the server applied it;</item>
|
||||
/// <item><c>Success</c> missing/false → <c>Promise.Rejected(Error)</c>; with no
|
||||
/// <c>Error</c> key that throws <c>ArgumentException: Error message cannot be
|
||||
/// null</c> (seen in Player.log as an unobserved task exception).</item>
|
||||
/// </list>
|
||||
/// So every such endpoint must answer <c>{"success":true,"error":""}</c> on
|
||||
/// success, and carry a non-null <c>error</c> on a 200 failure.
|
||||
/// </summary>
|
||||
public static class RecNetResults
|
||||
{
|
||||
public static IActionResult Ok() =>
|
||||
new OkObjectResult(new RecNetResult { Success = true, Error = string.Empty });
|
||||
|
||||
public static IActionResult Fail(string error) =>
|
||||
new OkObjectResult(new RecNetResult { Success = false, Error = error });
|
||||
}
|
||||
@@ -15,4 +15,21 @@ public class StringsController : ControllerBase
|
||||
[HttpGet("/strings/v1/{locale}")]
|
||||
public ActionResult<Dictionary<string, string>> GetLocale(string locale)
|
||||
=> Ok(new Dictionary<string, string>());
|
||||
|
||||
/// <summary>The 2023 client's server-string download (RecNet.Runtime/NHILNDLLLHH.txt).
|
||||
/// Two GETs share the path <c>/strings</c> on the strings host:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>?locale=&scopePrefix=client.&eTag=</c> (FMLBOAOBKPM, :531) →
|
||||
/// <c>Dictionary<string, Dictionary<long,string>></c>, scope → id → text;</item>
|
||||
/// <item><c>?locale=&scope=client.{name}&eTag=</c> (BNEELKAKCEN, :3) →
|
||||
/// <c>Dictionary<long,string></c>.</item>
|
||||
/// </list>
|
||||
/// The localisation service stores the first answer
|
||||
/// (RecRoom.Localization.Runtime/IBAJDNIMADI.txt BICKHBAJPIG) and its scope lookup
|
||||
/// (NHLKEJLGGIE) null-derefs when the download never succeeded but returns an empty
|
||||
/// table for a scope the server did not send. DorkNet keeps no server-side string
|
||||
/// overrides, so the truthful answer is an empty JSON object for either query —
|
||||
/// the lookups then fall back to the bundled text. The path used to 404.</summary>
|
||||
[HttpGet("/strings")]
|
||||
public IActionResult GetServerStrings() => Content("{}", "application/json");
|
||||
}
|
||||
|
||||
@@ -116,6 +116,29 @@ public class PlayerEntity
|
||||
[MaxLength(32)]
|
||||
public string? Phone { get; set; }
|
||||
|
||||
// ── 2023 profile fields ──────────────────────────────────────────────
|
||||
// Written by PUT account/me/{emoji,bannerimage,personalpronouns,
|
||||
// identityflags} and read back on every account DTO (account/me,
|
||||
// account/{id}, account/bulk, search, AccountUpdate push) under the
|
||||
// keys displayEmoji / bannerImage / personalPronouns / identityFlags.
|
||||
|
||||
/// <summary>Emoji shown beside the display name ("" = none).</summary>
|
||||
[MaxLength(32)]
|
||||
public string DisplayEmoji { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Profile banner image name (a storage image the player
|
||||
/// already uploaded, e.g. one of their photos). Null = no banner.</summary>
|
||||
[MaxLength(256)]
|
||||
public string? BannerImageName { get; set; }
|
||||
|
||||
/// <summary>Pronoun bitmask (client flags enum: SheHer=1, HeHim=2,
|
||||
/// TheyThem=4, ZeHir=8, ZeZir=16, XeXem=32).</summary>
|
||||
public int PersonalPronouns { get; set; } = 0;
|
||||
|
||||
/// <summary>Identity-flag bitmask (client flags enum: LGBTQIA=1,
|
||||
/// Transgender=2, Bisexual=4, …).</summary>
|
||||
public int IdentityFlags { get; set; } = 0;
|
||||
|
||||
// Navigation
|
||||
public AvatarEntity? Avatar { get; set; }
|
||||
public List<PlayerSettingEntity> Settings { get; set; } = [];
|
||||
|
||||
@@ -111,7 +111,7 @@ Note: counts are approximate because several client URLs (`api/avatar/v3/saved`,
|
||||
|
||||
| Verb | URL | Status | Handler |
|
||||
|---|---|---|---|
|
||||
| GET | `api/gameconfigs/v1/all` | STUB | `GameConfigurationsController.GameConfigsAll` (also shadowed by `AllEndpointsController.GameConfigs` — the explicit `gameconfigs/v1/all` route wins). Returns `[]`. |
|
||||
| GET | `api/gameconfigs/v1/all` | REAL | `GameConfigurationsController.GetAll` (also shadowed by `AllEndpointsController.GameConfigs` — the explicit `gameconfigs/v1/all` route wins). Serves the admin-managed key/value set from `ServerSettingsService.GetGameConfigurationsAsync`: Rec Center doors, the typed keys confirmed in the 2020.12 decomp, the `UGC.CircuitsV2.Limit.*` family (see `docs/recroom-2023-circuitsv2-limits.md`), and `PlayMenuCarouselOrdering_1` (the 2023 play-menu carousel priority map; without it the client logs "Could not find GameConfig for PlayMenuCarouselOrdering_1" and keeps prefab order). |
|
||||
|
||||
### `api/groups/`
|
||||
|
||||
@@ -391,6 +391,18 @@ These all live on `match.rec.net` (locally: `match.localhost`).
|
||||
| POST | `matchmake/none` | REAL | `Match.MatchController.MatchmakeNone` — returns the caller's current room (or dorm) without changing it |
|
||||
| POST | `player/heartbeat` | REAL | `Match.MatchPlayerController.Heartbeat` — refreshes the caller's `PlayerPresenceService` entry; also has a duplicate in `ApiNamespaceStubsController.Heartbeat` for the api-host case |
|
||||
|
||||
2023 additions on the auth / accounts / strings hosts:
|
||||
|
||||
| Verb | URL | Status | Handler |
|
||||
|---|---|---|---|
|
||||
| POST | `connect/token` `grant_type=urn:ietf:params:oauth:grant-type:device_code` | REAL | `Auth.AuthController.Token` via `DeviceAuthorizations.RedeemAsync` — 400 `authorization_pending` until approved, then the approver's token pair; single use |
|
||||
| POST | `connect/token` `grant_type=login_token` | REAL | `Auth.AuthController.Token` — redeems an `account/me/createlogintoken` token for its own `account_id`; single use |
|
||||
| POST | `account/me/createlogintoken` | REAL | `Accounts.AccountsController.CreateLoginToken` — bare JSON string (the client reads `FGLDKEJLAKB<String>`) |
|
||||
| POST | `account/me/remoteauth` | REAL | `Accounts.AccountsController.RemoteAuth` — approves a device `user_code` for the caller; `{success,error}` RecNetResult |
|
||||
| POST | `account/bulk/email`, `account/bulk/phone` | REAL | `Accounts.AccountsController.Bulk` — contact lookup, repeated `email` / `phone` form fields, account list |
|
||||
| GET | `strings?locale&scopePrefix|scope&eTag` | REAL | `Strings.StringsController.GetServerStrings` — empty table (no server-side string overrides) instead of a 404 |
|
||||
| GET | `api/incentivizedreferrals/referrals?take&continuationToken` | REAL | `IncentivizedReferralsController.Referrals` — `{Referrals:[{InviteeAccountId,CreatedAt,VerifiedAt}],ContinuationToken}` |
|
||||
|
||||
### CDN host (`cdn.rec.net` / `storage` / `data`)
|
||||
|
||||
Served by `Controllers/Cdn/CdnController` (catch-all that wins routing on every host).
|
||||
|
||||
@@ -42,8 +42,7 @@ public class NotifyHub(
|
||||
OnlinePresenceService presence,
|
||||
PlayerPresenceService playerRooms,
|
||||
NotificationService notifications,
|
||||
DorkNetDbContext db,
|
||||
ServerSettingsService serverSettings,
|
||||
PresenceBroadcastService presenceFanout,
|
||||
ILogger<NotifyHub> logger) : Hub
|
||||
{
|
||||
/// <summary>SignalR group name for a given player id. Use this
|
||||
@@ -108,18 +107,11 @@ public class NotifyHub(
|
||||
// friend's list for the rest of the session even though they were
|
||||
// still playing.
|
||||
//
|
||||
// Only push when a room is already known. The presence payload
|
||||
// derives isOnline from roomInstance being non-null, so a push with
|
||||
// no room reads as OFFLINE — worse than staying quiet. On a fresh
|
||||
// boot there is no room yet and the /goto that follows sends the
|
||||
// real presence anyway; this branch is for reconnects, where the
|
||||
// cached room is exactly what friends need to see again.
|
||||
if (cameOnline && playerRooms.GetRoom(id) is { } room)
|
||||
{
|
||||
var friendIds = await RelationshipQueries.EffectiveFriendIdsAsync(db, serverSettings, id);
|
||||
if (friendIds.Count > 0)
|
||||
await notifications.PlayerCameOnline(friendIds, id, room);
|
||||
}
|
||||
// isOnline is sent explicitly now, so this no longer needs a
|
||||
// known room: a player who connects before matchmaking, or who is
|
||||
// sitting in the offline room, is online with roomInstance null.
|
||||
if (cameOnline)
|
||||
await presenceFanout.PublishCurrentAsync(id);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -139,9 +131,17 @@ public class NotifyHub(
|
||||
await Groups.RemoveFromGroupAsync(Context.ConnectionId, GroupForPlayer(id));
|
||||
if (wentOffline)
|
||||
{
|
||||
var friendIds = await RelationshipQueries.EffectiveFriendIdsAsync(db, serverSettings, id);
|
||||
if (friendIds.Count > 0)
|
||||
await notifications.PlayerWentOffline(friendIds, id);
|
||||
// The socket can drop under a live session (the server or a
|
||||
// proxy closes it; the client logs "RecNet HubConnection error:
|
||||
// ... TCP Stream closed unexpectedly by the remote server" and
|
||||
// reconnects by itself). While the player is still
|
||||
// heartbeating, wait one activity window before calling them
|
||||
// offline; a real exit either logs out (immediate offline push)
|
||||
// or stops heartbeating and is caught by the deferred check.
|
||||
if (playerRooms.IsRecentlyActive(id))
|
||||
presenceFanout.ScheduleOfflineCheck(id);
|
||||
else
|
||||
await presenceFanout.PublishOfflineAsync(id);
|
||||
}
|
||||
logger.LogInformation(
|
||||
"[hub] disconnected player={PlayerId} conn={ConnectionId} wentOffline={WentOffline} error={Error}",
|
||||
|
||||
+3673
@@ -0,0 +1,3673 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using DorkNet.Server.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DorkNet.Server.Migrations
|
||||
{
|
||||
[DbContext(typeof(DorkNetDbContext))]
|
||||
[Migration("20261015110121_PlayerProfile2023Fields")]
|
||||
partial class PlayerProfile2023Fields
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "9.0.4");
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.AdminActionEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Action")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("AdminPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Reason")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("TargetId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("TargetType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("Timestamp")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AdminPlayerId");
|
||||
|
||||
b.HasIndex("Timestamp");
|
||||
|
||||
b.HasIndex("TargetType", "TargetId");
|
||||
|
||||
b.ToTable("AdminActions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("CustomAvatarItemsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("EquippedItemsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("FaceFeatures")
|
||||
.IsRequired()
|
||||
.HasMaxLength(8192)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("HairColor")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("InventoryJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("OutfitSelections")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("OutfitSelectionsV2")
|
||||
.IsRequired()
|
||||
.HasMaxLength(8192)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("SavedOutfitsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("SkinColor")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Avatars");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.BugReportEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Body")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Category")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ClientVersion")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("GameSessionId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Platform")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("ReadAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("ReporterPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ReadAt");
|
||||
|
||||
b.HasIndex("ReporterPlayerId");
|
||||
|
||||
b.ToTable("BugReports");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.CardEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ActionUrl")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Body")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Category")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("ExpiresAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Priority")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ExpiresAt");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.ToTable("Cards");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.CharadesWordListEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsBuiltIn")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("WordsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("CharadesWordLists");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatMessageEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Body")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("ModerationState")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("SenderPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("SentAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ThreadKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ThreadKey", "SentAt");
|
||||
|
||||
b.ToTable("ChatMessages");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatThreadEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ThreadKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(96)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ThreadKey")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ChatThreads");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatThreadMemberEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("JoinedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("LastReadMessageId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("SnoozeUntil")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ThreadKey")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.HasIndex("ThreadKey", "PlayerId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ChatThreadMembers");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.CheerEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CheeredAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("FromPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("TargetInventionId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("TargetPhotoId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("TargetPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("TargetRoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TargetPhotoId");
|
||||
|
||||
b.HasIndex("TargetPlayerId");
|
||||
|
||||
b.HasIndex("TargetRoomId");
|
||||
|
||||
b.HasIndex("FromPlayerId", "TargetPlayerId", "TargetRoomId", "TargetPhotoId", "Type")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Cheers");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubAdditionalImageEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("ClubId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Slot")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ClubId", "Slot")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ClubAdditionalImages");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubAnnouncementEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("AuthorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Body")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("ClubId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ClubId", "CreatedAt");
|
||||
|
||||
b.ToTable("ClubAnnouncements");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubAnnouncementReadEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("AnnouncementId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("ReadAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.HasIndex("AnnouncementId", "PlayerId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ClubAnnouncementReads");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubCategoryAssignmentEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("CategoryTagId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("ClubId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CategoryTagId");
|
||||
|
||||
b.HasIndex("ClubId", "CategoryTagId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ClubCategoryAssignments");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubCategoryTagEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("Active")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("OrderIndex")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Name");
|
||||
|
||||
b.ToTable("ClubCategoryTags");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("AllowJuniors")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("BanStatus")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Category")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("ClubChatEnabled")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ClubType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long?>("ClubhouseRoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsRRO")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Joinability")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("MinLevel")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT")
|
||||
.UseCollation("NOCASE");
|
||||
|
||||
b.Property<int>("State")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Visibility")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatorPlayerId");
|
||||
|
||||
b.HasIndex("Name")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Clubs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubMembershipEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("ClubId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("JoinedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Permissions")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.HasIndex("ClubId", "PlayerId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ClubMemberships");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubRolePermissionEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("ApproveMember")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("BanUnban")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("ClubId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("CreateEvent")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("EditDetails")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("EditPermissionSettings")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("MembershipType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("PostAnnouncement")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ClubId", "MembershipType")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ClubRolePermissions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubSubscriptionEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("ClubId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("SubscribedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ClubId");
|
||||
|
||||
b.HasIndex("PlayerId", "ClubId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ClubSubscriptions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.CohortAssignmentEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("AssignedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CohortKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Variant")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId", "CohortKey")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("CohortAssignments");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.CommunityBoardEntity", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Json")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("CommunityBoardRows");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.CouponEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Code")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("ExpiresAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("MaxRedemptions")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("RedemptionCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RewardAmount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("RewardCurrencyType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("RewardItemSlug")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("RewardType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Code")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Coupons");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.CouponRedemptionEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("CouponId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("RedeemedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CouponId", "PlayerId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("CouponRedemptions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.CurrencyBalanceEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("Balance")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("CurrencyType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId", "CurrencyType")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("CurrencyBalances");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.CustomAvatarItemEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AssetName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("BaseAvatarItemId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("CheerCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Color")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsFeatured")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsPublic")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ItemType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Price")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("PublicId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PurchaseCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatorPlayerId");
|
||||
|
||||
b.HasIndex("IsFeatured");
|
||||
|
||||
b.HasIndex("IsPublic");
|
||||
|
||||
b.HasIndex("PublicId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("CustomAvatarItems");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.CustomAvatarItemOwnershipEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("AcquiredAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CustomAvatarItemId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CustomAvatarItemId");
|
||||
|
||||
b.HasIndex("PlayerId", "CustomAvatarItemId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("CustomAvatarItemOwnership");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.DormStateEntity", b =>
|
||||
{
|
||||
b.Property<long>("PlayerId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CurrentDataBlobName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("PlayerId");
|
||||
|
||||
b.ToTable("DormStates");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.GameRewardSelectionEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("GiftContext")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("GrantedItemId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Message")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("Offer1ItemId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("Offer2ItemId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("Offer3ItemId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("RewardType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("SelectedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("SelectedGiftDropId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId", "SelectedAt");
|
||||
|
||||
b.ToTable("GameRewardSelections");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.GameSessionEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ActivityLevelId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("MaxCapacity")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("PhotonRoomName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("PlayerCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Region")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("RoomId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoomId", "Region");
|
||||
|
||||
b.ToTable("GameSessions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.GiftPackageEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AvatarItemDescOrHairDyeDesc")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("AvatarItemType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ConsumableItemDesc")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("Consumed")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("ConsumedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Currency")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("CurrencyType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("EquipmentModificationGuid")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("EquipmentPrefabName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ErrorMessage")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("FromPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("GiftContext")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("GiftRarity")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsGifted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsValid")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Level")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Message")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PackageMaterial")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PackageVariant")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Platform")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RecipientPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("SourceStoreItemId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SupportsCurrentPlatform")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Xp")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RecipientPlayerId", "Consumed");
|
||||
|
||||
b.ToTable("GiftPackages");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.InventionEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("CheerCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("CreationRoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("CreatorPermission")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("CreatorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("CurrentBlobName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("CurrentVersionNumber")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("FirstPublishedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("GeneralPermission")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsAgInvention")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsPublished")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("NumPlayersHaveUsedInRoom")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Permission")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Price")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ReplicationId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("SpawnCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("TagsCsv")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CheerCount");
|
||||
|
||||
b.HasIndex("CreatorPlayerId");
|
||||
|
||||
b.ToTable("Inventions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.InventionVersionEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("BlobName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("InstantiationCost")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("InventionId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("LightsCost")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ReplicationId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("VersionNumber")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("InventionId", "VersionNumber")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("InventionVersions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.IpBanEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("BannedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("BannedByAdminId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Cidr")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Reason")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("Until")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Cidr");
|
||||
|
||||
b.ToTable("IpBans");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ItemWishlistEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ItemKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("ItemType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId", "ItemKey", "ItemType")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ItemWishlists");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.KeepsakeEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Category")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("EarnedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("EventKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.HasIndex("PlayerId", "EventKey");
|
||||
|
||||
b.ToTable("Keepsakes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.LeaderboardChannelMetaEntity", b =>
|
||||
{
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Channel")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("LowerIsBetter")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ValueFormat")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("RoomId", "Channel");
|
||||
|
||||
b.HasIndex("RoomId");
|
||||
|
||||
b.ToTable("LeaderboardChannelMeta");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.LeaderboardStatEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("StatChannel")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("Value")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoomId", "PlayerId", "StatChannel")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("RoomId", "StatChannel", "Value");
|
||||
|
||||
b.ToTable("LeaderboardStats");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.LoadingScreenTipEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Context")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Message")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("PlatformMask")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("RoomNamesCsv")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("LoadingScreenTips");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.MessageEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Body")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("ReadAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("RecipientPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long?>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("SenderPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("SentAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RecipientPlayerId", "SentAt");
|
||||
|
||||
b.HasIndex("SenderPlayerId", "SentAt");
|
||||
|
||||
b.ToTable("Messages");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.NotificationPrefsEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("AllowAll")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("AllowAnnouncements")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("AllowEventInvite")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("AllowFriendRequest")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("AllowMessage")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Platform")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId", "Platform")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("NotificationPrefs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ObjectiveProgressEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("ClearedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsCompleted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Key")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId", "Key")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ObjectiveProgress");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PendingDeviceEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("DeviceId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("FirstSeenAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("LastIp")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("LastSeenAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Platform")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("PlatformId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("PendingDevices");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PhotoEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("BlobName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Caption")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("CheerCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsPublic")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("TaggedPlayerIdsCsv")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("UploaderPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ViewCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("BlobName");
|
||||
|
||||
b.HasIndex("CreatedAt");
|
||||
|
||||
b.HasIndex("RoomId");
|
||||
|
||||
b.HasIndex("UploaderPlayerId");
|
||||
|
||||
b.ToTable("Photos");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlatformIgnoreEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Platform")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("PlatformUserId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId", "Platform", "PlatformUserId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("PlatformIgnores");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerDeviceEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("DeviceId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("FirstSeen")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsBanned")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("LastSeen")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Platform")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("DeviceId");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.ToTable("PlayerDevices");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEloEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Elo")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("GameMode")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Losses")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("MatchesPlayed")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Wins")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("GameMode", "Elo");
|
||||
|
||||
b.HasIndex("PlayerId", "GameMode")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("PlayerElo");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("BannedUntil")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("BannerImageName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Bio")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("Birthday")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("CanReceiveInvites")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CreatedFromDeviceId")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DeviceId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DisplayEmoji")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("IdentityFlags")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsAdmin")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsCommunityTeam")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsDeveloper")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsJunior")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsVerified")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("LastIp")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("LastPlatform")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("LastPlatformId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("LastSeenAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Level")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Locale")
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("PersonalPronouns")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Phone")
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ProfileImageName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Reputation")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("XP")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Username")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Players");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEventEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Capacity")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("EndsAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("StartsAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatorPlayerId");
|
||||
|
||||
b.HasIndex("StartsAt");
|
||||
|
||||
b.ToTable("PlayerEvents");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEventResponseEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("EventId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Response")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.HasIndex("EventId", "PlayerId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("PlayerEventResponses");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerInventoryEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("AcquiredAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ItemSlug")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Quantity")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.HasIndex("PlayerId", "ItemSlug")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("PlayerInventory");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Key")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId", "Key")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("PlayerSettings");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlaylistEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Accessibility")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("CheerCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("CustomWarning")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("FavoriteCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsCurated")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT")
|
||||
.UseCollation("NOCASE");
|
||||
|
||||
b.Property<int>("OrderIndex")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SupportsJuniors")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SupportsLevelVoting")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SupportsScreens")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SupportsTeleportVR")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SupportsWalkVR")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("TagsCsv")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("VisitCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("VisitorCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("WarningMask")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatorPlayerId");
|
||||
|
||||
b.HasIndex("IsCurated");
|
||||
|
||||
b.HasIndex("Name");
|
||||
|
||||
b.ToTable("Playlists");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlaylistInteractionEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("Cheered")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("Favorited")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlaylistId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.HasIndex("PlaylistId", "PlayerId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("PlaylistInteractions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlaylistRoomEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("OrderIndex")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlaylistId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlaylistId");
|
||||
|
||||
b.HasIndex("RoomId");
|
||||
|
||||
b.HasIndex("PlaylistId", "OrderIndex");
|
||||
|
||||
b.ToTable("PlaylistRooms");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PrivateInstanceEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DataBlob")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Location")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("MaxCapacity")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("OwnerPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("PhotonRegion")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PhotonRoomId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("RoomCode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("SubRoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("OwnerPlayerId");
|
||||
|
||||
b.HasIndex("RoomId", "SubRoomId");
|
||||
|
||||
b.ToTable("PrivateInstances");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PrivateInstanceInviteeEntity", b =>
|
||||
{
|
||||
b.Property<long>("PrivateInstanceId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("InvitedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("LatestInviteMessageId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("PrivateInstanceId", "PlayerId");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.ToTable("PrivateInstanceInvitees");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PushTokenEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Platform")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Token")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Token");
|
||||
|
||||
b.HasIndex("PlayerId", "Platform")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("PushTokens");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("Favorited")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("Ignored")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("Muted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RequesterId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("TargetId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RequesterId", "TargetId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Relationships");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ReportEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Category")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("GameSessionId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Message")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("ReporterPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ResolutionNote")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("ResolvedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("ResolverAdminId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("TargetEventId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("TargetInventionId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("TargetPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("TargetRoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ReporterPlayerId");
|
||||
|
||||
b.HasIndex("ResolvedAt");
|
||||
|
||||
b.HasIndex("TargetPlayerId");
|
||||
|
||||
b.ToTable("Reports");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomBanEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("BanType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("BannedByPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("BannedPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Reason")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("Until")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("BannedPlayerId");
|
||||
|
||||
b.HasIndex("RoomId", "BannedPlayerId");
|
||||
|
||||
b.ToTable("RoomBans");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomBookmarkEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.HasIndex("PlayerId", "RoomId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("RoomBookmarks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomConsumableEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid?>("CurrencyId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("Price")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("PublicId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatorPlayerId");
|
||||
|
||||
b.HasIndex("PublicId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("RoomId");
|
||||
|
||||
b.ToTable("RoomConsumables");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomConsumableOwnershipEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("ConcurrencyCode")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Count")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("ModifiedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RoomConsumableId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoomConsumableId");
|
||||
|
||||
b.HasIndex("PlayerId", "RoomConsumableId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("RoomConsumableOwnership");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomCurrencyBalanceEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("Balance")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RoomCurrencyId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoomCurrencyId");
|
||||
|
||||
b.HasIndex("PlayerId", "RoomCurrencyId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("RoomCurrencyBalances");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomCurrencyEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("DailyLimit")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("PublicId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatorPlayerId");
|
||||
|
||||
b.HasIndex("PublicId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("RoomId");
|
||||
|
||||
b.ToTable("RoomCurrencies");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomCurrencyPurchaseOfferEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Amount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("CurrencyType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Price")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("PublicId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("RoomCurrencyId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PublicId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("RoomCurrencyId");
|
||||
|
||||
b.ToTable("RoomCurrencyPurchaseOffers");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomDataBlobEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("BlobName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<byte[]>("Bytes")
|
||||
.HasColumnType("BLOB");
|
||||
|
||||
b.Property<string>("ReferencedFilenamesCsv")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long?>("SubRoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UploadedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("UploadedByPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("BlobName")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("RoomId");
|
||||
|
||||
b.ToTable("RoomDataBlobs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Accessibility")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("AllowNewUsers")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("AllowsJuniors")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("CheerCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("CloningAllowed")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("CurrentDataBlobName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CustomRoomWarning")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("DisableMicAutoMute")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("FavoriteCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("HiddenFromBrowse")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<double>("HotScore")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsAGRoom")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsDormRoom")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsRoomLinkedToRecRoomStudio")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsStudioRoom")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("LoadScreensJson")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("LocationReplicationId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("MaxCapacity")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("MaxPlayerCalculationMode")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("MinLevel")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT")
|
||||
.UseCollation("NOCASE");
|
||||
|
||||
b.Property<string>("PromoExternalContentJson")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PromoImagesJson")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("RoomWarningMask")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("State")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("StudioSessionId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("SupportsLevelVoting")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SupportsMobile")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SupportsScreens")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SupportsTeleportVR")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SupportsVRLow")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SupportsWalkVR")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("TagsCsv")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("VisitCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("VisitorCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatorPlayerId");
|
||||
|
||||
b.HasIndex("HotScore");
|
||||
|
||||
b.HasIndex("Name")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Rooms");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomKeyEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(174)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(40)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Price")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ReplicationId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatorPlayerId");
|
||||
|
||||
b.HasIndex("RoomId");
|
||||
|
||||
b.HasIndex("RoomId", "Name");
|
||||
|
||||
b.ToTable("RoomKeys");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomKeyPurchaseEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("PaidPrice")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("PurchasedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("RoomKeyId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.HasIndex("RoomKeyId", "PlayerId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("RoomKeyPurchases");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomRoleEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("Accepted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("GrantedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("GrantedByPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Role")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoomId");
|
||||
|
||||
b.HasIndex("RoomId", "PlayerId", "Role")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("RoomRoles");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomSceneEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("CanMatchmakeInto")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("DataBlobName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("DataModifiedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsSandbox")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("MaxPlayers")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT")
|
||||
.UseCollation("NOCASE");
|
||||
|
||||
b.Property<int>("OrderIndex")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("RoomSceneLocationId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("StudioAssetBundleNamesCsv")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("StudioSubRoomDataSaveId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("StudioUnityAssetId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoomId", "Name");
|
||||
|
||||
b.HasIndex("RoomId", "OrderIndex")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("RoomScenes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomVisitEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("FirstVisitAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("LastVisitAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("VisitCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.HasIndex("RoomId", "LastVisitAt");
|
||||
|
||||
b.HasIndex("RoomId", "PlayerId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("RoomVisits");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyaleMatchEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ChestsOpened")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CompletedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("CustomGame")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("HealthPotionsConsumed")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("NumEliminations")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Rank")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("SecondsAlive")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("SecondsInAir")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ShieldPotionsConsumed")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("WalkGame")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CompletedAt");
|
||||
|
||||
b.ToTable("RoyaleMatches");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyaleMatchPlayerEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("MatchId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("NumEliminations")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Rank")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("SecondsAlive")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.HasIndex("MatchId", "PlayerId");
|
||||
|
||||
b.ToTable("RoyaleMatchPlayers");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyalePlayerProgressEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("CurrentLevelXPThreshold")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Level")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("NextLevelAcornReward")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("NextLevelXPThreshold")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("RankIdx")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("RankName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("TotalXP")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("RoyalePlayerProgress");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ServerSettingsEntity", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("AllAvatarItemsOwned")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("BaseRoomNamesJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CharadesSlotBindingsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CircuitsV2LimitsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DiscoveredGameConfigsJson")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("");
|
||||
|
||||
b.Property<bool>("GlobalFriendsEnabled")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("PlayMenuTagsJson")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("");
|
||||
|
||||
b.Property<bool>("ProfanityFilterDisabled")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("RecCenterDoorsJson")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("");
|
||||
|
||||
b.Property<bool>("RoomBlobVersionClampDisabled")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SignupsDisabled")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("WeeklyChallengeRewardJson")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("");
|
||||
|
||||
b.Property<bool>("WeeklyChallengesCompletedRequired")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<string>("WeeklyChallengesJson")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("ServerSettings");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.SignupCodeEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Code")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedByPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Descriptor")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("ExpiresAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("RedeemedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("RedeemedByPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("Revoked")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("SignupCodes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.StoreItemEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("AvailableUntil")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Category")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("CurrencyType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsLimitedTime")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("Price")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Slug")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Storefront")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Slug")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("Storefront");
|
||||
|
||||
b.HasIndex("IsActive", "Category");
|
||||
|
||||
b.ToTable("StoreItems");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.SubscriptionEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("SubscriberPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("TargetPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SubscriberPlayerId");
|
||||
|
||||
b.HasIndex("TargetPlayerId");
|
||||
|
||||
b.HasIndex("SubscriberPlayerId", "TargetPlayerId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Subscriptions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.TestCaseEntity", b =>
|
||||
{
|
||||
b.Property<long>("Pk")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AssignedPlayerIdsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("AssignedPlayerNamesJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CommentsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Id")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("JiraBugUrl")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("JiraUrl")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Key")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("MinNumAssignedPlayers")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("RoomName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("TagsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<uint?>("TestPassId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Pk");
|
||||
|
||||
b.HasIndex("Id")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("Status");
|
||||
|
||||
b.HasIndex("TestPassId");
|
||||
|
||||
b.ToTable("TestCases");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.TestPassEntity", b =>
|
||||
{
|
||||
b.Property<uint>("Id")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("EndDate")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("StartDate")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("TagsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("WasManuallyClosed")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("TestPasses");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.UgcPurchasableEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("CurrencyType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsFeatured")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ItemType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Price")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("PublicId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatorPlayerId");
|
||||
|
||||
b.HasIndex("PublicId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("RoomId");
|
||||
|
||||
b.ToTable("UgcPurchasables");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b =>
|
||||
{
|
||||
b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player")
|
||||
.WithOne("Avatar")
|
||||
.HasForeignKey("DorkNet.Server.Data.Entities.AvatarEntity", "PlayerId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Player");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b =>
|
||||
{
|
||||
b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player")
|
||||
.WithMany("Settings")
|
||||
.HasForeignKey("PlayerId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Player");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b =>
|
||||
{
|
||||
b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Requester")
|
||||
.WithMany("Relationships")
|
||||
.HasForeignKey("RequesterId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Requester");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b =>
|
||||
{
|
||||
b.Navigation("Avatar");
|
||||
|
||||
b.Navigation("Relationships");
|
||||
|
||||
b.Navigation("Settings");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DorkNet.Server.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class PlayerProfile2023Fields : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "BannerImageName",
|
||||
table: "Players",
|
||||
type: "TEXT",
|
||||
maxLength: 256,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "DisplayEmoji",
|
||||
table: "Players",
|
||||
type: "TEXT",
|
||||
maxLength: 32,
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "IdentityFlags",
|
||||
table: "Players",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "PersonalPronouns",
|
||||
table: "Players",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "BannerImageName",
|
||||
table: "Players");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "DisplayEmoji",
|
||||
table: "Players");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IdentityFlags",
|
||||
table: "Players");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PersonalPronouns",
|
||||
table: "Players");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1819,6 +1819,10 @@ namespace DorkNet.Server.Migrations
|
||||
b.Property<DateTime?>("BannedUntil")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("BannerImageName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Bio")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
@@ -1842,6 +1846,11 @@ namespace DorkNet.Server.Migrations
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DisplayEmoji")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
@@ -1851,6 +1860,9 @@ namespace DorkNet.Server.Migrations
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("IdentityFlags")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsAdmin")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
@@ -1892,6 +1904,9 @@ namespace DorkNet.Server.Migrations
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("PersonalPronouns")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Phone")
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
@@ -3234,6 +3249,10 @@ namespace DorkNet.Server.Migrations
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CircuitsV2LimitsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DiscoveredGameConfigsJson")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
using DorkNet.Models.Auth;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
|
||||
namespace DorkNet.Server.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Single projection from <see cref="PlayerEntity"/> to the account wire
|
||||
/// DTOs. Every account read (account/me, account/{id}, account/bulk,
|
||||
/// account/search, account/create) and the AccountUpdate /
|
||||
/// SelfAccountUpdate pushes go through here so a profile field written by
|
||||
/// one endpoint is visible on all of them — previously each call site built
|
||||
/// its own DTO and the 2023 profile fields were never emitted anywhere.
|
||||
/// </summary>
|
||||
public static class AccountDtos
|
||||
{
|
||||
public static RecNetAccount Public(PlayerEntity p) => Fill(new RecNetAccount(), p);
|
||||
|
||||
public static RecNetSelfAccount Self(PlayerEntity p)
|
||||
{
|
||||
var a = Fill(new RecNetSelfAccount(), p);
|
||||
a.Email = p.Email ?? string.Empty;
|
||||
a.Phone = p.Phone ?? string.Empty;
|
||||
a.Birthday = p.Birthday ?? new DateTime(2000, 1, 1);
|
||||
a.JuniorState = p.IsJunior ? 1 : 0;
|
||||
a.ParentAccountId = null;
|
||||
return a;
|
||||
}
|
||||
|
||||
private static T Fill<T>(T a, PlayerEntity p) where T : RecNetAccount
|
||||
{
|
||||
a.AccountId = (int)p.Id;
|
||||
a.RawUsername = p.Username;
|
||||
a.Username = p.Username;
|
||||
a.DisplayName = string.IsNullOrWhiteSpace(p.DisplayName) ? p.Username : p.DisplayName;
|
||||
a.ProfileImage = p.ProfileImageName ?? string.Empty;
|
||||
a.TreatAsJunior = p.IsJunior;
|
||||
a.HasBirthday = true;
|
||||
a.Platforms = 1;
|
||||
a.CreatedAt = p.CreatedAt;
|
||||
a.DisplayEmoji = p.DisplayEmoji ?? string.Empty;
|
||||
a.BannerImage = p.BannerImageName ?? string.Empty;
|
||||
a.PersonalPronouns = p.PersonalPronouns;
|
||||
a.IdentityFlags = p.IdentityFlags;
|
||||
return a;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace DorkNet.Server.Services;
|
||||
|
||||
/// <summary>
|
||||
/// OAuth device-authorization grant as the 2023 client drives it.
|
||||
///
|
||||
/// 1. The device that wants to sign in calls <c>connect/deviceauthorization</c>
|
||||
/// and shows the player the <c>user_code</c> (reader
|
||||
/// <c>RecNet.Runtime/AMPFGJPKHOD.txt:399-598</c>: device_code, user_code,
|
||||
/// verification_uri(_complete), expires_in, interval).
|
||||
/// 2. It polls <c>connect/token</c> with
|
||||
/// <c>grant_type=urn:ietf:params:oauth:grant-type:device_code</c> and
|
||||
/// <c>device_code</c> (<c>LBNJFPOLCDL_NestedType_DDNJDALGIME.txt:444-451</c>).
|
||||
/// A 400 whose <c>error</c> is <c>authorization_pending</c> or
|
||||
/// <c>slow_down</c> means "keep polling"
|
||||
/// (<c>LBNJFPOLCDL_NestedType_JAEKFNKDNCP.txt:97-116</c>); anything else
|
||||
/// ends the attempt.
|
||||
/// 3. A signed-in client approves the code with
|
||||
/// <c>account/me/remoteauth</c> (form field <c>code</c>).
|
||||
///
|
||||
/// State lives in one PlayerSettings row per authorization, owned by the system
|
||||
/// account: key <c>deviceauthorization:{device_code}</c>, value
|
||||
/// <c>{user_code}|{expiresAtUtc:O}</c>, plus <c>|{approverAccountId}</c> once
|
||||
/// approved. Redeeming deletes the row, so a device code is single-use.
|
||||
/// </summary>
|
||||
public static class DeviceAuthorizations
|
||||
{
|
||||
public const string KeyPrefix = "deviceauthorization:";
|
||||
public static readonly TimeSpan Lifetime = TimeSpan.FromMinutes(15);
|
||||
|
||||
public enum RedeemStatus { Invalid, Expired, Pending, Approved }
|
||||
|
||||
public sealed record Created(string DeviceCode, string UserCode, int ExpiresInSeconds);
|
||||
|
||||
public static async Task<Created> CreateAsync(DorkNetDbContext db, DateTime nowUtc)
|
||||
{
|
||||
var deviceCode = Guid.NewGuid().ToString("N");
|
||||
var userCode = deviceCode[..8].ToUpperInvariant();
|
||||
db.PlayerSettings.Add(new PlayerSettingEntity
|
||||
{
|
||||
PlayerId = RelationshipQueries.SystemAccountId,
|
||||
Key = KeyPrefix + deviceCode,
|
||||
Value = $"{userCode}|{(nowUtc + Lifetime):O}",
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
return new Created(deviceCode, userCode, (int)Lifetime.TotalSeconds);
|
||||
}
|
||||
|
||||
/// <summary>Bind the pending authorization whose user code is
|
||||
/// <paramref name="userCode"/> to <paramref name="approverId"/>. False when
|
||||
/// no live, unapproved authorization carries that code.</summary>
|
||||
public static async Task<bool> ApproveAsync(
|
||||
DorkNetDbContext db, string userCode, long approverId, DateTime nowUtc)
|
||||
{
|
||||
var code = NormaliseUserCode(userCode);
|
||||
if (code.Length == 0) return false;
|
||||
|
||||
var prefix = code + "|";
|
||||
var rows = await db.PlayerSettings
|
||||
.Where(s => s.Key.StartsWith(KeyPrefix) && s.Value.StartsWith(prefix))
|
||||
.ToListAsync();
|
||||
foreach (var row in rows)
|
||||
{
|
||||
var parts = row.Value.Split('|');
|
||||
if (parts.Length < 2 || !TryParseExpiry(parts[1], out var expires) || expires < nowUtc)
|
||||
continue;
|
||||
if (parts.Length >= 3 && parts[2].Length > 0) continue; // already approved
|
||||
row.Value = $"{parts[0]}|{parts[1]}|{approverId}";
|
||||
await db.SaveChangesAsync();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>One poll from the device. Approved authorizations are consumed
|
||||
/// and yield the approving account.</summary>
|
||||
public static async Task<(RedeemStatus Status, long AccountId)> RedeemAsync(
|
||||
DorkNetDbContext db, string? deviceCode, DateTime nowUtc)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(deviceCode)) return (RedeemStatus.Invalid, 0);
|
||||
var row = await db.PlayerSettings
|
||||
.FirstOrDefaultAsync(s => s.Key == KeyPrefix + deviceCode.Trim());
|
||||
if (row is null) return (RedeemStatus.Invalid, 0);
|
||||
|
||||
var parts = row.Value.Split('|');
|
||||
if (parts.Length < 2 || !TryParseExpiry(parts[1], out var expires) || expires < nowUtc)
|
||||
{
|
||||
db.PlayerSettings.Remove(row);
|
||||
await db.SaveChangesAsync();
|
||||
return (RedeemStatus.Expired, 0);
|
||||
}
|
||||
|
||||
if (parts.Length < 3 || !long.TryParse(parts[2], out var approver))
|
||||
return (RedeemStatus.Pending, 0);
|
||||
|
||||
db.PlayerSettings.Remove(row);
|
||||
await db.SaveChangesAsync();
|
||||
return (RedeemStatus.Approved, approver);
|
||||
}
|
||||
|
||||
private static string NormaliseUserCode(string? raw) =>
|
||||
new string((raw ?? string.Empty).Where(char.IsLetterOrDigit).ToArray()).ToUpperInvariant();
|
||||
|
||||
private static bool TryParseExpiry(string raw, out DateTime expiresUtc) =>
|
||||
DateTime.TryParse(raw, null, System.Globalization.DateTimeStyles.RoundtripKind, out expiresUtc);
|
||||
}
|
||||
@@ -10,9 +10,27 @@ namespace DorkNet.Server.Services;
|
||||
public sealed class JoinTimeoutService(
|
||||
PlayerPresenceService presence,
|
||||
NotificationService notifications,
|
||||
PresenceBroadcastService fanout,
|
||||
ILogger<JoinTimeoutService> logger)
|
||||
{
|
||||
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(30);
|
||||
/// <summary>
|
||||
/// How long a join may stay unreported before the server gives up on it.
|
||||
///
|
||||
/// This used to be 30 seconds, which is shorter than a real 2023 room
|
||||
/// load: the client reports its join result only after the room's assets
|
||||
/// have downloaded and the player has spawned, and a large maker room
|
||||
/// regularly takes longer than that (in the field: matchmake/room at
|
||||
/// 09:16:35, Photon joined at 09:16:41, the load still running at 09:17:33).
|
||||
/// The watchdog then cleared presence mid-load, the next heartbeat found
|
||||
/// no room and answered with the dorm, and the client logged "presence
|
||||
/// heartbeat response indicates local presence is out-of-sync" and
|
||||
/// cancelled the load ("OnPlayerPresenceUpdate is cancelling room load").
|
||||
///
|
||||
/// The 2023 client reports its own failures (reportjoinresult with
|
||||
/// FailedTo*) and runs its fallback chain, so this is only a backstop for
|
||||
/// a client that silently stopped mid-join.
|
||||
/// </summary>
|
||||
public static TimeSpan Timeout { get; } = TimeSpan.FromMinutes(3);
|
||||
private readonly ConcurrentDictionary<long, PendingJoin> _pending = new();
|
||||
|
||||
public void MarkPending(long playerId, RoomInstanceDto? room, bool deferPresenceCommit = false)
|
||||
@@ -40,6 +58,10 @@ public sealed class JoinTimeoutService(
|
||||
_ = WatchAsync(playerId, pending);
|
||||
}
|
||||
|
||||
/// <summary>Drop the player's pending join without touching presence —
|
||||
/// the client abandoned it (matchmake/none, logout).</summary>
|
||||
public void Cancel(long playerId, string reason) => ClearForPlayer(playerId, reason);
|
||||
|
||||
public (RoomInstanceDto TargetRoom, bool DeferPresenceCommit)? GetPending(long playerId)
|
||||
{
|
||||
if (!_pending.TryGetValue(playerId, out var pending)) return null;
|
||||
@@ -75,11 +97,15 @@ public sealed class JoinTimeoutService(
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
presence.Clear(playerId);
|
||||
// Room only: the player is still online and about to run the
|
||||
// client's fallback chain. A full Clear also dropped the activity
|
||||
// marker, so friends saw them go offline after every failed join.
|
||||
presence.ClearRoom(playerId);
|
||||
logger.LogWarning(
|
||||
"[join-watchdog] cleared failed join player={PlayerId} room={RoomId}/{RoomName} instance={InstanceId} outcome={Outcome}",
|
||||
playerId, failedRoom.RoomId, failedRoom.Name, instanceId, outcome);
|
||||
return Task.CompletedTask;
|
||||
// Whatever room friends were last told about is gone; correct them.
|
||||
return fanout.PublishCurrentAsync(playerId);
|
||||
}
|
||||
|
||||
private async Task WatchAsync(long playerId, PendingJoin pending)
|
||||
@@ -110,8 +136,9 @@ public sealed class JoinTimeoutService(
|
||||
return;
|
||||
}
|
||||
|
||||
presence.Clear(playerId);
|
||||
presence.ClearRoom(playerId);
|
||||
await notifications.KickPlayerAsync(playerId, "Room join timed out. Returning to dorm.");
|
||||
await fanout.PublishCurrentAsync(playerId);
|
||||
logger.LogWarning(
|
||||
"[join-watchdog] timed out player={PlayerId} room={RoomId}/{RoomName} instance={InstanceId}",
|
||||
playerId, pending.TargetRoom.RoomId, pending.TargetRoom.Name, pending.InstanceId);
|
||||
|
||||
@@ -170,45 +170,25 @@ public class NotificationService(
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Push fresh account payloads to the affected player's
|
||||
/// watch. The 2020 client caches Account/SelfAccount objects in memory;
|
||||
/// without this, username and display-name edits persist server-side
|
||||
/// but the current session keeps rendering the stale cached names until
|
||||
/// a full relog rebuilds the account cache.</summary>
|
||||
/// <summary>Push fresh account payloads after a profile mutation.
|
||||
/// The owner gets <c>SelfAccountUpdate</c> (private self DTO) and every
|
||||
/// connected client gets <c>AccountUpdate</c> (public DTO). The 2023
|
||||
/// client subscribes to both strings (RecNet.Runtime
|
||||
/// OPMAPIOEIFG.txt:267 "AccountUpdate" → Action<Account>, :279
|
||||
/// "SelfAccountUpdate" → Action<SelfAccount>) and refreshes its
|
||||
/// cached account from the payload — the profile-edit PUTs return no
|
||||
/// account, so this push is the ONLY way the new emoji / banner /
|
||||
/// pronouns / identity flags reach the editor's own UI and anyone else
|
||||
/// already looking at the profile. Sending AccountUpdate to the owner
|
||||
/// alone (the previous behaviour) left every other player rendering the
|
||||
/// stale cached account until relog.</summary>
|
||||
public Task AccountChanged(PlayerEntity player)
|
||||
{
|
||||
var account = new RecNetAccount
|
||||
{
|
||||
AccountId = (int)player.Id,
|
||||
RawUsername = player.Username,
|
||||
Username = player.Username,
|
||||
DisplayName = string.IsNullOrWhiteSpace(player.DisplayName) ? player.Username : player.DisplayName,
|
||||
ProfileImage = player.ProfileImageName ?? string.Empty,
|
||||
TreatAsJunior = player.IsJunior,
|
||||
HasBirthday = true,
|
||||
Platforms = 1,
|
||||
CreatedAt = player.CreatedAt,
|
||||
};
|
||||
var selfAccount = new RecNetSelfAccount
|
||||
{
|
||||
AccountId = account.AccountId,
|
||||
RawUsername = account.RawUsername,
|
||||
Username = account.Username,
|
||||
DisplayName = account.DisplayName,
|
||||
ProfileImage = account.ProfileImage,
|
||||
TreatAsJunior = account.TreatAsJunior,
|
||||
HasBirthday = account.HasBirthday,
|
||||
Platforms = account.Platforms,
|
||||
CreatedAt = account.CreatedAt,
|
||||
Email = player.Email ?? string.Empty,
|
||||
Phone = player.Phone ?? string.Empty,
|
||||
Birthday = player.Birthday ?? new DateTime(2000, 1, 1),
|
||||
JuniorState = player.IsJunior ? 1 : 0,
|
||||
ParentAccountId = null,
|
||||
};
|
||||
var account = AccountDtos.Public(player);
|
||||
var selfAccount = AccountDtos.Self(player);
|
||||
|
||||
return Task.WhenAll(
|
||||
NotifyAsync(player.Id, PushNotificationId.SubscriptionUpdateProfile, account),
|
||||
BroadcastAsync(PushNotificationId.SubscriptionUpdateProfile, account),
|
||||
NotifyTypedAsync(player.Id, "SelfAccountUpdate", selfAccount));
|
||||
}
|
||||
|
||||
@@ -271,17 +251,49 @@ public class NotificationService(
|
||||
/// the runtime-type path can pick the wrong contract under certain
|
||||
/// option combos and emit an empty <c>{}</c> body — which silently
|
||||
/// drops the presence update at the watch.</summary>
|
||||
public async Task PlayerPresenceChanged(IEnumerable<long> friendIds, long playerId, RoomInstanceDto? room)
|
||||
public async Task PlayerPresenceChanged(
|
||||
IEnumerable<long> friendIds,
|
||||
long playerId,
|
||||
RoomInstanceDto? room,
|
||||
bool? isOnline = null,
|
||||
int statusVisibility = 0)
|
||||
{
|
||||
var groups = friendIds.Select(NotifyHub.GroupForPlayer).ToArray();
|
||||
if (groups.Length == 0) return;
|
||||
|
||||
var redacted = RoomInstanceDto.Redact(room);
|
||||
var payload = BuildPresenceUpdatePayload(playerId, room, isOnline, statusVisibility);
|
||||
|
||||
logger.LogDebug(
|
||||
"[notify] presence push player={PlayerId} → {FriendCount} friend(s), payload={Payload}",
|
||||
playerId, groups.Length, payload);
|
||||
|
||||
await hub.Clients.Groups(groups).SendAsync(HubMethod, payload);
|
||||
}
|
||||
|
||||
/// <summary>The PresenceUpdate envelope for one player, as a JSON string.
|
||||
///
|
||||
/// <paramref name="isOnline"/> is independent of the room: the 2023
|
||||
/// presence DTO stores it in its own field (LCEHINCCHOB +0x50,
|
||||
/// RecNet.Runtime/LCEHINCCHOB.txt:635-653) and DNKHCBFPLJG treats
|
||||
/// "online with no room" as a real state — a player in the offline dorm
|
||||
/// after matchmake/none. Deriving it from the room, as this used to, sent
|
||||
/// such players to their friends as OFFLINE. When omitted it still falls
|
||||
/// back to "has a room" for callers that only know about room moves.
|
||||
/// An offline frame never carries a room.</summary>
|
||||
public static string BuildPresenceUpdatePayload(
|
||||
long playerId,
|
||||
RoomInstanceDto? room,
|
||||
bool? isOnline = null,
|
||||
int statusVisibility = 0)
|
||||
{
|
||||
var online = isOnline ?? room is not null;
|
||||
var redacted = online ? RoomInstanceDto.Redact(room) : null;
|
||||
var msg = new System.Text.Json.Nodes.JsonObject
|
||||
{
|
||||
["playerId"] = (int)playerId,
|
||||
// Everyone=0 (was 1=FriendsOnly, which hid you from non-friends).
|
||||
["statusVisibility"] = 0,
|
||||
// The player's stored /player/statusvisibility choice (default
|
||||
// 0 = Everyone), the same value their own heartbeat reports.
|
||||
["statusVisibility"] = statusVisibility,
|
||||
["deviceClass"] = 0,
|
||||
["vrMovementMode"] = 0,
|
||||
["roomInstance"] = redacted is null
|
||||
@@ -307,7 +319,7 @@ public class NotificationService(
|
||||
["EncryptVoiceChat"] = redacted.EncryptVoiceChat,
|
||||
},
|
||||
// 2020.12 PlayerPresence.Deserialize requires these.
|
||||
["isOnline"] = redacted is not null,
|
||||
["isOnline"] = online,
|
||||
// 2023.03.21 client build version; must match the joining
|
||||
// client's own version or it reports a version mismatch. MUST be
|
||||
// a STRING ("20230317"): the client's presence DTO reads
|
||||
@@ -325,13 +337,7 @@ public class NotificationService(
|
||||
["Id"] = WireName(PushNotificationId.SubscriptionUpdatePresence),
|
||||
["Msg"] = msg,
|
||||
};
|
||||
var payload = envelope.ToJsonString();
|
||||
|
||||
logger.LogDebug(
|
||||
"[notify] presence push player={PlayerId} → {FriendCount} friend(s), payload={Payload}",
|
||||
playerId, groups.Length, payload);
|
||||
|
||||
await hub.Clients.Groups(groups).SendAsync(HubMethod, payload);
|
||||
return envelope.ToJsonString();
|
||||
}
|
||||
|
||||
public Task PlayerCameOnline(IEnumerable<long> friendIds, long playerId, RoomInstanceDto? room) =>
|
||||
|
||||
@@ -48,6 +48,7 @@ public class PlayerPresenceService
|
||||
private readonly ConcurrentDictionary<long, DateTimeOffset> _localRoomExpires = new();
|
||||
private readonly ConcurrentDictionary<long, DateTimeOffset> _localActiveExpires = new();
|
||||
private readonly ConcurrentDictionary<long, DateTimeOffset> _localBootDormGraceExpires = new();
|
||||
private readonly ConcurrentDictionary<long, DateTimeOffset> _localNoRoomExpires = new();
|
||||
private readonly IConnectionMultiplexer? _redis;
|
||||
private readonly ILogger<PlayerPresenceService>? _log;
|
||||
|
||||
@@ -101,7 +102,9 @@ public class PlayerPresenceService
|
||||
try
|
||||
{
|
||||
var json = JsonSerializer.Serialize(room, JsonOpts);
|
||||
mux.GetDatabase().StringSet(Key(playerId), json, PresenceTtl);
|
||||
var db = mux.GetDatabase();
|
||||
db.StringSet(Key(playerId), json, PresenceTtl);
|
||||
db.KeyDelete(NoRoomKey(playerId));
|
||||
_log?.LogDebug("[presence] set redis player={PlayerId} room={Room} bytes={Bytes}",
|
||||
playerId, room?.Name ?? "<null>", json.Length);
|
||||
}
|
||||
@@ -117,6 +120,7 @@ public class PlayerPresenceService
|
||||
{
|
||||
_local[playerId] = room;
|
||||
_localRoomExpires[playerId] = DateTimeOffset.UtcNow.Add(PresenceTtl);
|
||||
_localNoRoomExpires.TryRemove(playerId, out _);
|
||||
_log?.LogDebug("[presence] set local player={PlayerId} room={Room}",
|
||||
playerId, room?.Name ?? "<null>");
|
||||
}
|
||||
@@ -333,6 +337,7 @@ public class PlayerPresenceService
|
||||
db.KeyDelete(Key(playerId));
|
||||
db.KeyDelete(ActiveKey(playerId));
|
||||
db.KeyDelete(BootDormGraceKey(playerId));
|
||||
db.KeyDelete(NoRoomKey(playerId));
|
||||
db.SetRemove(ActiveSetKey, playerId);
|
||||
}
|
||||
else
|
||||
@@ -341,9 +346,119 @@ public class PlayerPresenceService
|
||||
_localRoomExpires.TryRemove(playerId, out _);
|
||||
_localActiveExpires.TryRemove(playerId, out _);
|
||||
_localBootDormGraceExpires.TryRemove(playerId, out _);
|
||||
_localNoRoomExpires.TryRemove(playerId, out _);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Forget the player's room but keep everything that says they
|
||||
/// are still here (activity marker, login lock, boot grace). Used when a
|
||||
/// join fails or times out: the player is still online and about to run
|
||||
/// the client's fallback chain, so friends must not see them go offline,
|
||||
/// they just must not see a room the player never reached.</summary>
|
||||
public void ClearRoom(long playerId)
|
||||
{
|
||||
if (_redis is { } mux)
|
||||
{
|
||||
try
|
||||
{
|
||||
mux.GetDatabase().KeyDelete(Key(playerId));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log?.LogWarning(ex, "[presence] redis room DEL failed for player={PlayerId}", playerId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
_local.TryRemove(playerId, out _);
|
||||
_localRoomExpires.TryRemove(playerId, out _);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Record that the player deliberately left online matchmaking: they are
|
||||
/// online, but in no room instance.
|
||||
///
|
||||
/// The 2023 client does this in TryGoToOfflineRoom
|
||||
/// (RecNet.Runtime/RecNet/Matchmaking.txt:12677, the only caller of
|
||||
/// <c>matchmake/none</c> at :13279): it swaps its cached presence to the
|
||||
/// local offline instance (RoomInstanceId -1) and posts matchmake/none,
|
||||
/// ignoring the reply (<c><TryGoToOfflineRoom>b__119_0</c> returns a
|
||||
/// constant). From then on the heartbeat handler
|
||||
/// (Matchmaking.txt:18103, compare at :18415-18467) treats a negative RoomInstanceId like no
|
||||
/// room (HCHDEHIGEBE.GIICPDHJOOH = <c>RoomInstanceId < 0</c>) and is
|
||||
/// only in sync while the server answers <c>roomInstance: null</c>. Any
|
||||
/// room here — the old one, or a synthesised dorm — reads as out-of-sync
|
||||
/// and the client cancels its offline load to chase the server's room.
|
||||
/// The marker keeps the heartbeat's dorm fallback from firing for this
|
||||
/// player until they matchmake somewhere again.
|
||||
/// </summary>
|
||||
public void SetNoRoom(long playerId)
|
||||
{
|
||||
if (_redis is { } mux)
|
||||
{
|
||||
try
|
||||
{
|
||||
var db = mux.GetDatabase();
|
||||
db.KeyDelete(Key(playerId));
|
||||
db.StringSet(NoRoomKey(playerId), "1", PresenceTtl);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log?.LogWarning(ex, "[presence] redis no-room SET failed for player={PlayerId}", playerId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
_local.TryRemove(playerId, out _);
|
||||
_localRoomExpires.TryRemove(playerId, out _);
|
||||
_localNoRoomExpires[playerId] = DateTimeOffset.UtcNow.Add(PresenceTtl);
|
||||
}
|
||||
|
||||
/// <summary>True while the player is online with no room after
|
||||
/// <see cref="SetNoRoom"/>. Cleared by the next <see cref="SetRoom"/> or
|
||||
/// <see cref="Clear"/>.</summary>
|
||||
public bool IsInNoRoom(long playerId)
|
||||
{
|
||||
if (_redis is { } mux)
|
||||
{
|
||||
try
|
||||
{
|
||||
return mux.GetDatabase().KeyExists(NoRoomKey(playerId));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log?.LogWarning(ex, "[presence] redis no-room GET failed for player={PlayerId}", playerId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!_localNoRoomExpires.TryGetValue(playerId, out var expires)) return false;
|
||||
if (expires > DateTimeOffset.UtcNow) return true;
|
||||
_localNoRoomExpires.TryRemove(playerId, out _);
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Keep the no-room marker alive while the player keeps
|
||||
/// heartbeating from the offline room.</summary>
|
||||
public void TouchNoRoom(long playerId)
|
||||
{
|
||||
if (_redis is { } mux)
|
||||
{
|
||||
try
|
||||
{
|
||||
mux.GetDatabase().KeyExpire(NoRoomKey(playerId), PresenceTtl);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log?.LogWarning(ex, "[presence] redis no-room EXPIRE failed for player={PlayerId}", playerId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (_localNoRoomExpires.ContainsKey(playerId))
|
||||
_localNoRoomExpires[playerId] = DateTimeOffset.UtcNow.Add(PresenceTtl);
|
||||
}
|
||||
|
||||
/// <summary>List the currently-active instances of a given room,
|
||||
/// grouped by PhotonRoomId, with the player ids currently presenced
|
||||
/// in each. Drives the watch's RoomInstanceBrowser
|
||||
@@ -386,6 +501,7 @@ public class PlayerPresenceService
|
||||
private static string Key(long playerId) => $"presence:{playerId}";
|
||||
private static string ActiveKey(long playerId) => $"presence-active:{playerId}";
|
||||
private static string BootDormGraceKey(long playerId) => $"presence-boot-dorm-grace:{playerId}";
|
||||
private static string NoRoomKey(long playerId) => $"presence-noroom:{playerId}";
|
||||
private const string ActiveSetKey = "presence-active";
|
||||
|
||||
// ── LoginLock single-session enforcement ─────────────────────────
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
using DorkNet.Server.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace DorkNet.Server.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Online-status visibility enum (2023 client, dump.cs IPLLHDINPBL). Picked in
|
||||
/// the profile's "Set Online Status Visibility" dialog (Assembly-CSharp
|
||||
/// RRUI/Data/BaseAccountModel.txt, ShowChangeOnlineStatusVisibilityDialog) and
|
||||
/// sent as PUT player/statusvisibility form statusVisibility (RecNet.Runtime
|
||||
/// RecNet/Matchmaking.txt:17147-17161).
|
||||
/// </summary>
|
||||
public static class PresenceVisibility
|
||||
{
|
||||
public const int Public = 0;
|
||||
public const int FriendsOnly = 1;
|
||||
public const int FavoriteFriendsOnly = 2;
|
||||
public const int Offline = 3;
|
||||
|
||||
public static bool IsValid(int value) => value is >= Public and <= Offline;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tells a player's friends when that player's presence changes, from one
|
||||
/// place, so every transition sends the same frame built from the same state.
|
||||
///
|
||||
/// Before this existed only the immediate <c>/goto</c> commit pushed anything.
|
||||
/// Deferred commits (matchmake/dorm, private-instance hops) committed in
|
||||
/// reportjoinresult without a push, matchmake/none and logout changed presence
|
||||
/// silently, and SignalR connect/disconnect pushed with <c>isOnline</c> derived
|
||||
/// from "has a room" — so a friend's list showed people in rooms they had left,
|
||||
/// offline while they sat in their dorm, and never noticed a status-visibility
|
||||
/// change.
|
||||
///
|
||||
/// The client's PresenceUpdate handler (Matchmaking.EGPABEDPEJO, registered at
|
||||
/// RecNet.Runtime/RecNet/Matchmaking.txt:1436) feeds the payload straight into
|
||||
/// FLAOOBNJAAD (:6917), the same routine the heartbeat uses to apply an out-of-sync
|
||||
/// correction (:18467). A frame about the LOCAL player therefore moves that player, so
|
||||
/// these frames go to friends only, never to the subject.
|
||||
/// </summary>
|
||||
public sealed class PresenceBroadcastService(
|
||||
IServiceScopeFactory scopes,
|
||||
PlayerPresenceService presence,
|
||||
OnlinePresenceService connections,
|
||||
NotificationService notifications,
|
||||
ILogger<PresenceBroadcastService> logger)
|
||||
{
|
||||
/// <summary>PlayerSettings key for the persisted
|
||||
/// <c>/player/statusvisibility</c> choice.</summary>
|
||||
public const string StatusVisibilityPrefKey = "pref:statusVisibility";
|
||||
|
||||
/// <summary>A player counts as online while they heartbeat or hold a
|
||||
/// SignalR connection. Being in a room is NOT required: the client's
|
||||
/// presence DTO carries <c>isOnline</c> separately (LCEHINCCHOB +0x50) and
|
||||
/// DNKHCBFPLJG reads "online, in no room / the offline room" as a valid
|
||||
/// state, which is exactly what a player in the offline dorm is.</summary>
|
||||
public bool IsOnline(long playerId) =>
|
||||
presence.IsRecentlyActive(playerId) || connections.GetConnections(playerId).Count > 0;
|
||||
|
||||
/// <summary>Push the player's current presence (online state + committed
|
||||
/// room) to every friend.</summary>
|
||||
public Task PublishCurrentAsync(long playerId) => PublishAsync(playerId, forceOffline: false);
|
||||
|
||||
/// <summary>Push an offline frame regardless of any lingering markers —
|
||||
/// for an explicit logout.</summary>
|
||||
public Task PublishOfflineAsync(long playerId) => PublishAsync(playerId, forceOffline: true);
|
||||
|
||||
/// <summary>
|
||||
/// The last SignalR connection dropped but the player may still be
|
||||
/// playing: the socket can be closed under a live session ("RecNet
|
||||
/// HubConnection error: ... TCP Stream closed unexpectedly by the remote
|
||||
/// server") and the client reconnects on its own. Pushing offline right
|
||||
/// away greys the player out for every friend until that reconnect. Wait
|
||||
/// one activity window and only then decide: still silent means the game
|
||||
/// is really gone.
|
||||
/// </summary>
|
||||
public void ScheduleOfflineCheck(long playerId)
|
||||
{
|
||||
var delay = PlayerPresenceService.ActivityTtl + TimeSpan.FromSeconds(5);
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(delay);
|
||||
if (IsOnline(playerId)) return;
|
||||
await PublishAsync(playerId, forceOffline: true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "[presence-fanout] deferred offline check failed player={PlayerId}", playerId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async Task PublishAsync(long playerId, bool forceOffline)
|
||||
{
|
||||
if (playerId <= 0) return;
|
||||
try
|
||||
{
|
||||
await using var scope = scopes.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
var settings = scope.ServiceProvider.GetRequiredService<ServerSettingsService>();
|
||||
|
||||
var friendIds = (await RelationshipQueries.EffectiveFriendIdsAsync(db, settings, playerId))
|
||||
.Where(id => id != playerId)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
if (friendIds.Count == 0) return;
|
||||
|
||||
var online = !forceOffline && IsOnline(playerId);
|
||||
var room = online ? presence.GetRoom(playerId) : null;
|
||||
var visibility = await GetStatusVisibilityAsync(db, playerId);
|
||||
|
||||
// Honour the player's visibility choice the same way GET /player
|
||||
// does (PlayerPresenceController.ResolveHiddenAsync): recipients
|
||||
// who may not see them get the frame a genuinely offline player
|
||||
// produces. Every recipient here is already a friend, so
|
||||
// FriendsOnly needs no split; FavoriteFriendsOnly reaches only the
|
||||
// friends this player favorited (Favorited lives on the
|
||||
// favoriter's own row, RequesterId = favoriter).
|
||||
var favorited = visibility == PresenceVisibility.FavoriteFriendsOnly
|
||||
? (await db.Relationships.AsNoTracking()
|
||||
.Where(r => r.RequesterId == playerId && r.Favorited && friendIds.Contains(r.TargetId))
|
||||
.Select(r => r.TargetId)
|
||||
.ToListAsync()).ToHashSet()
|
||||
: new HashSet<long>();
|
||||
var (visibleTo, hiddenFrom) = SplitRecipients(friendIds, visibility, favorited);
|
||||
|
||||
logger.LogInformation(
|
||||
"[presence-fanout] player={PlayerId} online={Online} room={RoomId} instance={InstanceId} visibility={Visibility} -> {FriendCount} friend(s), {HiddenCount} hidden",
|
||||
playerId, online, room?.RoomId, room?.RoomInstanceId, visibility, visibleTo.Count, hiddenFrom.Count);
|
||||
|
||||
if (visibleTo.Count > 0)
|
||||
await notifications.PlayerPresenceChanged(visibleTo, playerId, room, online, visibility);
|
||||
if (hiddenFrom.Count > 0)
|
||||
await notifications.PlayerPresenceChanged(hiddenFrom, playerId, null, false, visibility);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Best-effort: a fan-out failure must never fail the matchmake,
|
||||
// logout or hub event that triggered it.
|
||||
logger.LogWarning(ex, "[presence-fanout] push failed player={PlayerId}", playerId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Split a player's friends into those who may see their real
|
||||
/// presence and those who must see them offline, per their status
|
||||
/// visibility. <paramref name="favorited"/> holds the friends the player
|
||||
/// favorited; it only matters for FavoriteFriendsOnly.</summary>
|
||||
public static (List<long> VisibleTo, List<long> HiddenFrom) SplitRecipients(
|
||||
IReadOnlyList<long> friendIds, int visibility, IReadOnlySet<long> favorited) =>
|
||||
visibility switch
|
||||
{
|
||||
PresenceVisibility.Offline => (new List<long>(), friendIds.ToList()),
|
||||
PresenceVisibility.FavoriteFriendsOnly => (
|
||||
friendIds.Where(favorited.Contains).ToList(),
|
||||
friendIds.Where(id => !favorited.Contains(id)).ToList()),
|
||||
_ => (friendIds.ToList(), new List<long>()),
|
||||
};
|
||||
|
||||
/// <summary>Stored status visibility for one player (0 = default).</summary>
|
||||
public static async Task<int> GetStatusVisibilityAsync(DorkNetDbContext db, long playerId)
|
||||
{
|
||||
var raw = await db.PlayerSettings.AsNoTracking()
|
||||
.Where(s => s.PlayerId == playerId && s.Key == StatusVisibilityPrefKey)
|
||||
.Select(s => s.Value)
|
||||
.FirstOrDefaultAsync();
|
||||
return int.TryParse(raw, out var v) && PresenceVisibility.IsValid(v) ? v : PresenceVisibility.Public;
|
||||
}
|
||||
|
||||
/// <summary>Stored status visibility for many players in one query.
|
||||
/// Players with no stored choice are absent from the result.</summary>
|
||||
public static async Task<Dictionary<long, int>> GetStatusVisibilitiesAsync(
|
||||
DorkNetDbContext db, IReadOnlyCollection<long> playerIds)
|
||||
{
|
||||
if (playerIds.Count == 0) return new Dictionary<long, int>();
|
||||
var rows = await db.PlayerSettings.AsNoTracking()
|
||||
.Where(s => s.Key == StatusVisibilityPrefKey && playerIds.Contains(s.PlayerId))
|
||||
.Select(s => new { s.PlayerId, s.Value })
|
||||
.ToListAsync();
|
||||
var result = new Dictionary<long, int>(rows.Count);
|
||||
foreach (var row in rows)
|
||||
{
|
||||
if (int.TryParse(row.Value, out var v) && PresenceVisibility.IsValid(v)) result[row.PlayerId] = v;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -536,6 +536,8 @@ public static class DatabaseBootstrap
|
||||
@"ALTER TABLE ""ServerSettings"" ADD COLUMN IF NOT EXISTS ""BaseRoomNamesJson"" text NOT NULL DEFAULT '';");
|
||||
await db.Database.ExecuteSqlRawAsync(
|
||||
@"ALTER TABLE ""ServerSettings"" ADD COLUMN IF NOT EXISTS ""RoomBlobVersionClampDisabled"" boolean NOT NULL DEFAULT false;");
|
||||
await db.Database.ExecuteSqlRawAsync(
|
||||
@"ALTER TABLE ""ServerSettings"" ADD COLUMN IF NOT EXISTS ""CircuitsV2LimitsJson"" text NOT NULL DEFAULT '';");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -646,6 +648,18 @@ 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 ""CircuitsV2LimitsJson"" 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)
|
||||
@@ -855,6 +869,11 @@ public static class DatabaseBootstrap
|
||||
@"ALTER TABLE ""Clubs"" ADD COLUMN IF NOT EXISTS ""ClubChatEnabled"" boolean NOT NULL DEFAULT true;",
|
||||
@"ALTER TABLE ""ChatMessages"" ADD COLUMN IF NOT EXISTS ""ModerationState"" integer NOT NULL DEFAULT 0;",
|
||||
@"ALTER TABLE ""TestCases"" ADD COLUMN IF NOT EXISTS ""CommentsJson"" text NOT NULL DEFAULT '[]';",
|
||||
// 2023 profile edits (PUT account/me/{emoji,bannerimage,personalpronouns,identityflags}).
|
||||
@"ALTER TABLE ""Players"" ADD COLUMN IF NOT EXISTS ""DisplayEmoji"" character varying(32) NOT NULL DEFAULT '';",
|
||||
@"ALTER TABLE ""Players"" ADD COLUMN IF NOT EXISTS ""BannerImageName"" character varying(256) NULL;",
|
||||
@"ALTER TABLE ""Players"" ADD COLUMN IF NOT EXISTS ""PersonalPronouns"" integer NOT NULL DEFAULT 0;",
|
||||
@"ALTER TABLE ""Players"" ADD COLUMN IF NOT EXISTS ""IdentityFlags"" integer NOT NULL DEFAULT 0;",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS "ClubRolePermissions" (
|
||||
"Id" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
@@ -911,6 +930,10 @@ public static class DatabaseBootstrap
|
||||
// valid JSON and made the comment reader throw. Idempotent: ''
|
||||
// is never a legitimate value for this column.
|
||||
@"UPDATE ""TestCases"" SET ""CommentsJson"" = '[]' WHERE ""CommentsJson"" = '';",
|
||||
@"ALTER TABLE ""Players"" ADD COLUMN ""DisplayEmoji"" TEXT NOT NULL DEFAULT '';",
|
||||
@"ALTER TABLE ""Players"" ADD COLUMN ""BannerImageName"" TEXT NULL;",
|
||||
@"ALTER TABLE ""Players"" ADD COLUMN ""PersonalPronouns"" INTEGER NOT NULL DEFAULT 0;",
|
||||
@"ALTER TABLE ""Players"" ADD COLUMN ""IdentityFlags"" INTEGER NOT NULL DEFAULT 0;",
|
||||
};
|
||||
foreach (var sql in sqlite)
|
||||
{
|
||||
|
||||
@@ -234,6 +234,8 @@ public static class ServiceCollectionExtensions
|
||||
services.AddSingleton<PlayerPresenceService>();
|
||||
services.AddSingleton<JoinTimeoutService>();
|
||||
services.AddSingleton<OnlinePresenceService>();
|
||||
// Friend presence fan-out; resolves its scoped DB per push.
|
||||
services.AddSingleton<PresenceBroadcastService>();
|
||||
services.AddSingleton<PlayerLogService>();
|
||||
// S3-compatible object storage (Garage in production, MinIO/disk in dev)
|
||||
// — holds profile images + room-blob bytes. Stateless wrapper around
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
# DorkNet — `march-2023-03-21`
|
||||
|
||||
This is the **December 2020.12.18** branch of DorkNet — a self-hostable
|
||||
This is the **March 2023** branch of DorkNet — a self-hostable
|
||||
reimplementation of the Rec Room backend tuned to the wire protocol of
|
||||
the **Rec Room 2020.12.18** client build.
|
||||
the **Rec Room 2023.03.21** PC client build (`appVersion` `20230317`).
|
||||
|
||||
> ⚠️ **Not affiliated with Rec Room Inc. or Against Gravity.**
|
||||
> See [DISCLAIMER](../../blob/main/DISCLAIMER.md). DorkNet ships no Rec
|
||||
> Room game assets or modified binaries — you supply your own
|
||||
> legally-acquired Rec Room 2020.12.18 install.
|
||||
> legally-acquired Rec Room 2023.03.21 install.
|
||||
|
||||
If you're not sure which DorkNet branch you want, start at the
|
||||
[main branch's chart](../../blob/main/BRANCHES.md). The launcher on
|
||||
@@ -19,23 +19,44 @@ main also auto-detects your install.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Client build** | Rec Room 2020.12.18 |
|
||||
| **Version key** (sent as `X-DorkNet-Version` header) | `december_2020_12_18` |
|
||||
| **Version plugin** | `DorkNet.Server/Versions/Late2020/Late2020VersionPlugin.cs` |
|
||||
| **Schema** | One EF `Initial` migration; SQLite + Postgres both work |
|
||||
| **Client build** | Rec Room 2023.03.21 (PC, IL2CPP); reports `appVersion` `20230317` |
|
||||
| **Accepted build IDs** (`api/versioncheck/v4?v=`, mapped in `appsettings.json` → `BuildIdToVersionKey`) | `20230317`, `20230321` |
|
||||
| **Version key** (sent as `X-DorkNet-Version` header) | `march_2023_03_21` |
|
||||
| **Version plugin** | `DorkNet.Server/Versions/Late2020/Late2020VersionPlugin.cs` (generation `March2023`; the folder name is historical) |
|
||||
| **Schema** | EF migrations plus an idempotent schema bootstrap; SQLite + Postgres both work |
|
||||
| **Runtime topology** | Gateway-fronted microservices with dedicated service slices and a monolith fallback |
|
||||
| **Diverges from `march-2020-03-10` by** | ~170 files |
|
||||
| **Diverges from `december-2020-12-18` by** | ~220 files |
|
||||
|
||||
Subsystems present on this branch that the March branch doesn't have:
|
||||
This branch serves **only** the 2023 client. It does not advertise or
|
||||
answer the older 2020 build keys; 2020 and 2018 clients have their own
|
||||
branches.
|
||||
|
||||
- Chat threads + group DMs (`ChatThreads`, `ChatThreadMembers`)
|
||||
- Clubs surface (`clubs.{apex}/*` — announcements, categories, subscriptions)
|
||||
- Playlists (curated + user-created)
|
||||
- Weekly challenges
|
||||
- Room keys (purchasable room access)
|
||||
- Room roles (per-room co-owner / moderator / host grants)
|
||||
- Leaderboard channel metadata
|
||||
- Loading-screen tips
|
||||
What the 2023 client gets here that the 2020 branches don't have:
|
||||
|
||||
- **Every 2023 client route.** All HTTP routes the 2023.03.21 client can
|
||||
issue reach a handler under the verb it actually uses, and
|
||||
`DorkNet.Server.Tests/ClientRouteCoverageTests.cs` fails the build if one
|
||||
regresses. Routes, verbs and wire shapes are in
|
||||
[docs/recroom-2023-client-api-complete.md](docs/recroom-2023-client-api-complete.md).
|
||||
- **Realtime over the SignalR hub** (`hub/v1` on the notify host): numeric
|
||||
push ids, presence updates to friends that honour each player's
|
||||
online-status visibility, and account updates when someone edits their
|
||||
profile.
|
||||
- **Room saves and sub-rooms** through the storage host, plus cloning and
|
||||
private instances:
|
||||
[docs/recroom-2023-room-save.md](docs/recroom-2023-room-save.md).
|
||||
- **CircuitsV2 limits** served as gameconfig and lifted well past retail
|
||||
(network budgets, chips, compute, cloud values, ink):
|
||||
[docs/recroom-2023-circuitsv2-limits.md](docs/recroom-2023-circuitsv2-limits.md).
|
||||
- **Room shops**: room consumables, room currencies and room keys:
|
||||
[docs/recroom-2023-room-consumables.md](docs/recroom-2023-room-consumables.md).
|
||||
- **2023 wardrobe**: owned items, color variants keyed by combination GUID,
|
||||
saved outfits and custom avatar items (custom shirts).
|
||||
- **2023 profile**: bio, display emoji, pronouns, identity flags, banner
|
||||
image and online-status visibility.
|
||||
- Inventions, clubs (roles, permissions, gallery), chat threads, playlists,
|
||||
quest reward chests, leaderboards, and the influencer and moderation
|
||||
surfaces.
|
||||
|
||||
---
|
||||
|
||||
@@ -50,7 +71,7 @@ dotnet tool install --global dotnet-ef
|
||||
**2. Clone this branch:**
|
||||
|
||||
```pwsh
|
||||
git clone --branch december-2020-12-18 https://github.com/DorkSquadRR/DorkNet
|
||||
git clone --branch march-2023-03-21 https://github.com/DorkSquadRR/DorkNet
|
||||
cd DorkNet
|
||||
```
|
||||
|
||||
@@ -92,53 +113,50 @@ notes, see
|
||||
|
||||
## Patching your client
|
||||
|
||||
```pwsh
|
||||
.\tools\install-melon.ps1 `
|
||||
-RecRoomPath "C:\Path\To\Recroom_Release_Data" `
|
||||
-PhotonAppId "<your-photon-realtime-app-id>" `
|
||||
-PhotonVoiceAppId "<your-photon-voice-app-id>"
|
||||
The 2023.03.21 PC build is laid out like this:
|
||||
|
||||
```
|
||||
<install>/
|
||||
RecRoom.exe <- the game; run this
|
||||
RecRoom_Data/ <- globalgamemanagers, resources.assets, levelN
|
||||
```
|
||||
|
||||
This installs the MelonLoader mod (`DorkNet.ClientMod`) into the
|
||||
client's `Mods/` folder and writes its config to
|
||||
`MelonLoader/UserData/dorknet-clientmod.json`. The mod rewrites all
|
||||
`*.rec.net` URIs to your configured DorkNet apex, swaps the Photon
|
||||
AppIds, and bypasses TLS verification on the client's HTTPS calls so
|
||||
self-signed certs work. (First run prints "launch the game once"; do
|
||||
that, then re-run with `-ResumeBuild` to finish — see the script's
|
||||
`-?` help.)
|
||||
The client is IL2CPP, so it is patched at runtime with **MelonLoader
|
||||
0.6.x** and the `DorkNet.ClientMod` mod:
|
||||
|
||||
### Standalone Quest build (experimental)
|
||||
1. Install MelonLoader 0.6.x into the install folder, launch
|
||||
`RecRoom.exe` once so MelonLoader generates its IL2CPP assemblies, then
|
||||
quit.
|
||||
2. Build the mod and copy it into `Mods\` next to `RecRoom.exe`:
|
||||
|
||||
The December Quest APK is also IL2CPP and can build the same
|
||||
`DorkNet.ClientMod` source against LemonLoader's Android runtime. The
|
||||
current Quest path is still device-tested manually: build the mod as a
|
||||
net8 LemonLoader DLL, install it into a LemonLoader-patched APK, then
|
||||
sideload the resigned APK.
|
||||
```pwsh
|
||||
$env:DOTNET_ROOT = "C:\Program Files\dotnet"
|
||||
dotnet build .\DorkNet.ClientMod -c Release
|
||||
Copy-Item .\DorkNet.ClientMod\bin\Release\DorkNet.ClientMod.dll "<install>\Mods\"
|
||||
```
|
||||
|
||||
From this branch:
|
||||
Close the game first; the copy fails while `RecRoom.exe` holds the DLL.
|
||||
3. Copy `DorkNet.ClientMod\dorknet-clientmod.json.template` to
|
||||
`<install>\UserData\dorknet-clientmod.json` (MelonLoader 0.6 keeps
|
||||
`UserData\` at the install root), then fill in your DorkNet apex and
|
||||
Photon AppIds. The config is read once at startup.
|
||||
|
||||
```pwsh
|
||||
dotnet build .\DorkNet.ClientMod\DorkNet.ClientMod.csproj `
|
||||
-p:TargetFrameworks=net8.0 `
|
||||
-p:MelonLoaderDir="C:\path\to\LemonLoader\melon_data\MelonLoader"
|
||||
```
|
||||
The mod rewrites all `*.rec.net` URIs to your DorkNet apex, swaps the
|
||||
Photon AppIds (they are hard-coded in the IL2CPP binary, so this has to
|
||||
happen at runtime), and can bypass TLS verification on the client's HTTPS
|
||||
calls so self-signed certs work.
|
||||
|
||||
Output lands at
|
||||
`DorkNet.ClientMod\bin\Debug\net8.0\DorkNet.ClientMod.dll`. Desktop
|
||||
builds still default to net6 and keep the historical flat output path.
|
||||
> `tools\install-melon.ps1` automates these steps for the **2020** layout
|
||||
> (`Recroom_Release.exe` / `Recroom_Release_Data`). It does not recognise
|
||||
> the 2023 `RecRoom.exe` / `RecRoom_Data` layout yet, so use the manual
|
||||
> steps above for 2023.
|
||||
|
||||
Quest notes:
|
||||
### Quest
|
||||
|
||||
- Target package: `com.AgainstGravity.RecRoom`.
|
||||
- Use a public HTTPS DorkNet host; keep `"EnableTlsTrustBypass": false`
|
||||
for Quest builds.
|
||||
- The Dec Photon hooks are discovered from the unique
|
||||
`PUNNetworkManager` methods returning `AppSettings` and
|
||||
`AuthenticationValues`, with the known Dec obfuscated names as fallback.
|
||||
- Standalone APK patching/resigning is not yet wrapped by
|
||||
`install-melon.ps1`; this is the porting path, not a public one-click
|
||||
installer yet.
|
||||
A Quest client for this branch is in progress and not usable yet. The
|
||||
archive holds a Quest build of the same release (version `20230317`), so
|
||||
Quest and PC players will be on matching versions once it lands. Until
|
||||
then, this branch supports the PC client only.
|
||||
|
||||
### Debugging (opt-in — `DorkNet.DebugMod`)
|
||||
|
||||
@@ -204,6 +222,87 @@ filter for extra players, so player indexes above the four baked spawn
|
||||
slots can reuse existing quest spawn points instead of being left out.
|
||||
Default `0` (off).
|
||||
|
||||
### Rec Royale night mode (opt-in)
|
||||
|
||||
Rec Royale never shipped a night variant, but the client carries the room
|
||||
"mood" engine (`RecRoom.Core.Scene.SceneMoodSettings`) and ready-made
|
||||
night `MoodSetting` assets in `resources.assets`. Set
|
||||
`"RecRoyaleNightMode"` in `dorknet-clientmod.json` and the mod applies one
|
||||
of those moods (sky dome, sun, fog, bloom and colour grading) as soon as
|
||||
the `RecRoyale_Frontier` scene finishes loading, and unlocks the weapon
|
||||
flashlight prefab while it is on:
|
||||
|
||||
```json
|
||||
{
|
||||
"RecRoyaleNightMode": true,
|
||||
"RecRoyaleNightMood": "Night_Calm_Outdoor_Mood",
|
||||
"RecRoyaleNightScenes": ["RecRoyale"],
|
||||
"RecRoyaleNightFlashlights": true,
|
||||
"RecRoyaleNightFog": false,
|
||||
"RecRoyaleNightExposure": -3.5,
|
||||
"RecRoyaleNightSunScale": 0.15,
|
||||
"RecRoyaleNightAmbientScale": 0.2,
|
||||
"RecRoyaleNightForcePostProcessing": true,
|
||||
"RecRoyaleNightMatchSunLights": true,
|
||||
"RecRoyaleNightDisableFog": false,
|
||||
"RecRoyaleNightHideObjects": ["Cloud"],
|
||||
"RecRoyaleNightToggleKey": "N"
|
||||
}
|
||||
```
|
||||
|
||||
`RecRoyaleNightDisableFog` turns fog off entirely while night is on
|
||||
(`RenderSettings.fog` forced off and the mood's fog block pushed out of
|
||||
range); it is ignored when `RecRoyaleNightFog` is `true`.
|
||||
`RecRoyaleNightHideObjects` hides every renderer whose object or material
|
||||
name contains one of the entries; the default hides Frontier's daytime
|
||||
cloud meshes (`CloudPlane`, `Clouds`, the `_Cloud_RecRoyale_Frontier_Mat`
|
||||
static batch). Both are restored on toggle-off or scene change.
|
||||
|
||||
The mood engine only ever drives `RenderSettings.sun`, which on Frontier
|
||||
is the sun-disc light, not the map's key light, so daylight would keep
|
||||
casting. `RecRoyaleNightMatchSunLights` (default `true`) copies the
|
||||
engine-driven sun's rotation and colour onto every other directional
|
||||
light and scales their intensity by `RecRoyaleNightSunScale`.
|
||||
|
||||
Two more RRO quirks the mod works around: the mood skybox material the
|
||||
engine swaps in (`MoodConfig.SkyBoxMaterial`) is not loaded outside
|
||||
custom-room scenes, so the mod builds one on the `Custom/SkyBox` shader
|
||||
and hands it to the engine (otherwise the sky is plain black and the
|
||||
engine refuses to touch sky colours or the sun); and post-exposure only
|
||||
shows when the scene Volume is on and the camera renders
|
||||
post-processing, which `RecRoyaleNightForcePostProcessing` (default
|
||||
`true`) forces while night mode is active.
|
||||
|
||||
Brightness: RRO maps are lightmapped, and baked lighting ignores the sun
|
||||
and ambient changes a mood makes, so the mod also darkens the mood data
|
||||
itself before applying it. `RecRoyaleNightExposure` is an EV offset added
|
||||
to the mood's colour-grading post-exposure (a full-frame post effect, so
|
||||
it dims lightmapped surfaces too; `0` = mood default), and
|
||||
`RecRoyaleNightSunScale` / `RecRoyaleNightAmbientScale` multiply the
|
||||
mood's sun and ambient intensities (`1` = unchanged). Raise the exposure
|
||||
toward `0` if it is too dark.
|
||||
|
||||
RRO scenes have no room data blob, so the mood engine starts disabled
|
||||
there; the mod replays the room-data loader's bring-up (disable, capture
|
||||
the scene's own lighting, enable), points `RenderSettings.sun` at the
|
||||
scene's directional light (Frontier ships without the reference, which
|
||||
would otherwise skip every sun change), and re-captures once the mood
|
||||
skybox material is in place so the sky colours apply. `RecRoyaleNightFog`
|
||||
is off by default: the mood's fog block is swapped for one captured from
|
||||
the scene, so fog stays as the map author left it.
|
||||
|
||||
Shipped moods: `Night_Calm_Outdoor_Mood` ("Calm Night"),
|
||||
`Night_Spooky_Outdoor_Mood` ("Spooky Night"), `Night_Wild_Outdoor_Mood`
|
||||
("Wild Night"), `Night_StuntRunner_Outdoor_Mood` and `OuterSpace_Mood`.
|
||||
`RecRoyaleNightToggleKey` is an optional `UnityEngine.KeyCode` name that
|
||||
flips night on/off in-game (off restores the scene's own mood); setting a
|
||||
key also arms the feature when `RecRoyaleNightMode` is `false`. This is
|
||||
purely **client-side**: the mod calls the mood engine's apply step
|
||||
directly and never touches the networked room-mood index, so other players
|
||||
keep their own view. Baked lightmaps are not changed, so interiors stay
|
||||
lit; the mood's colour grading does the darkening. Default `false` (off).
|
||||
Wire-level notes: `docs/recroom-2023-scene-moods.md`.
|
||||
|
||||
---
|
||||
|
||||
## Code map
|
||||
@@ -275,8 +374,8 @@ troubleshooting, see [docs/admin.md](docs/admin.md).
|
||||
|
||||
## Database & migrations
|
||||
|
||||
One EF migration (`Migrations/<timestamp>_Initial.cs`) captures the
|
||||
schema. SQLite dev DBs apply it via `Database.Migrate()`; Postgres uses
|
||||
The consolidated `Initial` migration plus the migrations added since
|
||||
(`Migrations/`) capture the schema. SQLite dev DBs apply it via `Database.Migrate()`; Postgres uses
|
||||
`EnsureCreated()` behind a transaction-scoped advisory lock. The
|
||||
entity model is the single source of truth — to add a column, edit the
|
||||
entity, boot, done (on SQLite — Postgres prod needs a migration).
|
||||
@@ -344,11 +443,12 @@ public internet:
|
||||
- **First account becomes admin.** The first account created on a fresh DB is
|
||||
granted `IsAdmin` so there's always a root admin. On a public server, create
|
||||
your admin account first and verify no one else beat you to it.
|
||||
- **Online status = Everyone.** Presence reports `StatusVisibility = 0`
|
||||
(Everyone) so every player appears online to every other player, matching
|
||||
the [everyone-is-friends](#everyone-is-friends-toggle) convenience. A public
|
||||
server may instead want to persist and honor each player's own visibility
|
||||
preference.
|
||||
- **Online status visibility.** Each player's in-game choice (Public,
|
||||
Friends only, Favorite friends only, Appear offline) is stored and
|
||||
honoured by both `GET /player` and the live presence pushes. Players who
|
||||
never pick one are Public. "Friends only" follows the
|
||||
[everyone-is-friends](#everyone-is-friends-toggle) toggle, so with that
|
||||
toggle on it shows them to everyone.
|
||||
- **Room / matchmaking capacity.** New matchmade sessions and subrooms default
|
||||
to **8** players and are capped at **40** (`GameSessionService`,
|
||||
`RoomSceneEntity.MaxPlayers`, admin `MaxCapacity` clamp). The in-game "Max
|
||||
@@ -371,8 +471,9 @@ LAN/friends infrastructure unless you harden the items above.
|
||||
See [main's CONTRIBUTING guide](../../blob/main/CONTRIBUTING.md) for
|
||||
the project-wide rules.
|
||||
|
||||
Branch-specific: schema changes on this branch don't flow to
|
||||
`march-2020-03-10` automatically. If a fix applies to both, cherry-pick
|
||||
Branch-specific: this is the repo's default branch, and Dependabot and
|
||||
server PRs target it. Schema changes here don't flow to the 2020 or 2018
|
||||
branches automatically. If a fix applies to more than one, cherry-pick
|
||||
it across. Don't rebase across branches.
|
||||
|
||||
---
|
||||
|
||||
@@ -64,6 +64,19 @@ an object wrapping one. Bare-scalar endpoints are a recurring source of defects
|
||||
returning `{"Limit":1000}` where the client reads `1000` makes the deserialize
|
||||
throw and the feature silently fail.
|
||||
|
||||
**The untyped promise is not always status-only.** Many writes return
|
||||
`LDGADANDBIO` (no type argument), which reads like "body ignored". About 40 of
|
||||
them are not: they pass the response to `BFDGEKKNAPB.PEFKBAEAOPD`
|
||||
(`RecNet.Runtime/BFDGEKKNAPB.txt:353`, "ExpandRecNetResult"), which
|
||||
deserialises it as `DJMHAFPGLLN` = `{Success, Error}` (formatter
|
||||
`LMCCNLLHLCJ.txt:191-218`). An empty 200 (or `null`) parses to null and the
|
||||
continuation null-derefs, so the player sees "Failed to …" after the server
|
||||
applied the change. A body without `Success` parses as a failure with a null
|
||||
`Error`, and `Promise.Rejected(null)` throws `ArgumentException: Error message
|
||||
cannot be null`, which shows up in Player.log as an unobserved task exception.
|
||||
Search for callers of `BFDGEKKNAPB.PEFKBAEAOPD` before you decide a write
|
||||
needs no body. The server helper is `Controllers/RecNetResults.cs`.
|
||||
|
||||
Watch for a projection between the wire type and the method return type.
|
||||
`comments/unreadcounts` returns `Dictionary<long,uint>` from the method, but the
|
||||
wire payload is `List<UnreadRoomComments>` — a `Func<List<...>, Dictionary<...>>`
|
||||
@@ -193,8 +206,15 @@ verified against the binary before the change, and the full test suite passes.
|
||||
| `PUT player/photonregionpings` | parsed only the 2020 pair shape | also reads one field per region name |
|
||||
| `POST player/notifydisconnect` | bound 2020 `otherPlayerId` only | also binds `PlayerId`/`RoomInstanceId` |
|
||||
| `PUT player/{statusvisibility,vrmovementmode,avoidjuniors}` | echoed and discarded | persisted per player and read back on heartbeat |
|
||||
| `GET player?{ids}` statusVisibility | always 0 (Public); "appear offline" never hid anyone | reports the stored value; Offline hides from everyone else, FriendsOnly / FavoriteFriendsOnly hide from non-qualifying callers |
|
||||
| `PUT account/me/{emoji,personalpronouns,identityflags,bannerimage}` | 404 — routes missing ("Failed to modify …") | handlers added; form fields `displayEmoji` / `pronounFlags` / `identityFlags` / `imageName`; values persisted and emitted on every account DTO + pushed as AccountUpdate to all clients |
|
||||
| `GET econ/customAvatarItems/v1/itemOwnershipLimit` | object body where the client reads an int | bare JSON integer |
|
||||
| `GET api/customAvatarItems/v1/isCreationAllowedForAccount` | object body where the client reads a bool | bare JSON boolean |
|
||||
| `GET api/customAvatarItems/v1/isCreationAllowedForAccount` | object body, then (wrongly) a bare boolean | `{Success, Error}` envelope — the client parses DJMHAFPGLLN (converter LMCCNLLHLCJ registers only `Success`/`Error`); the bare boolean is what logged "Failed to query if local account can create Custom Avatar Items" |
|
||||
| `GET api/customAvatarItems/v1/minPriceForPublicItem` | object with four hedge keys | bare JSON integer — sent through FDKKOPAPDGF<Int32>, continuation `<GetPricingConfig>b__69_0(Int32 minimumPrice)` |
|
||||
| `GET + POST api/avatar/v3/saved{,/set}` | `OutfitSelectionsV2` and `CustomAvatarItems` dropped on save; loading a slot restored the stale V1 selection (the client wears V2 when non-empty) | all eight IMMOJHBENFI keys persisted and served |
|
||||
| `POST api/avatar/v{2,3}/gifts/generate` | 415 on the client's form-urlencoded body (seen live) | form-or-JSON binder |
|
||||
| `GET api/avatar/v1/lockeditems` | 5-key subset: AvatarItemId 0, no TagList/IsBaseAvatarItem | full IFNGBPJGINL owned-item shape |
|
||||
| `GET api/avatar/v4/items` (colored variants) | a colorway reused its base item's AvatarItemId, so owning a base + colorways collided in the id-keyed wardrobe | id hashed from the full descriptor |
|
||||
| `GET api/customAvatarItems/v2/fromCreator/{id}` | bare array; client reads {Results,TotalResults} | paged container |
|
||||
| `POST econ/customAvatarItems/v1/{id}/purchase` | 404 — route only existed under api/ | econ/ alias added |
|
||||
| `GET api/ugcPurchasables/v1/items/room/{roomId}` | 404 — only the ?roomId= form existed | path form added |
|
||||
@@ -235,10 +255,58 @@ verified against the binary before the change, and the full test suite passes.
|
||||
| `SignalR push `Id` (notify)` | enum NAME sent for most ids; client only registered numbers — pushes vanished | numeric by default, with the six string-keyed ids listed explicitly |
|
||||
| `SignalR SubscriptionUpdateRoom` | sent as `SubscriptionUpdateRoom`; client listens on `RoomUpdate` | sent as `RoomUpdate` |
|
||||
| `Room ban notification` | sent id 24, for which the client has no handler | sent as ModerationKick (22) with IsBan |
|
||||
| `Presence on SignalR reconnect` | disconnect pushed offline; nothing ever pushed back online, so a reconnect greyed the player out for the rest of the session | connect re-pushes presence when a room is already known |
|
||||
| `Presence on SignalR reconnect` | disconnect pushed offline; nothing ever pushed back online, so a reconnect greyed the player out for the rest of the session | connect pushes presence (online even with no room); a drop while the player still heartbeats waits one activity window before pushing offline |
|
||||
| `POST matchmake/none` | re-committed the previous room (or the dorm), so the next heartbeat answered with a room while the client held its offline instance (-1): "presence out-of-sync", offline load cancelled, player dragged back | player is online with no room; any pending join is abandoned; heartbeat answers `roomInstance: null` until the next matchmake |
|
||||
| `Presence fan-out to friends` | only the immediate /goto commit pushed; deferred commits (matchmake/dorm, private hops), matchmake/none, logout, failed joins and statusvisibility changes were silent, and `isOnline` was derived from "has a room" | one fan-out (PresenceBroadcastService) for every transition, explicit `isOnline`, stored `statusVisibility`, never sent to the subject |
|
||||
| `GET player` (bulk) | `isOnline` required a room; `statusVisibility` hard-coded 0; caller's own row ignored a pending join | online = heartbeat or SignalR; stored visibility; own row matches the heartbeat |
|
||||
| `Join watchdog` | 30 s timeout cleared presence in the middle of long room loads, so the next heartbeat bounced the player to the dorm | 3 min backstop that clears the room only (player stays online) |
|
||||
| `15 club/{id}/members/* writes, DELETE club/{id}, PUT+DELETE club/home/me` | empty 200; the RecNetResult reader null-derefed, so every join/leave/invite/ban showed "Failed to …" | `{success:true,error:""}` |
|
||||
| `announcements/club/{c}/{a}` edit, delete, `/read` | empty 200 / `{Read:true}`; read threw "Error message cannot be null" | RecNetResult |
|
||||
| `DELETE api/keepsakes/{id}`, `PUT comments/read/{r}/{c}`, `DELETE subscription/{id}` | empty 200 | RecNetResult |
|
||||
| `api/roomcurrencies/v1/deletePurchaseOffer`, `PUT accountprivacysettings/recenthistoryvisibility` | non-RecNetResult body (no `Success`) — rejected with a null message | RecNetResult (privacy keys kept) |
|
||||
| `POST api/playerwarnings` (refusals) | failure carried `Message`, not `Error` | `Error` added |
|
||||
| `POST account/me/createlogintoken` | object where the client reads a bare string | bare JSON string |
|
||||
| `POST account/me/remoteauth` | matched codes against createlogintoken tokens — always 401 — and answered a token pair | approves a device-authorization `user_code` for the caller; RecNetResult |
|
||||
| `connect/token` `device_code` / `login_token` grants | both fell into the device-id fallback (approval bypassed / wrong account) | real grants: `authorization_pending` until approved; login token redeemed once for its own account |
|
||||
| `PUT api/playerevents/v2/{id}/multiinstance` | broadcast-permission fields are boxed enums (sent as NAMES), parsed as ints → always 0 | names (None/RoomOwners/All) and numbers accepted |
|
||||
| `GET api/incentivizedreferrals/referrals` | 404 — built by the LPPEFJMCJFN URL builder, missed by the literal sweep | implemented (paged) |
|
||||
| `POST account/bulk/email`, `account/bulk/phone` | 404 | routed to the bulk account lookup |
|
||||
| `GET strings` (strings host) | 404 | empty table — no server-side overrides |
|
||||
| `gameconfig PlayMenuCarouselOrdering_1` | missing — "Could not find GameConfig" warning, prefab order | served; ids recovered from the build's DisplayOrderPriorityImpl |
|
||||
|
||||
Everything else in the per-subsystem Defects sections below is still
|
||||
outstanding.
|
||||
outstanding — but the sections are stale. A 2026-10 re-check of the
|
||||
breaks-gameplay rooms, playerevents, room-currency, clubs, keepsakes and
|
||||
storefront entries found all of them fixed except purchase-offer `Order`
|
||||
(sent by the client, MIHKAJFMIPE.txt:2664, never persisted; display order only).
|
||||
|
||||
### Requests outside the route-literal sweep
|
||||
|
||||
The sweep that produced `client-routes-2023.tsv` matched plain literals in
|
||||
RecNet.Runtime. These were found later and are now in the inventory:
|
||||
leading-slash literals (`/api/playerReputation/v2/bulk`,
|
||||
`/api/players/v2/progression/bulk`, `/strings`, `/config/{0}`, `/data/{0}`),
|
||||
base-plus-format routes (`api/itemWishlists` + `{0}/v1/wishlist/me/{1}`,
|
||||
`api/roomEarningsDistributions` + `{0}/v1/earningsDistribution/{1}`), the
|
||||
`LPPEFJMCJFN` URL builder (`api/incentivizedreferrals/referrals`), the
|
||||
runtime-composed `account/me/{field}` and `account/bulk/{email,phone}`, and
|
||||
the Assembly-CSharp CDN fetch `/sigs/{id}` (JFDPIJFDHAD.txt:1432).
|
||||
|
||||
Known and deliberately not served:
|
||||
|
||||
- GameLogs host (`HKBNPBMNDGG` 22): `PUT /reports/{guid}`, `/reports/{guid}/userinfo`,
|
||||
`/reports/{guid}/attachments/{name}`, `/Reports/{guid}/Complete`
|
||||
(AICEFGDBCGI.txt:671-1944). This is the "Send Game Log" tool, which is hidden
|
||||
unless gameconfig `Experience.GameLogTool.HideTool` is false — its client
|
||||
default is true (BugReportingVisibilityModel.txt:241-245), and DorkNet does not
|
||||
send the key, so the client never makes these calls.
|
||||
- BugReporting host (18): `POST /ado/bug`, `POST /ado/file` — Rec Room's internal
|
||||
Azure DevOps bug filer for staff builds.
|
||||
- Third-party hosts baked into the client (Amplitude, RudderStack, Unity user
|
||||
reporting, backtrace.io) are not RecNet services.
|
||||
- `/sigs/{id}` answers an empty payload on purpose: it is an encrypted
|
||||
file-signature set for the client's integrity check. The client logs
|
||||
"/sigs/<id> is empty" and boot continues (Player.log, 2026-10-10 session).
|
||||
|
||||
## Subsystems
|
||||
|
||||
@@ -320,6 +388,8 @@ Handler: `DorkNet.Server\Controllers\Accounts\AccountsController.cs:54`
|
||||
|
||||
**Fix.** In AccountsController.GetMe, build the payload from the actual PlayerEntity (Email, Phone, Birthday, IsJunior) and add availableUsernameChanges (e.g. a large int), displayEmoji, bannerImage, personalPronouns (0), identityFlags (0) keys — extend RecNetSelfAccount or return an augmented DTO for the 2023 path.
|
||||
|
||||
**Status: FIXED.** Every account read (account/me, account/{id}, account/bulk, account/search, account/create, api/account/v1/me) and the AccountUpdate / SelfAccountUpdate pushes now go through `DorkNet.Server/Services/AccountDtos.cs`, built from the real PlayerEntity: displayEmoji, bannerImage, personalPronouns, identityFlags on the public DTO; email/phone/birthday/juniorState plus availableUsernameChanges (99) on the self DTO.
|
||||
|
||||
##### `DELETE account/me` — VERB_MISMATCH (degraded)
|
||||
|
||||
Client's in-game account-deletion action sends DELETE /account/me (OPMAPIOEIFG.txt:8771-8773, verb 4). Server registers only [HttpGet] on /account/me (plus v1/v2 GET aliases) — DELETE returns 405 and the client logs 'Failed to delete local account'.
|
||||
@@ -342,6 +412,8 @@ None of these five concrete routes exist. server-routes.txt has no account/me/em
|
||||
|
||||
**Fix.** Add handlers in AccountsController: [HttpPut] /account/me/emoji (form 'emoji'), /account/me/personalpronouns (form 'personalpronouns', int), /account/me/identityflags (form 'identityflags', int), /account/me/bannerimage (form 'bannerimage'); [HttpPost] /account/me/confirmphone (route the existing ConfirmPhone action). Persist emoji/bannerimage/pronouns/flags on PlayerEntity so GET /account/me can echo them back (required for the account/me shape fix above).
|
||||
|
||||
**Status: FIXED for the four profile edits** (confirmphone still open). The form field names suggested above were wrong — the field is NOT the suffix. Verified per call site lambda: emoji → `displayEmoji` (OPMAPIOEIFG_NestedType_KPGNAEPGLAC.txt:57), personalpronouns → `pronounFlags` (…_LCCEHLBIOPK.txt:67, boxed Int32), identityflags → `identityFlags` (…_HHDFMJPLFHG.txt:67, boxed Int32), bannerimage → `imageName` (…_NJKHNGEPODH.txt:57). All four are PUT (rcx=3 before OPMAPIOEIFG.OBAGJBBIFGB). The response is never parsed, so the new value reaches the editor and other players only through the `AccountUpdate` push (now broadcast to every connected client) and `SelfAccountUpdate` (owner). Emoji is validated against `emojiConfig/whitelistedEmojis` (U+FE0F-insensitive, stored in the list's spelling); empty clears. New Players columns DisplayEmoji / BannerImageName / PersonalPronouns / IdentityFlags (migration PlayerProfile2023Fields + DatabaseBootstrap patch). Tests: `DorkNet.Server.Tests/ProfileEdit2023Tests.cs`.
|
||||
|
||||
##### `POST account/me/createlogintoken` — SHAPE_MISMATCH (degraded)
|
||||
|
||||
Client deserializes the response as a BARE JSON string (FGLDKEJLAKB<System.String>, LBNJFPOLCDL.txt:8382) — server returns an OBJECT { Success, Error, Token, LoginToken, ExpiresAt } (AccountsController.cs:575-582). The strict reader hits '{' where it expects '"' and throws → 'Failed to CreateLoginToken', so every 'open on the web' link (BHKIHEEBBNG builds auth.rec.net URL + token) fails.
|
||||
@@ -433,7 +505,7 @@ All 20 client routes have real, verb-matching handlers on DorkNet (GoToControlle
|
||||
**Client-side notes.** TRANSPORT/HOST: Every route in this subsystem is issued against the matchmaking host: BNDIAONDFFF..ctor(BestHTTP.HTTPMethods verb, GJDLNNLKDIJ host, string route) with host enum value 3 at every call site (BNDIAONDFFF.txt:74 for the signature; host base URLs come from the nameserver response — PAPLNIPKAMG has a NestedType_NameServerResponse). Verb enum is BestHTTP.HTTPMethods: 0=GET, 2=POST, 3=PUT — decoded from the rdx immediate at each ctor call. Params added via BNDIAONDFFF.AFGEDDANEKP are sent as an application/x-www-form-urlencoded (or multipart when binary present) BODY on POST/PUT via HTTPFormBase.AddField (BNDIAONDFFF.txt:3032,3099), and as a ?k=v&k=v QUERY string only on body-less verbs (the \"?\"/\"&\" concat path, BNDIAONDFFF.txt:3444-3489). DorkNet's [FromForm] bindings therefore match.
|
||||
CENTRAL SENDER: All matchmake/* routes except matchmake/none funnel through Matchmaking.HHJFFJEHLOB (Matchmaking.txt:11330, original name GoToHelper): POST, always adds BypassMovementModeRestriction=bool, LoginLock=string, MaxPersistenceVersion=Int32 (from GJGGDBDBCMJ — clamp-relevant, see room-version-clamp work), repeated AdditionalPlayerIds=Int32; a per-route Action<BNDIAONDFFF> closure adds JoinMode / ClientJoinData / AdditionalPlayersAutoFollow / SubRoomId. Response: JSON -> Matchmaking/PCFNLCMMGKB {errorCode:HMIBNIEBEKK enum, roomInstance:HCHDEHIGEBE} -> mapped by <>c.<GoToHelper>b__114_2 to the HMIBNIEBEKK error value the UI consumes ('Matchmaking request failed ({0}): {1}' on failure). The exact JSON key casing of PCFNLCMMGKB/HCHDEHIGEBE is reflection/attribute-driven and NOT recoverable from ISIL; the camelCase errorCode/roomInstance shape served by DorkNet (GoToController.cs:1529-1620) is verified working live against this exact build (matchmake/dorm boot, room joins, sub-room hops all functioning on the march-2023 branch), so treat it as the wire truth.
|
||||
2023-vs-2020 MISMATCHES FOUND (both benign, responses ignored, but fix for fidelity): (1) player/notifydisconnect — 2023 sends PlayerId + RoomInstanceId form keys; server binds 2020's otherPlayerId so the logged id is always null (MatchPlayerController.cs:117). (2) player/photonregionpings — 2023 sends one form field per region keyed by REGION NAME (us=42&eu=90...), not the region=/ping= pair arrays the server parses (MatchPlayerController.cs:239+); 2023 pings are silently dropped. Also note verb: 2023 uses PUT here (server already accepts GET/POST/PUT).
|
||||
HEARTBEAT: response IS parsed (BDDJOGCKIJO); serve the current roomInstance (never null once in a room) and appVersion as the STRING \"20230317\" or the client reports presence out-of-sync / version mismatch. avoidjuniors GET must return a bare JSON boolean primitive.
|
||||
HEARTBEAT: response IS parsed (BDDJOGCKIJO); serve the current roomInstance (never null once in a room) and appVersion as the STRING \"20230317\" or the client reports presence out-of-sync / version mismatch. The compare (RecNet.Runtime/RecNet/Matchmaking.txt:18415-18467): no cached presence = always resync; cached roomInstance null OR RoomInstanceId < 0 (the offline room, HCHDEHIGEBE.GIICPDHJOOH) = in sync only with roomInstance null; otherwise in sync only when HCHDEHIGEBE.DCIJHIPJHOB finds every field equal. The compare is skipped while TryGoToOfflineRoom's matchmake/none is in flight (static flag +0xF8). A PresenceUpdate push about the LOCAL player runs the same apply path (EGPABEDPEJO -> FLAOOBNJAAD), so presence pushes must never go to their subject. avoidjuniors GET must return a bare JSON boolean primitive.
|
||||
COVERAGE: all 20 routes in groups.json are real HTTP routes, all are already registered on the DorkNet server (server-routes.txt:812-847); no missing endpoints, only the two request-binding mismatches above.
|
||||
|
||||
#### Endpoints
|
||||
@@ -479,6 +551,8 @@ Handler: `DorkNet.Server/Controllers/Match/MatchPlayerController.cs:168`
|
||||
|
||||
**Fix.** Persist the value per player (e.g. a field on PlayerPresenceService state or a Players column), read it back in Heartbeat/SetVrMovementMode responses, and honor it in the friend-presence read path.
|
||||
|
||||
**Status: FIXED.** Persisted as PlayerSettings `pref:statusVisibility` and echoed on heartbeat (earlier change). The enum is Public=0 / FriendsOnly=1 / FavoriteFriendsOnly=2 / Offline=3 (dump.cs IPLLHDINPBL), set from the profile's "Set Online Status Visibility" dialog. `GET player?{ids}` (PlayerPresenceController) now reports the stored value — to self as well, which the dialog's current selection depends on — and reports a hidden player exactly like an offline one (isOnline=false, roomInstance=null): Offline to everyone but self, FriendsOnly to non-friends (global-friends toggle honored), FavoriteFriendsOnly to callers the player has not favorited. Still open: the SignalR `PresenceUpdate` push (NotificationService.PlayerPresenceChanged) hard-codes statusVisibility 0 and does not filter recipients, so an appear-offline player's room changes still reach friends live.
|
||||
|
||||
##### `PUT player/vrmovementmode` — STUB (degraded)
|
||||
|
||||
Same pattern as statusvisibility: correct verb and camelCase form key 'vrMovementMode', client ignores the body, but the value is echoed and dropped — Heartbeat always reports vrMovementMode: 0 (line 163). If any server-side matchmaking logic is ever meant to gate on movement mode (the client sends BypassMovementModeRestriction on every matchmake/* call, implying the retail server matched comfort modes), it has no data to work from.
|
||||
@@ -1275,7 +1349,7 @@ Verified all 37 real client HTTP calls against the DorkNet server source (every
|
||||
|---|---|---|---|
|
||||
| GET | `api/avatar/v1/defaultbaseavataritems` | none | JSON array of the same UnlockedAvatarItem shape as v4/items |
|
||||
| GET | `api/avatar/v1/defaultunlocked` | none | Same array shape as v4/items (server aliases it to the same handler) |
|
||||
| GET | `api/avatar/v1/lockeditems` | Query: repeated key "desc" carrying avatar-item desc strings (list added via helper 0x181F2E250 with key literal "desc") | JSON array; server ships subset {AvatarItemType, AvatarItemDesc, FriendlyName, Tooltip, Rarity} for each requested desc the caller does NOT own |
|
||||
| GET | `api/avatar/v1/lockeditems` | Query: repeated key "desc" carrying avatar-item desc strings (list added via helper 0x181F2E250 with key literal "desc") | JSON array of List<IFNGBPJGINL> — the same element as v4/items (CLIANCNHNIE reader: AvatarItemType, AvatarItemDesc, FriendlyName, Tooltip, Rarity, TagList, AvatarItemId, IsBaseAvatarItem) for each requested desc the caller does NOT own |
|
||||
| GET | `api/avatar/v2` | none | Object with keys (exact literals in reader EKHJKDNHOPB, case-tolerant Pascal/camel/lower): OutfitSelections(string), OutfitSelectionsV2(string), FaceFeatures(string), SkinColor(string), HairColor(string), CustomAvatarIte |
|
||||
| GET | `api/avatar/v2/gifts` | none | JSON array of gift objects. Server wire (verified live, DorkNet AvatarGiftsController.ToWire): Id(Int64), FromPlayerId(Int64?), ConsumableItemDesc(string), AvatarItemType(Int32), AvatarItemDesc(string, 4-comma-part 'guid |
|
||||
| POST | `api/avatar/v2/gifts/consume/` | POST to the literal URL WITH trailing slash (no id in path). Params: "Id" (boxed Int64 gift id), "UnlockedLevel" (boxed Int32) — sent form-urlencoded (server had to accept form; [F | Gift object echo (server returns ToWire(gift)); client treats non-2xx as 'Gift is missing from the list.' error |
|
||||
@@ -1283,8 +1357,8 @@ Verified all 37 real client HTTP calls against the DorkNet server source (every
|
||||
| POST | `api/avatar/v2/set` | JSON body written by EKHJKDNHOPB writer, PascalCase keys: OutfitSelections, OutfitSelectionsV2, FaceFeatures, SkinColor, HairColor, CustomAvatarItems:[{CustomAvatarItemId, BodyPart | Client fires-and-forgets (void); server echoes AvatarV2Dto |
|
||||
| GET | `api/avatar/v2/{playerId}` | none (player id is bare path segment under v2) | Same HHDLNAPEMGP object as api/avatar/v2 |
|
||||
| POST | `api/avatar/v3/gifts/generate` | Body params: "GiftContext" (boxed Int32), "Message" (string, optional) | Single gift object (same as v2) |
|
||||
| GET | `api/avatar/v3/saved` | none | JSON array of saved outfits. Server DTO: Slot(Int32), PreviewImageName(string), OutfitSelections(string), FaceFeatures(string), SkinColor(string), HairColor(string). NOTE: client BEPMJICDNDD has Int32 + SIX strings + Lis |
|
||||
| POST | `api/avatar/v3/saved/set` | JSON body = one BEPMJICDNDD serialised via JsonConvert (ALHIJCJOLCB) then BNDIAONDFFF.FJLLPHFOOJJ; same field set as the GET element | Client ignores body (fire-and-forget task with error toast); server echoes the DTO |
|
||||
| GET | `api/avatar/v3/saved` | none | JSON array of BEPMJICDNDD. Converter IMMOJHBENFI keys (IMMOJHBENFI.txt:587-910): Slot(Int32), PreviewImageName, OutfitSelections, OutfitSelectionsV2, FaceFeatures, SkinColor, HairColor (strings), CustomAvatarItems([{CustomAvatarItemId, BodyPart}]). Client stores them in a Dictionary keyed by Slot. The client wears OutfitSelectionsV2 when non-empty, else OutfitSelections (BEPMJICDNDD.NLGCNDMLACA) |
|
||||
| POST | `api/avatar/v3/saved/set` | Raw JSON body (application/json) = one BEPMJICDNDD written by IMMOJHBENFI; same eight keys as the GET element. Verb ordinal 2 (FFFIMAGLKEG.txt:4071-4073). No delete route exists in this client | Client ignores body (status only, LDGADANDBIO); server echoes the outfit |
|
||||
| GET | `api/avatar/v4/items` | none | JSON array. Server DTO (verified live against this client): AvatarItemType(Int32), AvatarItemDesc(string, MUST have >=4 comma-parts 'guid,,,'), AvatarItemId(Int32 — must be unique+non-zero or wardrobe collapses), Platfor |
|
||||
| GET | `api/catalog/v1/all?onlyAvailableSkus=true` | query baked into the literal: onlyAvailableSkus=true | JSON array of SKU objects. Exact client key set not in ISIL (reflection DTO); server ships dual-cased dictionary verified live — required keys include SkuId(Int32 — string here throws FormatException), Slug/Sku, Name, De |
|
||||
| POST | `api/consumables/v1/consume` | JSON body = JsonUtility.ToJson(ConsumableItemRequest); server binds {Id(Int64), DeltaCount(Int32)} (client passes Mathf.Abs of the count) | Server returns {Id, Count}; client parse target is a string callback (Action<String>) — tolerant |
|
||||
@@ -1299,11 +1373,11 @@ Verified all 37 real client HTTP calls against the DorkNet server source (every
|
||||
| DELETE | `api/customAvatarItems/v1/design` | none. SERVER GAP: no DELETE on design — client logs 'Unable to delete custom avatar item design' | status only |
|
||||
| GET | `api/customAvatarItems/v1/featured` | none | JSON array of custom-item objects (server ToWire shape as listed for the create endpoint) |
|
||||
| GET | `api/customAvatarItems/v1/hot` | none | JSON array of custom-item objects |
|
||||
| GET | `api/customAvatarItems/v1/isCreationAllowedForAccount` | none | Client expects a BARE JSON boolean. LIKELY MISMATCH: server returns object {IsCreationAllowed, Allowed} — should be bare true/false like the sibling endpoints |
|
||||
| GET | `api/customAvatarItems/v1/isCreationAllowedForAccount` | none | `{"Success": bool, "Error": string|null}` — response type DJMHAFPGLLN (MPBLNLMCEDL.txt:6216-6223), keys from its converter LMCCNLLHLCJ.txt:191-254; the client stores `Success` as can-create (HONEEPBHNLB, :9155-9216). NOT a bare boolean, unlike the two siblings |
|
||||
| GET | `api/customAvatarItems/v1/isCreationEnabled` | none | Bare JSON boolean; server correctly returns Content("true","application/json") |
|
||||
| GET | `api/customAvatarItems/v1/isRenderingEnabled` | none | Bare JSON boolean; server returns bare true |
|
||||
| GET | `api/customAvatarItems/v1/me` | Query params "skip" (boxed Int32), "take" (boxed Int32) | Paged object {Results:[NOAAFNFJPFB...], TotalResults:Int32} (server also emits extra Created/Owned arrays which the client ignores) |
|
||||
| GET | `api/customAvatarItems/v1/minPriceForPublicItem` | none | Object with one int field; server hedges with {MinPrice:100, Price:100} — one of the two keys presumably matches the client's property name |
|
||||
| GET | `api/customAvatarItems/v1/minPriceForPublicItem` | none | BARE JSON integer — FDKKOPAPDGF<Int32> (MPBLNLMCEDL.txt:6922, same generic token as itemOwnershipLimit) continued by `<GetPricingConfig>b__69_0(Int32 minimumPrice)`; the IHLMDLKOLNB config is built client-side |
|
||||
| PUT | `api/customAvatarItems/v1/{id}` | JSON body via FJLLPHFOOJJ carrying the changed fields (name/description/price/publish-state). SERVER GAP: only GET is registered on v1/{id:guid}; PUT 405s | Updated NOAAFNFJPFB object |
|
||||
| DELETE | `api/customAvatarItems/v1/{id}` | none (guid in path). SERVER GAP: no DELETE registered — 405 | status only (client fire-and-forget) |
|
||||
| POST | `api/customAvatarItems/v1/{id}/report` | JSON body via FJLLPHFOOJJ with report reason enum + free-text (exact keys UNKNOWN — body serialised from method args). SERVER GAP: no report route registered — 404 | status only |
|
||||
@@ -1360,37 +1434,27 @@ Handler: `none (api-side sibling: DorkNet.Server/Controllers/API/CustomAvatarIte
|
||||
|
||||
**Fix.** Add [HttpPost("/econ/customAvatarItems/v1/{id:guid}/purchase")] (rooted route, trailing-slash-tolerant) on CustomAvatarItemsController delegating to the existing Purchase(Guid) action; also read the requestedPrice query param and reject/adjust when it differs from item.Price to keep price-display honesty.
|
||||
|
||||
##### `POST api/avatar/v2/gifts/generate` — SHAPE_MISMATCH (degraded)
|
||||
##### `POST api/avatar/v2/gifts/generate` — FIXED
|
||||
|
||||
Route and verb exist (v2+v3), response ToWire is correct, and GenerateGiftRequest properties match the client's exact body keys (GiftContext/IsGameGift/AlternateGiftContext/Message). BUT the handler binds [FromBody] GenerateGiftRequest? (line 68), which only the JSON input formatter can read. The client builds this request with the same BNDIAONDFFF.AFGEDDANEKP named-param helper as gifts/consume, and the server's own comment on the consume handler (lines 170-184) documents that this helper produced a form-urlencoded body live and that [FromBody] returned HTTP 415 there. The [Consumes(...form-urlencoded...)] attribute on line 67 does NOT add a form formatter — a form-encoded generate body still 415s and the reward chest never spawns a gift; a JSON body works. PLAUSIBLE (not live-confirmed which encoding generate uses; consume is proven form-encoded, so generate likely is too).
|
||||
The client's AFGEDDANEKP params go out form-urlencoded; a live trace showed `GiftContext=12000&IsGameGift=True&Message=…` answered 415 while the handler was `[FromBody]`. It now binds through `FormOrJsonModelBinder`.
|
||||
|
||||
Handler: `DorkNet.Server/Controllers/API/Avatar/V2/AvatarGiftsController.cs:65`
|
||||
Handler: `DorkNet.Server/Controllers/API/Avatar/V2/AvatarGiftsController.cs`
|
||||
|
||||
**Fix.** In AvatarGiftsController.Generate, drop [FromBody] and read the body the way ConsumeViaBody does: if Request.HasFormContentType read GiftContext/IsGameGift/AlternateGiftContext/Message from the form, else JSON-parse the raw body (case-insensitive), else fall back to query. Keep the response unchanged.
|
||||
##### `POST api/avatar/v3/gifts/generate` — FIXED
|
||||
|
||||
##### `POST api/avatar/v3/gifts/generate` — SHAPE_MISMATCH (degraded)
|
||||
Same handler and fix as v2 generate.
|
||||
|
||||
Same handler as v2 generate ([HttpPost("api/avatar/v3/gifts/generate")] on line 66), so the route/verb the 2023 client uses for reward chests exists — but it shares the exact [FromBody]-vs-form-body risk described for v2 generate (415 on a form-urlencoded body despite the Consumes attribute).
|
||||
##### `GET api/avatar/v3/saved` — FIXED
|
||||
|
||||
Handler: `DorkNet.Server/Controllers/API/Avatar/V2/AvatarGiftsController.cs:66`
|
||||
SavedOutfitDto now carries `OutfitSelectionsV2` and `CustomAvatarItems`, the two IMMOJHBENFI keys it used to drop. Rows saved before the fix read back with V2 empty, which the client treats as "use OutfitSelections".
|
||||
|
||||
**Fix.** Same fix as v2 generate (manual form/JSON/query body reading); one change covers both routes.
|
||||
Handler: `DorkNet.Server/Controllers/API/Avatar/V3/AvatarSavedOutfitsController.cs`
|
||||
|
||||
##### `GET api/avatar/v3/saved` — SHAPE_MISMATCH (degraded)
|
||||
##### `POST api/avatar/v3/saved/set` — FIXED
|
||||
|
||||
Route/verb OK (v2/v3/v4) and the array element carries Slot/PreviewImageName/OutfitSelections/FaceFeatures/SkinColor/HairColor with exact PascalCase. But SavedOutfitDto (lines 95-103) has NO OutfitSelectionsV2 and NO CustomAvatarItems, while the client outfit DTO BEPMJICDNDD holds Int32 + SIX strings + List<BGNNOMBFMLH>. The 2023 avatar model round-trips OutfitSelectionsV2 + CustomAvatarItems everywhere else (AvatarV2Dto has both), so a saved outfit restores without its V2 selections and without custom shirts — silent data loss on every save slot, no crash (Newtonsoft defaults the missing fields).
|
||||
The body is parsed by hand, case-insensitively, so all eight keys persist. Before this, loading a saved slot restored the stale V1 selection (or nothing), because the client wears `OutfitSelectionsV2` whenever it is non-empty (BEPMJICDNDD.NLGCNDMLACA), and every custom shirt in the slot was lost.
|
||||
|
||||
Handler: `DorkNet.Server/Controllers/API/Avatar/V3/AvatarSavedOutfitsController.cs:29`
|
||||
|
||||
**Fix.** Add [JsonPropertyName("OutfitSelectionsV2")] string and [JsonPropertyName("CustomAvatarItems")] List<CustomAvatarItemRefDto> to SavedOutfitDto (reuse CustomAvatarItemRefDto from AvatarV2Controller). Existing persisted JSON deserializes fine (missing props default).
|
||||
|
||||
##### `POST api/avatar/v3/saved/set` — SHAPE_MISMATCH (degraded)
|
||||
|
||||
Route/verb OK, [FromBody] binds the client's JSON body, upsert-by-Slot works and echoes the DTO. Same defect as the GET: [FromBody] SavedOutfitDto silently DROPS the client's OutfitSelectionsV2 string and CustomAvatarItems list at bind time (System.Text.Json ignores unknown members), so those parts of the outfit are never persisted. Also note SavedOutfitDto's non-nullable value binding is fine here (all strings + int).
|
||||
|
||||
Handler: `DorkNet.Server/Controllers/API/Avatar/V3/AvatarSavedOutfitsController.cs:42`
|
||||
|
||||
**Fix.** Same DTO extension as the GET; no handler change needed.
|
||||
Handler: `DorkNet.Server/Controllers/API/Avatar/V3/AvatarSavedOutfitsController.cs`
|
||||
|
||||
##### `GET api/consumables/v1/getTransferable/{playerId}` — MISSING (degraded)
|
||||
|
||||
@@ -1468,13 +1532,11 @@ Handler: `DorkNet.Server/Controllers/API/CustomAvatarItems/CustomAvatarItemsCont
|
||||
|
||||
**Fix.** Add [HttpDelete("design")] that removes the caller's design row and returns Ok().
|
||||
|
||||
##### `GET api/customAvatarItems/v1/isCreationAllowedForAccount` — SHAPE_MISMATCH (degraded)
|
||||
##### `GET api/customAvatarItems/v1/isCreationAllowedForAccount` — FIXED
|
||||
|
||||
Handler returns the OBJECT {IsCreationAllowed, Allowed}, but the client method returns FGLDKEJLAKB<System.Boolean> — it parses the body as a BARE JSON boolean. Object-into-Boolean deserialization fails, so the can-create gate errors (likely read as not-allowed) and shirt creation is blocked at the UI gate. CONFIRMED from both sides. The two sibling flags on lines 209-215 already do it correctly with Content("true","application/json").
|
||||
The first audit read the method's `FGLDKEJLAKB<Boolean>` return type as the wire type and switched the body to a bare boolean. That was wrong: the request is sent as FDKKOPAPDGF<DJMHAFPGLLN> (MPBLNLMCEDL.txt:6216-6223) and the boolean is projected client-side from field +0x10 (HONEEPBHNLB, :9155-9216). DJMHAFPGLLN's converter LMCCNLLHLCJ registers only `Success` and `Error` (LMCCNLLHLCJ.txt:191-254). The bare `true` failed that parse, and the client logged `Failed to query if local account can create Custom Avatar Items: An error occurred`. The server now returns `{"Success": allowed, "Error": null|reason}`.
|
||||
|
||||
Handler: `DorkNet.Server/Controllers/API/CustomAvatarItems/CustomAvatarItemsController.cs:194`
|
||||
|
||||
**Fix.** Return Content(allowed ? "true" : "false", "application/json") from IsCreationAllowedForAccount.
|
||||
Handler: `DorkNet.Server/Controllers/API/CustomAvatarItems/CustomAvatarItemsController.cs`
|
||||
|
||||
##### `GET api/ugcPurchasables/v1/items/room/{roomId}` — MISSING (degraded)
|
||||
|
||||
@@ -1484,13 +1546,11 @@ Handler: `DorkNet.Server/Controllers/API/UgcPurchasables/UgcPurchasablesControll
|
||||
|
||||
**Fix.** Add [HttpGet("api/ugcPurchasables/v1/items/room/{roomId:long}")] mapped to the existing List logic (roomId from route instead of query).
|
||||
|
||||
##### `GET api/customAvatarItems/v1/minPriceForPublicItem` — UNKNOWN (none)
|
||||
##### `GET api/customAvatarItems/v1/minPriceForPublicItem` — FIXED
|
||||
|
||||
Route/verb OK; server hedges with {MinPrice:100, Price:100}. Client DTO IHLMDLKOLNB is an object with a single Int32 property whose JSON key is not recoverable from ISIL (reflection DTO) — whether either hedge key matches is UNKNOWN. If neither matches, the client silently falls back to the DTO's static default rather than crashing (int defaults to 0 under Newtonsoft missing-key semantics), so worst case is a wrong minimum-price validation, not a crash.
|
||||
The wire is a bare Int32, not an IHLMDLKOLNB object. HCBOHHEACPK sends FDKKOPAPDGF<Int32> (MPBLNLMCEDL.txt:6922, the same generic token as the bare-Int32 itemOwnershipLimit call at :2981), and the continuation is `<GetPricingConfig>b__69_0(Int32 minimumPrice)` (MPBLNLMCEDL_NestedType___c.txt:286). The server now returns a bare `100`.
|
||||
|
||||
Handler: `DorkNet.Server/Controllers/API/CustomAvatarItems/CustomAvatarItemsController.cs:217`
|
||||
|
||||
**Fix.** None provable. If live testing shows the min-price gate misbehaving, dump the real key from a live Newtonsoft trace or the metadata name in the decompile and align.
|
||||
Handler: `DorkNet.Server/Controllers/API/CustomAvatarItems/CustomAvatarItemsController.cs`
|
||||
|
||||
### Clubs, threads, chat and comments
|
||||
|
||||
@@ -3371,7 +3431,7 @@ Trigger-verification caveat: gameplay-flow attributions are grounded in the clie
|
||||
| GET | `api/config/v1/cohortnux/{cohortId}` | none (cohort id in path, Int32) | JSON array of {ButtonNumber:Int32, Version:Int32 (enum MDPAPEEJFED), Override:Int32 (enum IAEGEKLOMHF), CustomTitle:String, CustomDescription:String, CustomRoomName:String, DefaultTitle:String, DefaultDescription:String, |
|
||||
| GET | `api/config/v1/freegiftbutton` | none | bare JSON boolean: true\|false |
|
||||
| GET | `api/config/v2` | none | Object with exactly 8 recognized keys (each accepted in PascalCase/camelCase/lowercase): LevelProgressionMaps: [{Level:Int32, RequiredXp:Int32, GiftDropId:Int32?}], DailyObjectives: OIMDBINAEGE[][] i.e. array-of-arrays o |
|
||||
| GET | `api/gameconfigs/v1/all` | none | JSON array of {Key:String, Value:String, StartTime:DateTime? (ISO8601), EndTime:DateTime?} — tri-casing accepted |
|
||||
| GET | `api/gameconfigs/v1/all` | none | JSON array of {Key:String, Value:String, StartTime:DateTime? (ISO8601), EndTime:DateTime?} — tri-casing accepted. Carries the `UGC.CircuitsV2.Limit.*` family that fills `RecRoom.CircuitsV2.Config.LimitConfig`, including the per-frame RPC/byte budgets — see `docs/recroom-2023-circuitsv2-limits.md` |
|
||||
| GET | `api/testcasemanagement/v1/testcase/{id}` | none (string id concatenated into path) | {Id:String, Key:String, Title:String, Description:String, RoomName:String, Status:Int32 (enum LIHDCCJNKGC), AssignedPlayerNames:[String], Tags:[String], JiraUrl:String, Comments:[{Comment:String, CreatedAt:DateTime}]} — |
|
||||
| POST | `api/testcasemanagement/v1/testcase/{id}/claim` | none (empty body) | none consumed (BNDIAONDFFF.KDOPJCNKOOK = fire, ignore body); 2xx status only |
|
||||
| POST | `api/testcasemanagement/v1/testcase/{id}/comment` | raw JSON body = JsonConvert.SerializeObject(comment) → a JSON string literal, e.g. "\"my comment\"" | none consumed; 2xx status only |
|
||||
|
||||
Reference in New Issue
Block a user