using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using DorkNet.Models.Players; using DorkNet.Server.Data; using DorkNet.Server.Data.Entities; using System.Security.Cryptography; namespace DorkNet.Server.Services; public class PlayerService(DorkNetDbContext db, RoomService rooms, ILogger logger) { /// /// Default outfit GUIDs piped into /// for brand-new accounts. The wire format is a comma-separated list of /// item-instance GUIDs, one per slot in client order: head, torso, legs, /// feet, accessory. The client maps each GUID against its baked /// AvatarItemDefinition catalog and falls back to the generic /// "starter" mesh when a GUID is unknown — which means private servers /// don't need a real items DB to dress new players, just stable /// placeholders that the client will resolve to the built-in /// starter clothing for each slot. /// /// Empty string ("") = the client renders no clothes / default skin. /// We seed all five slots so the avatar boots fully clothed. /// /// Wire format for /// — a SEMICOLON-separated list of AvatarItemSelection /// entries, each entry is COMMA-separated as /// {prefabGuid},{maskGuid},{swatchGuid},{decalGuid},{bodyPart} /// (exactly 5 parts; the inner AvatarItemVisualData takes /// 4 of them, then AvatarItemSelection.ToRecNetString /// appends the int BodyPart). Verified at: /// /// Cpp2IL_ISIL/.../PlayerOutfit.txt:4601 — split delimiter /// literal 0x3B = ;. /// Cpp2IL_ISIL/.../AvatarItemSelection.txt:887 — exactly /// 5 comma-segments expected, anything else throws /// AvatarParseException("Attempting to parse bad Avatar Item /// string …"). /// Cpp2IL_CS/.../Player.cs:262 — BodyPart enum /// {None=-1, Head=0, Torso=1, LeftHand=2, RightHand=3, Mouth=4}. /// Cpp2IL_ISIL/.../OutfitTypeExt.txt:23-39 — /// GetMatchingBodyPart: type ≤ 10 (Hat/Hair/Eye) → /// Head; type == 20 (Mouth/Beard) → Head; 100..104 /// (Neck/Shirt/Belt/Pocket/TeamJersey) → Torso; 200..203 /// (Wrist/Glove/Watch/TeamWrist) → LeftHand. /// /// /// We leave mask/swatch/decal empty (three empty parts between /// commas) so the watch picks the defaults baked into the /// AvatarItem prefab. Every prefabGuid below is in /// data/avatar_item_lookup.json's safe set. public const string StarterOutfitSelections = "03f8c394-28fa-4087-978b-8d108f0bd969,,,,0" + ";" + // Hat_Angler slot 0 → Head "1d27b674-f9e2-4ffc-9d8c-a58a1be06457,,,,0" + ";" + // Hair_Afro slot 2 → Head "2c3773a5-a0b4-41c5-a4df-6572dff516c9,,,,0" + ";" + // Eye_2020Glasses slot 10 → Head "8d10cc78-6b00-45f3-affb-205e9cc5b03f,,,,0" + ";" + // Beard_Close slot 20 → Head "5b6535f0-86cd-417a-bcce-a1ace9a5f260,,,,1" + ";" + // ArcherQuiver/Neck slot 100 → Torso "ec5255d5-edaf-4888-9fe1-e89ac0e0d300,,,,1" + ";" + // AstronautTorso slot 101 → Torso "eda29bce-5a07-4439-a2a7-cafa11e2ed62,,,,1" + ";" + // Belt_PaintballBelt slot 102 → Torso "uubY_Cn9A0-Ww4lsIRCF6w,,,,1" + ";" + // Neck_DraculaMedal slot 103 → Torso "74e6878f-94e0-41f2-93ea-de6412238385,,,,2"; // PartyBand/Wrist slot 200 → LeftHand /// JSON list of GUIDs the player owns at signup. Stored /// on and surfaced via /// GET api/avatar/v4/items as the unlocked-items list. /// The wardrobe drawer in the watch shows only the player's /// inventory; the store's "Already Owned" badge intersects every /// tile against this list. So this is intentionally only ~40 /// items spread across 9 slots — enough that each tab has /// variety on first boot, but the rest of the 453-item catalog /// stays purchasable in the store. /// /// Every GUID is in data/avatar_item_lookup.json's safe /// list. Adding GUIDs NOT in that file would crash the watch's /// wardrobe coroutine on the first one it can't resolve. To /// extend, run tools/AvatarCatalogDumpPatch against an /// updated client and pull GUIDs from the dump. public const string StarterInventoryJson = """ [ "03f8c394-28fa-4087-978b-8d108f0bd969", "AbYdU7O_9U-Lwg7zFVZzXQ", "d2d3264b-fbf6-40b5-9f10-c85ec737d112", "17aed0a0-70ed-49da-ad09-5bc4746f7718", "f218e2de-46fd-45b9-9e0e-670b6bb905e7", "7a7d8a06-7982-4bcb-b7bd-4d03b48889b4", "1d27b674-f9e2-4ffc-9d8c-a58a1be06457", "0088603e-ec3b-4478-8694-e6fb1989b3f2", "a6cbfe76-534a-4655-a8a8-3fed13d001c7", "b861e5f3-fc6d-43b3-9861-c1b45cb493a8", "880a3cc0-7407-4b61-b759-f9dd890fe9e5", "e36bcd98-7e85-43fa-89f8-57e4ec33823a", "2c3773a5-a0b4-41c5-a4df-6572dff516c9", "47629ed1-5378-427c-ad61-4b44d2fe90cc", "_qXaNbm4yEeyalo372QbvA", "TcgHS_0aPkehrBg7ytMOMQ", "8d10cc78-6b00-45f3-affb-205e9cc5b03f", "61ba3c90-c81e-4deb-bc79-50c0f1fe3e83", "cc96f8a5-bc5b-4f89-83b7-ecd53905ada7", "3e139dd9-4757-43a8-8546-8da70dc17f0a", "5b6535f0-86cd-417a-bcce-a1ace9a5f260", "83be5ba4-525a-4781-a5f6-839c22e4d1d3", "c6c08eb5-381a-4193-9722-80da95d62abe", "5accb7dc-90a3-46ee-bad2-8fd1861d50f1", "ec5255d5-edaf-4888-9fe1-e89ac0e0d300", "da55bfc3-47b7-46f6-b548-7fb2c2a5e0bf", "2f8518fc-c989-40de-98b4-c6f09b304166", "348ac48b-986e-4d42-a0f7-1aea16b88271", "843e3364-a743-4dfc-a990-0436e47b8c10", "b02053b3-013f-42b0-8881-85f5be2b8cda", "eda29bce-5a07-4439-a2a7-cafa11e2ed62", "75-HXZBOAEiDBqclnOn8YQ", "awOFE0_GSECr8GBNBo6a_w", "uubY_Cn9A0-Ww4lsIRCF6w", "5e9bbda6-d681-42b1-9cc4-5758acf34bc0", "b95678aa-351a-4929-8a0e-0274a183eb18", "74e6878f-94e0-41f2-93ea-de6412238385", "52VsI_lKlkSUAOJlfDnyrQ", "5b01eaa3-0cac-40c0-b72a-b3f6a868ae0c", "88739d79-aeb9-471a-b25b-776f46bba9f6" ] """; /// Color vault GUIDs for the starter avatar. The 2020 /// client's OutfitManager.FindColorFromVault expects IDs from /// its baked skin/hair color vaults here, not packed RGBA strings. public const string StarterHairColor = "81_c6R0my0qK9hYM_0a7LQ"; public const string StarterSkinColor = "cl2EzJ4v6kW3g4Oo9ZQ3hA"; /// Starter wallet for every new account: enough RecCenterTokens /// (CurrencyType=2) to buy ~10–20 store items so the store doesn't /// look broken on first boot. The watch's storefront UI gates the /// purchase confirmation on balance >= price; with a /// zero balance every tile shows "Not enough tokens" and the /// "Buy" button is disabled, which the user can't tell apart from /// a server bug. /// /// Seeded from at signup /// time and from on /// startup (idempotent, only inserts a row when one doesn't /// already exist for the player+currency). public const long StarterRecCenterTokens = 5000; /// The system-account player id. Reserved for the /// Coach account that owns every Rec Room Original room /// (RecCenter, Paintball, Dodgeball, etc.) — those rows hard-code /// CreatorPlayerId = 1 in the seed data. Real players are /// assigned ids in [100_000, 10_000_000), so id=1 stays uncontested /// forever. public const long SystemAccountId = 1; public const string SystemAccountUsername = "Coach"; /// Ensure the Coach system account exists at id=. /// Idempotent — repeated calls no-op once the row is in place. Called /// from Program.cs at startup BEFORE /// because the RR-Original room seeder sets CreatorPlayerId = 1 /// and we want that to be a real FK target rather than a dangling /// sentinel. The migrator already accepts pre-existing sentinels, so /// existing migrated databases will heal on first boot. /// /// Coach is non-loginable: DeviceId = "system" never matches any /// real client deviceId hash (those are 40-char hex), PasswordHash /// is null so the password grant rejects, BannedUntil is set far /// in the future as belt-and-suspenders so even a JWT minted somehow /// can't be used to act as Coach. The owner-of-record on system rooms /// gets to be a real account so admin tools' "Find rooms by player" /// queries don't have to special-case id=1. public async Task EnsureSystemAccountAsync() { var existing = await db.Players.AsNoTracking() .FirstOrDefaultAsync(p => p.Id == SystemAccountId); if (existing is not null) { // Self-heal: if the row exists but the bookkeeping fields // drift (e.g. someone manually edited Username), restore them // so admin tools always show "Coach" as the owner. var nameDrift = existing.Username != SystemAccountUsername || existing.DisplayName != SystemAccountUsername; // Heal a legacy year-9999 BannedUntil written before WireDates: // such a value corrupts the watch's local-timezone date parse if it // ever reaches a client (e.g. via /api/playersbanned). See WireDates. var banDrift = existing.BannedUntil.HasValue && existing.BannedUntil.Value > WireDates.FarFuture; if (nameDrift || banDrift) { await db.Players .Where(p => p.Id == SystemAccountId) .ExecuteUpdateAsync(s => s .SetProperty(p => p.Username, SystemAccountUsername) .SetProperty(p => p.DisplayName, SystemAccountUsername) .SetProperty(p => p.BannedUntil, WireDates.FarFuture)); } return; } var coach = new PlayerEntity { Id = SystemAccountId, DeviceId = "system", CreatedFromDeviceId = "system", LastPlatform = 0, LastPlatformId = string.Empty, Username = SystemAccountUsername, DisplayName = SystemAccountUsername, Bio = "Rec Room Original room owner. System account — not a real player.", Level = 99, XP = 0, IsAdmin = false, IsDeveloper = false, IsVerified = true, CanReceiveInvites = false, // Far-future ban as a hard authorization backstop in case a // forged JWT ever names player 1 as the subject. BanCheckMiddleware // refuses any authenticated request when BannedUntil > now. // WireDates.FarFuture (2099), NOT year 9999 — a 9999 BannedUntil // serialised to the watch overflows its local-timezone date parse // in some DST timezones and corrupts the response. See WireDates. BannedUntil = WireDates.FarFuture, PasswordHash = null, CreatedAt = new DateTime(2020, 3, 6, 0, 0, 0, DateTimeKind.Utc), LastSeenAt = new DateTime(2020, 3, 6, 0, 0, 0, DateTimeKind.Utc), Avatar = new AvatarEntity { OutfitSelections = StarterOutfitSelections, InventoryJson = StarterInventoryJson, HairColor = StarterHairColor, SkinColor = StarterSkinColor, UpdatedAt = new DateTime(2020, 3, 6, 0, 0, 0, DateTimeKind.Utc), }, }; db.Players.Add(coach); await db.SaveChangesAsync(); // Postgres bigserial doesn't auto-bump its sequence when we // insert an explicit Id, so the next AddAsync(player) would try // to assign Id=1 and collide. Bring the sequence in line with // MAX(Id). SQLite handles this implicitly because it reads the // current max on each INSERT; only Postgres needs the nudge. if (db.Database.IsNpgsql()) { await db.Database.ExecuteSqlRawAsync(""" SELECT setval( pg_get_serial_sequence('"Players"', 'Id'), GREATEST((SELECT COALESCE(MAX("Id"), 0) FROM "Players"), 1)) """); } logger.LogInformation( "[seed-system] Created Coach system account at id={Id}", SystemAccountId); } /// Insert a starter /// row for the player if they don't already have one for /// CurrencyType=2 (RecCenterTokens). Idempotent: existing /// balances are left untouched (so a player who has already spent /// down isn't mysteriously refilled). Called from both new-account /// flows so a brand-new account boots into a non-zero wallet, and /// from at startup so /// pre-existing accounts (created before this seed shipped) get /// the same one-time grant. private async Task EnsurePlayerWalletAsync(long playerId) { var exists = await db.CurrencyBalances.AsNoTracking() .AnyAsync(c => c.PlayerId == playerId && c.CurrencyType == 2); if (exists) return; db.CurrencyBalances.Add(new CurrencyBalanceEntity { PlayerId = playerId, CurrencyType = 2, Balance = StarterRecCenterTokens, UpdatedAt = DateTime.UtcNow, }); await db.SaveChangesAsync(); } /// One-time backfill at startup: every player who's /// missing a CurrencyType=2 row gets the starter grant. Players /// who already have a row keep it (whatever balance, even 0 — they /// might have spent it, and silently topping them back up would /// be exploitable). New rows are inserted with a single bulk /// query. /// /// Why a startup backfill instead of an EF migration: same /// reasoning as — keeps the /// starter value in C# without forcing every prod DB to re-run a /// one-off SQL UPDATE. Self-heals if the seed value evolves. public async Task BackfillStarterWalletsAsync() { // Find players who DON'T have a row for currency 2 yet. var missing = await db.Players.AsNoTracking() .Where(p => p.Id != SystemAccountId) .Where(p => !db.CurrencyBalances.Any(c => c.PlayerId == p.Id && c.CurrencyType == 2)) .Select(p => p.Id) .ToListAsync(); if (missing.Count == 0) return; var now = DateTime.UtcNow; foreach (var pid in missing) { db.CurrencyBalances.Add(new CurrencyBalanceEntity { PlayerId = pid, CurrencyType = 2, Balance = StarterRecCenterTokens, UpdatedAt = now, }); } await db.SaveChangesAsync(); logger.LogInformation( "[seed-wallet] granted {Count} players the starter {Tokens}-token balance", missing.Count, StarterRecCenterTokens); } /// /// Backfill , /// , and color vault IDs for accounts that /// were created BEFORE the new starter set landed (e.g. when the /// columns shipped, when the v1 5-mask-GUID seed shipped, or any /// avatar row with default-empty JSON). Idempotent — runs every /// startup, only touches rows that currently hold a value we know /// is broken or empty. /// /// Why a startup-time backfill instead of an EF migration: the /// migrator would force every prod database to re-run a one-off /// data UPDATE, and we'd have to keep the SQL identical to the /// in-code starter strings. Running it at boot lets us evolve /// the starter values in C# without a migration churn cycle, and /// self-heals any AvatarEntity row that drifted out of sync (e.g. /// from a partial migration, a hand-edit, or a now-removed /// signup path that didn't seed the avatar). /// /// CALLED FROM Program.cs startup AFTER /// . /// public async Task EnsureAvatarSeedAsync() { // The earliest seed value for InventoryJson — five mask/swatch // asset GUIDs that aren't in the runtime config's // avatarItemDataLookup, so the watch's wardrobe parser would // throw "Missing data for guid …" on the first one. Any // AvatarEntity still holding this gets backfilled to the // current StarterInventoryJson. const string OldBrokenInventory = """ [ "0dd6596c-b4ea-4d55-a219-e1f44e4a376d", "941c046e-4e95-49f8-a7d7-19071fcc3c94", "b75ef67d-00c3-4ac1-9b72-212032460294", "e17481fc-b2b6-47a5-91e5-7205f45a3247", "48abd952-214f-48b2-a8f1-1146f6f69aa2" ] """; // Normalise the JSON strings before comparing — DB-stored // JSON loses leading whitespace, so we compare without it. var oldInvCompact = System.Text.RegularExpressions.Regex.Replace( OldBrokenInventory, @"\s+", ""); var newInvCompact = System.Text.RegularExpressions.Regex.Replace( StarterInventoryJson, @"\s+", ""); // Detection: a valid OutfitSelections is a SEMICOLON-separated // list of {guid},{m},{s},{d},{bp} tuples (verified at // PlayerOutfit.txt:4601). The 5-mask-GUID seed (one comma list, // no semicolons) and the no-bodyPart seed (also one comma list, // no semicolons) both lack the semicolon delimiter — that's a // sufficient discriminator. Anything with a semicolon was // already produced by the new code path and we leave it alone. // Empty / missing values also need a backfill. // // The semicolon test uses Contains(";") (string overload, // which EF Core translates to a Postgres LIKE) NOT // Contains(';') (char overload, which EF can't translate // and previously crashed startup with InvalidOperationException // "The LINQ expression … could not be translated"). var rows = await db.Avatars .Where(a => string.IsNullOrEmpty(a.OutfitSelections) || !a.OutfitSelections.Contains(";") || string.IsNullOrEmpty(a.InventoryJson) || a.InventoryJson == "[]" || a.InventoryJson == OldBrokenInventory || a.InventoryJson == oldInvCompact || string.IsNullOrEmpty(a.HairColor) || a.HairColor.Contains(",") || string.IsNullOrEmpty(a.SkinColor) || a.SkinColor.Contains(",")) .ToListAsync(); if (rows.Count == 0) return; var now = DateTime.UtcNow; int outfitsFixed = 0, inventoryFixed = 0, colorsFixed = 0; foreach (var a in rows) { if (string.IsNullOrEmpty(a.OutfitSelections) || !a.OutfitSelections.Contains(";")) { a.OutfitSelections = StarterOutfitSelections; outfitsFixed++; } var invCompact = string.IsNullOrEmpty(a.InventoryJson) ? string.Empty : System.Text.RegularExpressions.Regex.Replace(a.InventoryJson, @"\s+", ""); if (invCompact == string.Empty || invCompact == "[]" || invCompact == oldInvCompact) { a.InventoryJson = newInvCompact; inventoryFixed++; } if (string.IsNullOrEmpty(a.HairColor) || a.HairColor.Contains(',')) { a.HairColor = StarterHairColor; colorsFixed++; } if (string.IsNullOrEmpty(a.SkinColor) || a.SkinColor.Contains(',')) { a.SkinColor = StarterSkinColor; colorsFixed++; } a.UpdatedAt = now; } await db.SaveChangesAsync(); logger.LogInformation( "[seed-avatar] backfilled {OutfitCount} OutfitSelections + {InvCount} InventoryJson rows + {ColorCount} color fields", outfitsFixed, inventoryFixed, colorsFixed); } /// /// Look up an existing account by deviceId, or create a new one if no /// account is bound to that device yet. This is the canonical /// account-creation entry point — every login flow funnels through here. /// /// Why deviceId and not platformId/Steam-ID: /// - The client sends both fields in the platformlogin form body. We /// deliberately ignore platformId (Steam 64) so accounts are NOT /// keyed to a Steam identity. A user running the same patched client /// on the same machine gets the same persistent account no matter /// what Steam emulator (Goldberg, real Steam, none) is in front. /// - deviceId is a stable per-installation hash the client generates /// itself. Different machines → different accounts → multiplayer /// works without Steam. /// - When deviceId is empty (rare paths), fall back to a generated /// synthetic id so we never create accidentally-shared accounts. /// /// Lookup-only counterpart to /// . Returns the existing player /// row for the given deviceId, or null if none exists — never creates /// a new account. Callers that want to enforce the admin "signups /// disabled" toggle use this to gate the device-id login fallback /// without accidentally minting an account. public async Task GetByDeviceAsync(string? deviceId) { if (string.IsNullOrWhiteSpace(deviceId)) return null; return await db.Players .Include(p => p.Avatar) .FirstOrDefaultAsync(p => p.DeviceId == deviceId); } public async Task GetOrCreateByDeviceAsync( string? deviceId, int platform = 0, string? platformId = null, string? displayName = null) { if (string.IsNullOrWhiteSpace(deviceId)) { // Synthesize a stable id from the platformId or generate one so // we don't share an account across unrelated callers. deviceId = !string.IsNullOrWhiteSpace(platformId) ? $"synthetic-{platformId}" : $"synthetic-{Guid.NewGuid():N}"; } var existing = await db.Players .Include(p => p.Avatar) .FirstOrDefaultAsync(p => p.DeviceId == deviceId); if (existing is not null) { existing.LastSeenAt = DateTime.UtcNow; existing.LastPlatform = platform; if (!string.IsNullOrEmpty(platformId)) existing.LastPlatformId = platformId; await db.SaveChangesAsync(); return existing; } var name = string.IsNullOrWhiteSpace(displayName) ? GenerateUsername() : await EnsureUniqueUsernameAsync(displayName); // First-account-on-fresh-DB gets admin so there's always a root // admin to bootstrap moderation. Cheap query — Players is keyed by // a small int and `Any()` on an empty table is O(1). var isFirstAccount = !await db.Players.AsNoTracking().AnyAsync(); var player = new PlayerEntity { Id = RandomNumberGenerator.GetInt32(100_000, 9_999_999), DeviceId = deviceId, // Audit trail — pinned to the creating deviceId and never // reassigned, even if `DeviceId` later rotates. CreatedFromDeviceId = deviceId, LastPlatform = platform, LastPlatformId = platformId ?? string.Empty, Username = name, DisplayName = name, IsAdmin = isFirstAccount, CreatedAt = DateTime.UtcNow, LastSeenAt = DateTime.UtcNow, }; player.Avatar = new AvatarEntity { PlayerId = player.Id, // Pre-populate so a brand-new account spawns dressed in the // canonical 2020 starter outfit rather than the no-clothes // T-pose the client renders for empty OutfitSelections. OutfitSelections = StarterOutfitSelections, InventoryJson = StarterInventoryJson, HairColor = StarterHairColor, SkinColor = StarterSkinColor, }; db.Players.Add(player); await db.SaveChangesAsync(); // Every new player gets their own dorm row. Lazy-creates here // (and at first /goto/room/DormRoom for legacy accounts that // pre-date this code). await rooms.EnsurePersonalDormAsync(player.Id); // Seed a starter token balance so the new account isn't // staring at "Not enough tokens" on every store tile. await EnsurePlayerWalletAsync(player.Id); if (isFirstAccount) { logger.LogInformation( "[seed-admin] First account on this DB → {Username} (id {PlayerId}, deviceId {DeviceId}) granted IsAdmin=true", name, player.Id, deviceId); } return player; } /// /// Always creates a new account row, regardless of whether one /// already exists for the given . Used /// by account/create so the watch's "Create new account" /// selector option actually creates new — the older flow that /// returned the existing account by deviceId blocked multi-account- /// per-device entirely. /// /// Boot-flow orphans (the watch always probes account/create on /// every boot, even when the user is picking a cached account) are /// cleaned up by + the /// cached_login handler's "if you logged in to a different id, the /// just-created one was an orphan" heuristic. /// public async Task CreateNewAccountAsync( string? deviceId, int platform = 0, string? platformId = null, string? displayName = null) { // Synthesise deviceId / platformId for empty inputs so the // audit trail still lands somewhere meaningful. var effectiveDeviceId = string.IsNullOrWhiteSpace(deviceId) ? $"synthetic-{Guid.NewGuid():N}" : deviceId; var name = string.IsNullOrWhiteSpace(displayName) ? GenerateUsername() : await EnsureUniqueUsernameAsync(displayName); // First-account-on-fresh-DB still gets admin — same logic as // GetOrCreateByDeviceAsync, but we honour it here too in case // the DB is fresh and the very first call is an explicit // create. var isFirstAccount = !await db.Players.AsNoTracking().AnyAsync(); var player = new PlayerEntity { Id = RandomNumberGenerator.GetInt32(100_000, 9_999_999), DeviceId = effectiveDeviceId, CreatedFromDeviceId = effectiveDeviceId, LastPlatform = platform, LastPlatformId = platformId ?? string.Empty, Username = name, DisplayName = name, IsAdmin = isFirstAccount, CreatedAt = DateTime.UtcNow, LastSeenAt = DateTime.UtcNow, }; player.Avatar = new AvatarEntity { PlayerId = player.Id, OutfitSelections = StarterOutfitSelections, InventoryJson = StarterInventoryJson, HairColor = StarterHairColor, SkinColor = StarterSkinColor, }; db.Players.Add(player); await db.SaveChangesAsync(); await rooms.EnsurePersonalDormAsync(player.Id); await EnsurePlayerWalletAsync(player.Id); if (isFirstAccount) { logger.LogInformation( "[seed-admin] First account on this DB → {Username} (id {PlayerId}) granted IsAdmin=true", name, player.Id); } return player; } /// Hard-delete a freshly-created orphan account row. Only /// safe to call on accounts that have NEVER been logged into via /// JWT-issuing flows — the orphan tracker enforces that /// constraint by only flagging accounts created within the same /// session that ended up logging into a different id. Cascades /// the avatar row. public async Task DeleteOrphanAsync(long accountId) { var player = await db.Players .Include(p => p.Avatar) .FirstOrDefaultAsync(p => p.Id == accountId); if (player is null) return false; if (player.Avatar is not null) db.Avatars.Remove(player.Avatar); db.Players.Remove(player); await db.SaveChangesAsync(); return true; } public async Task GetByIdAsync(long id) => await db.Players.Include(p => p.Avatar).FirstOrDefaultAsync(p => p.Id == id); /// /// Stamp the calling platform onto an existing account so the /// next boot's /cachedlogin/forplatformid query finds it. /// Called from every successful login path (password, cached_login, /// refresh) — without this, a manual username/password sign-in /// would never tag the account with the current Steam platformId /// and the watch's "remembered accounts" prompt would stay empty. /// public async Task TagPlatformAsync(long playerId, int platform, string? platformId) { var player = await db.Players.FirstOrDefaultAsync(p => p.Id == playerId); if (player is null) return; player.LastSeenAt = DateTime.UtcNow; if (platform != 0) player.LastPlatform = platform; if (!string.IsNullOrWhiteSpace(platformId)) player.LastPlatformId = platformId; await db.SaveChangesAsync(); } /// /// Returns every account that has ever logged in with the given platform /// + platformId combination, most-recent-first. Backs the /// auth.rec.net/cachedlogin/forplatformid/{platform}/{platformId} /// "remembered logins" prompt the client shows on its account-selection /// screen. /// /// On a private server with a single Steam emulator pointed at one /// account per machine, this typically returns 0 or 1 row. Multi-account /// developers (or anyone testing the multi-account flow) get a real list. /// public async Task> GetCachedLoginsAsync(int platform, string? platformId, int take = 10) { if (string.IsNullOrWhiteSpace(platformId)) return []; return await db.Players .Where(p => p.LastPlatform == platform && p.LastPlatformId == platformId) .OrderByDescending(p => p.LastSeenAt) .Take(take) .ToListAsync(); } public async Task GetByUsernameAsync(string username) => await db.Players.FirstOrDefaultAsync(p => p.Username == username); public async Task> SearchAsync(string query, int take = 20) { // Clamp the page size — without this a malicious caller could // request `take=1000000` and exhaust the connection. 50 is enough // for the watch's typeahead UI (it shows ~10 rows at a time). var clamped = Math.Clamp(take, 1, 50); var needle = (query ?? string.Empty).Trim().ToLower(); if (needle.Length == 0) return []; var idQuery = long.TryParse(needle, out var parsedId) ? parsedId : -1; return await db.Players .Where(p => p.Id == idQuery || p.Username.ToLower().Contains(needle) || p.DisplayName.ToLower().Contains(needle)) .OrderBy(p => p.Id == idQuery || p.Username.ToLower() == needle || p.DisplayName.ToLower() == needle ? 0 : p.Username.ToLower().StartsWith(needle) || p.DisplayName.ToLower().StartsWith(needle) ? 1 : 2) .ThenBy(p => p.Username) .Take(clamped) .ToListAsync(); } /// /// Update both Username and DisplayName. The client treats display name /// changes as username changes for older builds; we keep them in sync /// unless the caller is explicitly editing one or the other. /// public async Task UpdateUsernameAsync(long id, string username) { var player = await db.Players.FindAsync(id); if (player is null) return false; var unique = await EnsureUniqueUsernameAsync(username, exceptId: id); player.Username = unique; player.DisplayName = unique; await db.SaveChangesAsync(); return true; } public async Task UpdateDisplayNameAsync(long id, string displayName) { var player = await db.Players.FindAsync(id); if (player is null) return false; player.DisplayName = displayName; await db.SaveChangesAsync(); return true; } public async Task UpdateBioAsync(long id, string bio) { var player = await db.Players.FindAsync(id); if (player is null) return false; player.Bio = bio; await db.SaveChangesAsync(); return true; } public static PlayerProfile ToProfile(PlayerEntity e) => new() { AccountId = e.Id, Username = e.Username, DisplayName = e.DisplayName, Bio = e.Bio, Level = e.Level, XP = e.XP, Reputation = e.Reputation, IsVerified = e.IsVerified, IsDeveloper = e.IsDeveloper, IsCommunityTeam = e.IsCommunityTeam, CanReceiveInvites = e.CanReceiveInvites, IsJunior = e.IsJunior, ProfileImage = e.ProfileImageName, CreatedAt = e.CreatedAt, }; private static string GenerateUsername() => $"Player_{RandomNumberGenerator.GetInt32(1000, 99999)}"; /// /// Append a numeric suffix until the username is unique. Skips the /// suffix entirely if the requested name is already free. /// private async Task EnsureUniqueUsernameAsync(string requested, long? exceptId = null) { var name = requested.Trim(); if (name.Length == 0) name = GenerateUsername(); var taken = await db.Players.AnyAsync(p => p.Username == name && p.Id != exceptId); if (!taken) return name; for (var i = 1; i < 1000; i++) { var candidate = $"{name}_{i}"; if (!await db.Players.AnyAsync(p => p.Username == candidate && p.Id != exceptId)) return candidate; } return $"{name}_{RandomNumberGenerator.GetInt32(1000, 99999)}"; } }