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:
Alexa
2026-10-15 14:08:11 +03:00
parent 36e64e99ba
commit 6ee1d6f9b3
45 changed files with 6666 additions and 448 deletions
@@ -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>&lt;UpdateLatestReadComment&gt;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&lt;Int32&gt;
/// (RecNet.Runtime/MPBLNLMCEDL.txt:6922 — the same generic token
/// 0x18847B0B8 as the bare-Int32 itemOwnershipLimit call at :2981) and
/// continues with <c>&lt;GetPricingConfig&gt;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
@@ -34,6 +34,58 @@ public class IncentivizedReferralsController(DorkNetDbContext db, LevelService l
});
}
/// <summary>The caller's referred players, paged.
///
/// <c>FGLDKEJLAKB&lt;GPJIMAHMAIO&gt; 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&lt;Int64&gt;</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&lt;Int64&gt;)</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&lt;Int64&gt;)</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>&lt;TryGoToOfflineRoom&gt;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}&amp;{id2}&amp;{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>&lt;ExpandRecNetResult&gt;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=&amp;scopePrefix=client.&amp;eTag=</c> (FMLBOAOBKPM, :531) →
/// <c>Dictionary&lt;string, Dictionary&lt;long,string&gt;&gt;</c>, scope → id → text;</item>
/// <item><c>?locale=&amp;scope=client.{name}&amp;eTag=</c> (BNEELKAKCEN, :3) →
/// <c>Dictionary&lt;long,string&gt;</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; } = [];
+13 -1
View File
@@ -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).
+17 -17
View File
@@ -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}",
@@ -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()
+46
View File
@@ -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);
}
+31 -4
View File
@@ -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);
+53 -47
View File
@@ -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&lt;Account&gt;, :279
/// "SelfAccountUpdate" → Action&lt;SelfAccount&gt;) 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>&lt;TryGoToOfflineRoom&gt;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 &lt; 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