Import december-2020-12-18 from private repo (scrubbed 2026-05-27)

Targets Rec Room 2020.12.18 client.

Re-import after adding:
- DorkNet.ClientMod/dorknet-clientmod.json.template — config schema
  the launcher's manifest-driven patcher renders into UserData/.
- tools/build-patcher-zip.ps1 — produces the dorknet-clientpatch-{key}.zip
  Release asset the launcher fetches.
- .github/workflows/release.yml — auto-publishes both Release assets
  on v1-december-* tag push.

Scrubbed via maintainer-side scrub-for-public.ps1: same drop rules as
the prior import, plus build-patcher-zip.ps1 added to the tools/
allow-list so it survives the scrub.
This commit is contained in:
Alexa
2026-05-27 01:04:12 +03:00
commit 4edf25b2d5
375 changed files with 201291 additions and 0 deletions
+103
View File
@@ -0,0 +1,103 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using DorkNet.Server.Data;
namespace DorkNet.Server.Services;
public class AuthService(IConfiguration config, DorkNetDbContext db)
{
// Resolution order MUST match Program.cs's JwtBearer validator setup —
// env var first, config key second — otherwise tokens are signed with
// one secret and validated against another and every authenticated
// request 401s. In production we set DORKNET_JWT_SECRET in Coolify
// (RECNET_JWT_SECRET kept as fallback for backward compat with older
// configs) and leave Jwt:Secret as the appsettings.json placeholder;
// the mismatched-secret bug shows up only there.
private string SecretKey =>
Environment.GetEnvironmentVariable("DORKNET_JWT_SECRET")
?? Environment.GetEnvironmentVariable("RECNET_JWT_SECRET")
?? config["Jwt:Secret"]
?? throw new InvalidOperationException(
"JWT secret not configured. Set DORKNET_JWT_SECRET env var or " +
"Jwt:Secret in appsettings.Local.json.");
public string GenerateToken(long playerId) => CreateJwt(playerId, TimeSpan.FromHours(12), "access");
/// <summary>
/// Issues an (access, refresh) pair for the OAuth-style password-grant
/// response shape (Login.LoginResponse). Access tokens are short-lived;
/// the refresh token is what api/platformlogin/refresh accepts later.
/// </summary>
public (string AccessToken, string RefreshToken) GenerateTokenPair(long playerId) =>
(CreateJwt(playerId, TimeSpan.FromHours(12), "access"),
CreateJwt(playerId, TimeSpan.FromDays(30), "refresh"));
private string CreateJwt(long playerId, TimeSpan lifetime, string tokenType)
{
var handler = new JwtSecurityTokenHandler();
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(SecretKey));
// Look up dev/admin flags so the JWT carries a "developer" role
// claim for accounts that should see the in-game debug button.
// The watch's Login.SetAccessToken parses the access_token's
// <c>role</c> claim — either a single string or a list — and
// calls <c>HasRole("developer", token)</c>; that result drives
// <c>Accounts.SetLocalAccountIsDev</c> and ultimately
// <c>SessionManager.IsDeveloper</c>, which the watch UI checks
// when deciding whether to show the dev console toggle.
var isDev = db.Players
.Where(p => p.Id == playerId)
.Select(p => (bool?)(p.IsDeveloper || p.IsAdmin))
.FirstOrDefault() ?? false;
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, playerId.ToString()),
new(ClaimTypes.Role, "gameClient"),
new("token_type", tokenType),
};
// Multiple Role claims become a JSON array on the wire — the
// watch's HasRole helper handles both string and List<string>
// forms.
if (isDev) claims.Add(new Claim(ClaimTypes.Role, "developer"));
var descriptor = new SecurityTokenDescriptor
{
Subject = new ClaimsIdentity(claims),
Expires = DateTime.UtcNow.Add(lifetime),
Issuer = "https://api.rec.net/",
SigningCredentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256),
};
return handler.WriteToken(handler.CreateJwtSecurityToken(descriptor));
}
public long? ValidateToken(string token)
{
try
{
var handler = new JwtSecurityTokenHandler();
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(SecretKey));
var principal = handler.ValidateToken(token, new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = key,
ValidateIssuer = true,
ValidIssuer = "https://api.rec.net/",
ValidateAudience = false,
ClockSkew = TimeSpan.Zero,
}, out _);
var idClaim = principal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
return idClaim is not null ? long.Parse(idClaim) : null;
}
catch
{
return null;
}
}
}
+559
View File
@@ -0,0 +1,559 @@
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Services;
/// <summary>
/// Backs the <c>clubs.localhost/*</c> surface used by the 2020.12 watch:
/// * <c>GET /club/home/me</c> — composite home tab payload
/// * <c>GET /club/mine/member</c> — clubs the player is in
/// * <c>GET /club/mine/created</c> — clubs the player created
/// * <c>GET /club/categoryTags</c> — admin-curated tag list
/// * <c>GET /announcements/v2/mine/unread</c> — direct feed
/// * <c>GET /announcements/v2/subscription/mine/unread</c> — subs feed
///
/// Owns no in-memory state — every method round-trips the DbContext so
/// reads stay consistent across replicas. The legacy
/// <c>GroupsController</c> reads + writes the same <see cref="ClubEntity"/>
/// table, so club rows created via <c>api/groups/v1/</c> show up here
/// automatically without backfill.
/// </summary>
public class ClubService(DorkNetDbContext db)
{
/// <summary>Clubs the player is a member of (any permissions
/// value above 0 OR a Member=0 row — i.e. presence in the
/// <see cref="ClubMembershipEntity"/> table). The owner's
/// auto-membership row inserted by <c>GroupsController.Create</c>
/// (Permissions=127) also matches.</summary>
public Task<List<ClubEntity>> MyClubsAsync(long playerId) =>
(from m in db.ClubMemberships
join c in db.Clubs on m.ClubId equals c.Id
where m.PlayerId == playerId && c.State == 0
orderby c.Name
select c).ToListAsync();
/// <summary>Clubs whose <see cref="ClubEntity.CreatorPlayerId"/>
/// equals the player. The owner usually also has a membership row,
/// so <see cref="MyClubsAsync"/> + this overlap — the watch's
/// "Mine" tab renders the union and de-dupes by ClubId.</summary>
public Task<List<ClubEntity>> CreatedByAsync(long playerId) =>
db.Clubs
.Where(c => c.CreatorPlayerId == playerId && c.State == 0)
.OrderBy(c => c.Name)
.ToListAsync();
/// <summary>
/// Unread announcements for clubs the player is a MEMBER of. Drives
/// <c>announcements/v2/mine/unread</c>. The "unread" filter is
/// LEFT JOIN against <see cref="ClubAnnouncementReadEntity"/> for
/// the calling player — rows with no read-marker count as unread.
/// </summary>
public async Task<List<ClubAnnouncementEntity>> UnreadDirectAsync(long playerId)
{
var memberClubIds = await db.ClubMemberships
.Where(m => m.PlayerId == playerId)
.Select(m => m.ClubId)
.ToListAsync();
if (memberClubIds.Count == 0) return new();
return await UnreadForClubsAsync(playerId, memberClubIds);
}
/// <summary>
/// Unread announcements for clubs the player SUBSCRIBES to (rows
/// in <see cref="ClubSubscriptionEntity"/>). Drives
/// <c>announcements/v2/subscription/mine/unread</c>. Same unread
/// semantics as the direct feed.
/// </summary>
public async Task<List<ClubAnnouncementEntity>> UnreadSubscriptionAsync(long playerId)
{
var subClubIds = await db.ClubSubscriptions
.Where(s => s.PlayerId == playerId)
.Select(s => s.ClubId)
.ToListAsync();
if (subClubIds.Count == 0) return new();
return await UnreadForClubsAsync(playerId, subClubIds);
}
private async Task<List<ClubAnnouncementEntity>> UnreadForClubsAsync(
long playerId, List<long> clubIds)
{
// Pull all candidate announcements + the player's read-markers
// for them in one pass each. EF Core's LEFT-JOIN over a
// subquery generates pathological SQL on SQLite, so the
// anti-join is done in memory after two index-supported reads.
var candidates = await db.ClubAnnouncements
.Where(a => clubIds.Contains(a.ClubId))
.OrderByDescending(a => a.CreatedAt)
.ToListAsync();
if (candidates.Count == 0) return candidates;
var candidateIds = candidates.Select(a => a.Id).ToList();
var readIds = await db.ClubAnnouncementReads
.Where(r => r.PlayerId == playerId && candidateIds.Contains(r.AnnouncementId))
.Select(r => r.AnnouncementId)
.ToListAsync();
var readSet = new HashSet<long>(readIds);
return candidates.Where(a => !readSet.Contains(a.Id)).ToList();
}
/// <summary>
/// Active category tags in display order. The 2020.12 watch
/// deserialises the response as <c>List&lt;String&gt;</c>, so the
/// controller projects <see cref="ClubCategoryTagEntity.Name"/>
/// out — the Id/OrderIndex/Active columns are admin-facing only.
/// </summary>
public Task<List<ClubCategoryTagEntity>> CategoryTagsAsync() =>
db.ClubCategoryTags
.Where(t => t.Active)
.OrderBy(t => t.OrderIndex)
.ThenBy(t => t.Name)
.ToListAsync();
/// <summary>
/// Discover-tab search for <c>GET /club/search</c>. The 2020.12
/// watch can send either a category-only browse request or a text
/// search; the rows are projected by the controller into the
/// <c>CAJHIEENJJD</c> search envelope.
/// </summary>
public async Task<(List<ClubEntity> Clubs, int TotalClubs)> SearchAsync(
string? query, string? category, int sort, int count)
{
var take = Math.Clamp(count <= 0 ? 30 : count, 1, 100);
var q = db.Clubs.Where(c => c.State == 0);
if (!string.IsNullOrWhiteSpace(category))
{
var categoryLower = category.Trim().ToLower();
q = q.Where(c => c.Category.ToLower() == categoryLower);
}
if (!string.IsNullOrWhiteSpace(query))
{
var queryLower = query.Trim().ToLower();
q = q.Where(c =>
c.Name.ToLower().Contains(queryLower) ||
c.Description.ToLower().Contains(queryLower));
}
var total = await q.CountAsync();
q = sort switch
{
1 => q.OrderByDescending(c => c.CreatedAt).ThenBy(c => c.Name),
2 => q.OrderBy(c => c.Name),
_ => q.OrderByDescending(c => c.ClubType)
.ThenByDescending(c => c.UpdatedAt)
.ThenBy(c => c.Name),
};
var clubs = await q.Take(take).ToListAsync();
return (clubs, total);
}
/// <summary>
/// Clubs the player is subscribed to, in the lowercase-key wire
/// shape <c>LNIKPLKOBDK</c> expects per
/// <c>dist/.../IsilDump/Assembly-CSharp/LNIKPLKOBDK.txt:110-124</c>:
/// <c>{accountId, clubId, subscriberCount}</c>. One row per
/// (player, club) subscription row. <c>subscriberCount</c> is the
/// total subscribers for that club across all players — derived
/// live from the same table so it stays in sync without a cached
/// counter that could drift.
/// </summary>
public async Task<List<object>> MySubscriptionsAsync(long playerId)
{
var mine = await db.ClubSubscriptions
.Where(s => s.PlayerId == playerId)
.Select(s => s.ClubId)
.ToListAsync();
if (mine.Count == 0) return new List<object>();
var counts = await db.ClubSubscriptions
.Where(s => mine.Contains(s.ClubId))
.GroupBy(s => s.ClubId)
.Select(g => new { ClubId = g.Key, Count = g.Count() })
.ToListAsync();
var byClub = counts.ToDictionary(c => c.ClubId, c => c.Count);
return mine
.Select(clubId => (object)new
{
accountId = playerId,
clubId = clubId,
subscriberCount = byClub.TryGetValue(clubId, out var c) ? c : 0,
})
.ToList();
}
/// <summary>
/// Composite home-tab payload for <c>GET /club/home/me</c>. The
/// watch's deserialiser for this endpoint expects a single
/// <c>RecNet.Club</c> object (<c>typeof(System.Action`1&lt;PLILLKHMNDA&gt;)</c>
/// per ISIL) — typically the player's "home club" (set via
/// <c>SetMyHomeClub</c>) rendered as the headline card. We pick the
/// first club the player is a member of, ordered by membership
/// recency. Returns null when the player has no club memberships
/// — the controller turns that into <see cref="Microsoft.AspNetCore.Mvc.NoContentResult"/>
/// so the watch knows to render the "join a club" empty state.
/// </summary>
public async Task<ClubEntity?> HomeClubAsync(long playerId)
{
// Most-recent-joined club wins. Owners are members too (the
// /create flow inserts an owner-permissions=127 membership
// row), so a brand-new club creator sees their fresh club here.
var pick = await (from m in db.ClubMemberships
join c in db.Clubs on m.ClubId equals c.Id
where m.PlayerId == playerId && c.State == 0
orderby m.JoinedAt descending
select c).FirstOrDefaultAsync();
return pick;
}
/// <summary>
/// Upserts a read-marker so subsequent <see cref="UnreadDirectAsync"/>
/// / <see cref="UnreadSubscriptionAsync"/> calls skip the
/// announcement. Idempotent — calling twice is a no-op (the unique
/// index on <c>(AnnouncementId, PlayerId)</c> guarantees one row).
/// </summary>
public async Task MarkAnnouncementReadAsync(long playerId, long announcementId)
{
var existing = await db.ClubAnnouncementReads
.FirstOrDefaultAsync(r => r.PlayerId == playerId
&& r.AnnouncementId == announcementId);
if (existing is not null) return;
db.ClubAnnouncementReads.Add(new ClubAnnouncementReadEntity
{
AnnouncementId = announcementId,
PlayerId = playerId,
ReadAt = DateTime.UtcNow,
});
await db.SaveChangesAsync();
}
/// <summary>
/// Live count of approved members for a club (used by the wire
/// <c>MemberCount</c> int — see <see cref="ClubEntity"/> doc).
/// </summary>
public Task<int> MemberCountAsync(long clubId) =>
db.ClubMemberships.CountAsync(m => m.ClubId == clubId);
/// <summary>Subscribers TO a club (everyone who hit subscribe on
/// the club page). Backs <c>/subscription/subscriberCount/{id}</c>
/// when the id resolves to a club.</summary>
public Task<int> ClubSubscriberCountAsync(long clubId) =>
db.ClubSubscriptions.CountAsync(s => s.ClubId == clubId);
/// <summary>Subscribers TO a player (everyone who hit subscribe on
/// the player's profile — the pre-club subscription model in
/// <see cref="DorkNetDbContext.Subscriptions"/>). Backs
/// <c>/subscription/subscriberCount/{id}</c> when the id resolves
/// to a player account.</summary>
public Task<int> PlayerSubscriberCountAsync(long playerId) =>
db.Subscriptions.CountAsync(s => s.TargetPlayerId == playerId);
/// <summary>
/// Bulk member-count lookup so the controller can project a list
/// of clubs in one round-trip. Returns a dict keyed by ClubId; any
/// club without members maps to 0 in the caller's projection.
/// </summary>
public async Task<Dictionary<long, int>> MemberCountsAsync(IEnumerable<long> clubIds)
{
var ids = clubIds.Distinct().ToList();
if (ids.Count == 0) return new();
var rows = await db.ClubMemberships
.Where(m => ids.Contains(m.ClubId))
.GroupBy(m => m.ClubId)
.Select(g => new { ClubId = g.Key, Count = g.Count() })
.ToListAsync();
return rows.ToDictionary(r => r.ClubId, r => r.Count);
}
/// <summary>Single club lookup by id. Returns null for missing
/// or disbanded (State != 0) clubs so the controller surfaces 404
/// before any wire-shape projection runs.</summary>
public Task<ClubEntity?> GetByIdAsync(long clubId) =>
db.Clubs.FirstOrDefaultAsync(c => c.Id == clubId && c.State == 0);
/// <summary>Single club lookup by exact display name. Used by
/// <c>GET /club?name=...</c>; case-insensitive matching keeps the
/// browse/name-resolve path stable across SQLite and Postgres
/// collations.</summary>
public Task<ClubEntity?> GetByNameAsync(string name)
{
var trimmed = name.Trim().ToLower();
return db.Clubs.FirstOrDefaultAsync(c => c.State == 0 && c.Name.ToLower() == trimmed);
}
/// <summary>Single membership row lookup. Used by both the
/// /club/{id}/members/{playerId} read and every member-mutation
/// endpoint to verify the target row exists before acting.</summary>
public Task<ClubMembershipEntity?> MembershipForAsync(long clubId, long playerId) =>
db.ClubMemberships.FirstOrDefaultAsync(m => m.ClubId == clubId && m.PlayerId == playerId);
/// <summary>Bulk member-row lookup for <c>members/bulk</c>. Returns
/// only rows that exist; missing playerIds are silently dropped (the
/// watch handles a shorter list fine — it just renders no row).</summary>
public Task<List<ClubMembershipEntity>> MembershipsBulkAsync(long clubId, IEnumerable<long> playerIds)
{
var ids = playerIds.Distinct().ToList();
if (ids.Count == 0) return Task.FromResult(new List<ClubMembershipEntity>());
return db.ClubMemberships
.Where(m => m.ClubId == clubId && ids.Contains(m.PlayerId))
.ToListAsync();
}
/// <summary>List clubs an arbitrary player is a MEMBER of. Drives
/// <c>account/{playerId}/clubs</c> on a profile card; uses the same
/// State=0 filter as the my-clubs read so disbanded clubs stay
/// hidden.</summary>
public Task<List<ClubEntity>> ClubsForPlayerAsync(long playerId) =>
(from m in db.ClubMemberships
join c in db.Clubs on m.ClubId equals c.Id
where m.PlayerId == playerId && c.State == 0
orderby c.Name
select c).ToListAsync();
/// <summary>
/// 2020.12 <c>PPGPAHNMGEC MembershipType</c> wire enum verified
/// against <c>dist\RecRoom-2020.12.18-dump\DiffableCs\Assembly-CSharp\PPGPAHNMGEC.cs</c>.
/// These ARE the values the watch decodes — earlier 0..N guesses
/// were wrong: <c>MyMembershipType=4</c> would have rendered as
/// "Pending_Denied" instead of "Creator" and disabled every owner
/// affordance in the watch UI.
/// </summary>
public const int MembershipTypeBanned = -1;
public const int MembershipTypeNotMember = 0;
public const int MembershipTypeRequested = 1; // Pending_Requested
public const int MembershipTypeInvited = 2; // Pending_Invited
public const int MembershipTypeDenied = 3; // Pending_Denied
public const int MembershipTypeMember = 10;
public const int MembershipTypeModerator = 20;
public const int MembershipTypeCoOwner = 30;
public const int MembershipTypeOwner = 100; // Creator on the wire
public static int MembershipTypeFromPerms(int perms)
{
// 256 = ban marker (set by /members/ban handler).
if ((perms & 256) != 0) return MembershipTypeBanned;
// 128 = pending (set by /members/invite + /requesttojoin handlers).
// We don't yet distinguish invite-from vs request-to on the perms
// int; surface Requested by default so the caller's "I asked to
// join" UI lights up. (Outgoing invites from owners use the same
// marker; the watch shows them under "pending invites" either
// way because the deserializer only branches on
// Member/Moderator/CoOwner/Creator vs Pending_*.)
if ((perms & 128) != 0) return MembershipTypeRequested;
if (perms >= 127) return MembershipTypeOwner;
if (perms >= 124) return MembershipTypeCoOwner;
if (perms >= 24) return MembershipTypeModerator;
return MembershipTypeMember;
}
/// <summary>Inverse of <see cref="MembershipTypeFromPerms"/> for
/// <c>changetype</c>. Returns the canonical perms int to assign
/// when the caller sets a new role. Owner (Creator=100 on the wire)
/// is intentionally excluded — promotion to Owner is a
/// transfer-ownership flow, not a changetype call.</summary>
public static int PermsFromMembershipType(int membershipType) => membershipType switch
{
MembershipTypeCoOwner => 124,
MembershipTypeModerator => 24,
MembershipTypeMember => 0,
_ => 0,
};
/// <summary>
/// Persist a club-targeted moderation report. Stores into the same
/// <see cref="ReportEntity"/> table the admin queue reads from, with
/// <see cref="ReportEntity.TargetRoomId"/> carrying the clubId (we
/// don't have a separate TargetClubId column yet — the admin SPA
/// renders any non-zero target field on the report card, so this
/// surfaces as a club report there too via the Message prefix).
/// </summary>
public async Task AddClubReportAsync(long clubId, long reporterId, int category, string message)
{
db.Reports.Add(new ReportEntity
{
ReporterPlayerId = reporterId,
TargetPlayerId = 0,
TargetRoomId = clubId,
Category = category,
Message = $"[club {clubId}] {message ?? string.Empty}",
});
await db.SaveChangesAsync();
}
/// <summary>
/// Create a new club + the owner's membership row in one go. Per
/// the 2020.12 wire <c>club/create</c> response shape, the caller
/// projects the resulting <see cref="ClubEntity"/> into the
/// <c>PIHMJGCGNLP</c> envelope via the controller's wire mappers.
/// Returns null when the name collides with an existing club so
/// the caller can surface a 409.
/// </summary>
public async Task<ClubEntity?> CreateAsync(long creatorId, string name, string? description, string? imageName, string? category)
{
var trimmed = (name ?? string.Empty).Trim();
if (trimmed.Length == 0) return null;
if (await db.Clubs.AnyAsync(c => c.Name == trimmed)) return null;
var club = new ClubEntity
{
Name = trimmed,
Description = description ?? string.Empty,
ImageName = imageName ?? string.Empty,
Category = category ?? string.Empty,
CreatorPlayerId = creatorId,
};
db.Clubs.Add(club);
await db.SaveChangesAsync();
db.ClubMemberships.Add(new ClubMembershipEntity
{
ClubId = club.Id,
PlayerId = creatorId,
Permissions = 127, // Owner
});
await db.SaveChangesAsync();
return club;
}
/// <summary>Apply a free-form mutation to a club row after
/// verifying the caller is owner/co-owner. Returns the updated
/// entity on success, null on missing club, or throws
/// <see cref="UnauthorizedAccessException"/> when the caller can't
/// modify. Used by every <c>club/{id}/modify*</c> endpoint variant.</summary>
public async Task<ClubEntity?> ModifyAsync(long clubId, long callerId, Action<ClubEntity> mutator)
{
var club = await db.Clubs.FirstOrDefaultAsync(c => c.Id == clubId && c.State == 0);
if (club is null) return null;
if (!await CanManageAsync(clubId, callerId, club))
throw new UnauthorizedAccessException();
mutator(club);
club.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return club;
}
/// <summary>
/// Check whether the caller can perform admin-class mutations on a
/// club (owner, co-owner, or moderator depending on the action).
/// Owner always wins; co-owner and moderator are looked up via the
/// membership perms int. Used by every club/{id}/modify* and
/// member mutation route.
/// </summary>
public async Task<bool> CanManageAsync(long clubId, long callerId, ClubEntity? club = null)
{
club ??= await db.Clubs.AsNoTracking().FirstOrDefaultAsync(c => c.Id == clubId);
if (club is null) return false;
if (club.CreatorPlayerId == callerId) return true;
var membership = await db.ClubMemberships.AsNoTracking()
.FirstOrDefaultAsync(m => m.ClubId == clubId && m.PlayerId == callerId);
if (membership is null) return false;
// Pending invites/requests (128) and ban markers (256) must NOT
// grant moderation rights. Without this mask a pending invitee
// with perms=128 evaluates `>= 24` true and inherits the ability
// to ban/promote — full moderation hijack via "I'd like to join".
var perms = membership.Permissions;
if ((perms & 128) != 0 || (perms & 256) != 0) return false;
return perms >= 24;
}
/// <summary>Upsert a membership row at a target role. Used by
/// invite/request/accept/changetype/directJoin/etc. Returns the
/// row for callers that need to inspect or surface it; for void
/// endpoints the caller just discards the result.</summary>
public async Task<ClubMembershipEntity> UpsertMembershipAsync(long clubId, long targetPid, int perms)
{
var row = await db.ClubMemberships
.FirstOrDefaultAsync(m => m.ClubId == clubId && m.PlayerId == targetPid);
if (row is null)
{
row = new ClubMembershipEntity
{
ClubId = clubId,
PlayerId = targetPid,
Permissions = perms,
};
db.ClubMemberships.Add(row);
}
else
{
row.Permissions = perms;
}
await db.SaveChangesAsync();
return row;
}
/// <summary>Remove a membership row entirely — used by leave,
/// remove, ban (after stamping a separate ban row if/when we
/// introduce one), declineinvite, denyrequest. Idempotent: missing
/// row → no-op.</summary>
public async Task RemoveMembershipAsync(long clubId, long targetPid)
{
var row = await db.ClubMemberships
.FirstOrDefaultAsync(m => m.ClubId == clubId && m.PlayerId == targetPid);
if (row is null) return;
db.ClubMemberships.Remove(row);
await db.SaveChangesAsync();
}
/// <summary>List the announcements for a single club in newest-first
/// order. Drives <c>announcements/club/{cid}</c> when the watch
/// renders the club's full announcement feed (not just the unread
/// rollup the home tab uses).</summary>
public Task<List<ClubAnnouncementEntity>> AnnouncementsForClubAsync(long clubId, int take = 50) =>
db.ClubAnnouncements
.Where(a => a.ClubId == clubId)
.OrderByDescending(a => a.CreatedAt)
.Take(take)
.ToListAsync();
/// <summary>Single announcement lookup by (clubId, announcementId).
/// Returns null when either id is unknown so the controller can
/// 404 cleanly.</summary>
public Task<ClubAnnouncementEntity?> AnnouncementAsync(long clubId, long announcementId) =>
db.ClubAnnouncements.FirstOrDefaultAsync(a => a.Id == announcementId && a.ClubId == clubId);
/// <summary>Delete an announcement after verifying the caller can
/// manage the club. Idempotent: missing row → no-op.</summary>
public async Task<bool> DeleteAnnouncementAsync(long clubId, long announcementId, long callerId)
{
if (!await CanManageAsync(clubId, callerId)) return false;
var row = await db.ClubAnnouncements
.FirstOrDefaultAsync(a => a.Id == announcementId && a.ClubId == clubId);
if (row is null) return true; // idempotent — caller treats as success
db.ClubAnnouncements.Remove(row);
await db.SaveChangesAsync();
return true;
}
/// <summary>
/// Idempotent seed of the default category tags. Inserts the
/// canonical Sports / Creative / Roleplay / Hangout / Education
/// tags on first launch; re-running on a populated table bails
/// without touching existing rows so admin edits are never
/// clobbered.
/// </summary>
public async Task SeedDefaultsAsync()
{
if (await db.ClubCategoryTags.AnyAsync()) return;
var defaults = new (string Name, int Order)[]
{
("Sports", 0),
("Creative", 1),
("Roleplay", 2),
("Hangout", 3),
("Education", 4),
};
foreach (var (name, order) in defaults)
{
db.ClubCategoryTags.Add(new ClubCategoryTagEntity
{
Name = name,
OrderIndex = order,
Active = true,
});
}
await db.SaveChangesAsync();
}
}
@@ -0,0 +1,150 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using Microsoft.EntityFrameworkCore;
namespace DorkNet.Server.Services;
/// <summary>
/// Single-row Postgres-backed store for the dorm community board state.
///
/// Wire shape mirrors what the watch's
/// <c>CommunityBoard.CommunityBoardDTO.Deserialize</c> reads
/// (Cpp2IL_ISIL/.../CommunityBoard_NestedType_CommunityBoardDTO.txt:460-480):
/// <c>FeaturedPlayer</c> / <c>FeaturedRoomGroup</c> /
/// <c>CurrentAnnouncement</c> / <c>InstagramImages</c> / <c>Videos</c>.
///
/// Pre-PR-3 wrote the JSON to <c>data/community_board.json</c> on disk +
/// kept an in-memory cache. With horizontal scale, an admin edit on
/// replica A wouldn't invalidate replica B's cache — admins would see
/// stale state until the next restart. The single-row table sidesteps
/// the cache-invalidation problem entirely: every read is one cheap
/// SELECT, every write is one UPDATE/INSERT, both visible to all
/// replicas immediately.
///
/// Service is now <b>scoped</b> rather than singleton because it holds
/// a DbContext reference.
/// </summary>
public class CommunityBoardService(DorkNetDbContext db)
{
/// <summary>Fixed PK for the single board row. We pin to id=1 rather
/// than using a singleton-table pattern because EF Core needs a
/// trackable key.</summary>
private const int RowId = 1;
private static readonly JsonSerializerOptions JsonOpts = new()
{
PropertyNameCaseInsensitive = true,
WriteIndented = false,
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
};
public async Task<CommunityBoardState> GetAsync()
{
var row = await db.CommunityBoardRows.AsNoTracking()
.FirstOrDefaultAsync(c => c.Id == RowId);
if (row is null) return DefaultState();
try
{
return JsonSerializer.Deserialize<CommunityBoardState>(row.Json, JsonOpts)
?? DefaultState();
}
catch (JsonException)
{
// Corrupt row — return default rather than 500. Next admin
// edit will overwrite the bad payload.
return DefaultState();
}
}
public async Task<CommunityBoardState> UpdateAsync(CommunityBoardState next)
{
var json = JsonSerializer.Serialize(next, JsonOpts);
var existing = await db.CommunityBoardRows.FirstOrDefaultAsync(c => c.Id == RowId);
if (existing is null)
{
db.CommunityBoardRows.Add(new CommunityBoardEntity
{
Id = RowId,
Json = json,
UpdatedAt = DateTime.UtcNow,
});
}
else
{
existing.Json = json;
existing.UpdatedAt = DateTime.UtcNow;
}
await db.SaveChangesAsync();
return next;
}
private static CommunityBoardState DefaultState() => new()
{
CurrentAnnouncement = new AnnouncementState
{
Message = "Welcome to the private server.",
MoreInfoUrl = string.Empty,
},
};
}
/// <summary>Storage shape for the community board. Field names match
/// the watch's CommunityBoardDTO keys (verified in
/// CommunityBoard_NestedType_CommunityBoardDTO.txt) so the same record
/// can be returned verbatim from <c>GET /api/communityboard/v1/current</c>.</summary>
public class CommunityBoardState
{
public FeaturedPlayerState? FeaturedPlayer { get; set; }
public FeaturedRoomGroupState? FeaturedRoomGroup { get; set; }
public AnnouncementState? CurrentAnnouncement { get; set; }
public List<InstagramImageState> InstagramImages { get; set; } = new();
public List<VideoState> Videos { get; set; } = new();
}
/// <summary>CommunityBoardFeaturedPlayerData: Id / TitleOverride / UrlOverride
/// (CommunityBoard_NestedType_CommunityBoardFeaturedPlayerData.txt:410-420).</summary>
public class FeaturedPlayerState
{
public int Id { get; set; }
public string TitleOverride { get; set; } = string.Empty;
public string UrlOverride { get; set; } = string.Empty;
}
/// <summary>FeaturedRoomGroupDTO: Name / FeaturedRooms list
/// (Rooms_NestedType_FeaturedRoomGroupDTO.txt:85-90). FeaturedRooms is
/// a list of room ids — the watch fetches each via /api/rooms/v2/{id}.</summary>
public class FeaturedRoomGroupState
{
public string Name { get; set; } = string.Empty;
public List<long> FeaturedRooms { get; set; } = new();
}
/// <summary>CommunityBoardAnnouncementDTO: Message / MoreInfoUrl
/// (CommunityBoard_NestedType_CommunityBoardAnnouncementDTO.txt:85-90).</summary>
public class AnnouncementState
{
public string Message { get; set; } = string.Empty;
public string MoreInfoUrl { get; set; } = string.Empty;
}
/// <summary>InstagramImageDTO: ImageName / ImageUrl
/// (CommunityBoard_NestedType_InstagramImageDTO.txt:85-90).</summary>
public class InstagramImageState
{
public string ImageName { get; set; } = string.Empty;
public string ImageUrl { get; set; } = string.Empty;
}
/// <summary>CommunityBoardVideoData: BlobName / Title / Description /
/// ThumbnailBlobName / SourceUrl
/// (CommunityBoard_NestedType_CommunityBoardVideoData.txt:460-480).</summary>
public class VideoState
{
public string BlobName { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public string ThumbnailBlobName { get; set; } = string.Empty;
public string SourceUrl { get; set; } = string.Empty;
}
+109
View File
@@ -0,0 +1,109 @@
using DorkNet.Models.Config;
namespace DorkNet.Server.Services;
public class ConfigService(IConfiguration config, DomainConfig domain)
{
public RecRoomConfig GetConfig(string baseUrl)
{
var photon = new PhotonConfig
{
AppId = config["Photon:AppId"] ?? string.Empty,
VoiceAppId = config["Photon:VoiceAppId"] ?? string.Empty,
CloudRegion = config["Photon:CloudRegion"] ?? "us",
};
// baseUrl was previously parsed to mirror the request's apex
// back at the client, but with DomainConfig as the single
// source of truth every outbound URL is built off the
// configured deployment apex regardless of which Host header
// the client used to hit us. The baseUrl parameter is kept
// for API stability but is no longer dereferenced.
_ = baseUrl;
var apex = domain.Apex;
return new RecRoomConfig
{
MessageOfTheDay = config["Server:MOTD"] ?? "Welcome to the private server!",
CdnBaseUri = $"https://{domain.Sub("cdn")}",
PhotonConfig = photon,
ServiceUrls = BuildServiceUrlMap(apex),
ConfigTable = new Dictionary<string, string>
{
// Season keys the 2019/2020 client checks at startup
["Season"] = "Spring",
["CurrentSeason"] = "Spring",
["SeasonId"] = "1",
["SeasonName"] = "Spring",
["ActiveEvent"] = "None",
["EventName"] = "",
// Feature flags — keep everything on
["FriendsEnabled"] = "true",
["ChatEnabled"] = "true",
["VoiceEnabled"] = "true",
},
};
}
/// <summary>
/// Subdomain → service-name map. The wire response wraps each
/// value with <c>https://</c> + the configured apex domain
/// (<see cref="DomainConfig"/>, driven by DORKNET_DOMAIN) so
/// every URL the client sees comes from one source of truth.
/// </summary>
private static readonly (string Service, string Subdomain)[] ServiceSubdomains = new[]
{
("WWW", ""), // apex
("API", "api"),
("Accounts", "accounts"),
("Auth", "auth"),
("BugReporting", "bugreporting"),
("Cards", "cards"),
("CDN", "cdn"),
("Chat", "chat"),
("Clubs", "clubs"),
("CMS", "cms"),
("Commerce", "commerce"),
("Data", "data"),
("DataCollection", "datacollection"),
("Discovery", "discovery"),
("Econ", "econ"),
("GameLogs", "gamelogs"),
("Geo", "geo"),
("Images", "img"),
("Leaderboard", "leaderboard"),
("Link", "link"),
("Lists", "lists"),
("Matchmaking", "match"),
("Moderation", "moderation"),
("NameServer", "ns"),
("Notifications", "notify"),
("PlatformNotifications", "platformnotifications"),
("PlayerSettings", "playersettings"),
("RoomComments", "roomcomments"),
("RoomieIntegrations", "roomieintegrations"),
("Rooms", "rooms"),
("Storage", "storage"),
("Strings", "strings"),
("StringsCDN", "strings-cdn"),
("Studio", "studio"),
("Thorn", "thorn"),
("Videos", "videos"),
};
/// <summary>Build the service URL map for a specific apex
/// domain. Used by NsController + the client config endpoints;
/// the apex is fed in by the caller (typically
/// <see cref="DomainConfig.Apex"/>) so the map stays a single
/// source of truth.</summary>
public static Dictionary<string, string> BuildServiceUrlMap(string apex)
{
var map = new Dictionary<string, string>(ServiceSubdomains.Length);
foreach (var (service, sub) in ServiceSubdomains)
{
var host = string.IsNullOrEmpty(sub) ? apex : $"{sub}.{apex}";
map[service] = $"https://{host}";
}
return map;
}
}
+18
View File
@@ -0,0 +1,18 @@
namespace DorkNet.Server.Services;
/// <summary>Single source of truth for the deployment apex domain.
/// Read from <c>DORKNET_DOMAIN</c> env var (or config key
/// <c>Domain:Apex</c>) at startup. Defaults to <c>localhost</c>. Replaces
/// per-controller <c>[Host("api.rec.net","api.localhost")]</c> filters —
/// allowed hosts are derived as <c>{apex}</c> + <c>*.{apex}</c>, fed
/// into HostFilteringMiddleware. Subdomain-discriminating handlers
/// (Cdn vs Img on path "/{*path}") branch on
/// <c>Request.Host.Host.Split('.')[0]</c>.</summary>
public sealed class DomainConfig
{
public string Apex { get; }
public DomainConfig(string apex) { Apex = apex; }
public string Sub(string prefix) => $"{prefix}.{Apex}";
public static bool MatchesSubdomain(string host, string prefix)
=> host.StartsWith(prefix + ".", StringComparison.OrdinalIgnoreCase);
}
@@ -0,0 +1,110 @@
using DorkNet.Models.GameSessions;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using Microsoft.EntityFrameworkCore;
namespace DorkNet.Server.Services;
/// <summary>
/// Cross-replica matchmaking session registry. Each session is a Photon
/// match with a player count; <see cref="JoinOrCreateAsync"/> finds an
/// existing non-full session in the requested room+region or creates
/// a new one.
///
/// Pre-PR-3 used a process-local <c>ConcurrentDictionary</c> + atomic
/// counter. With horizontal scale that was unsafe two ways:
/// <list type="bullet">
/// <item>Each replica's counter started at 1, so two replicas would
/// hand out colliding session ids — clients on different replicas
/// could end up with the same SessionId pointing at different
/// Photon rooms.</item>
/// <item>"Find a non-full session" only saw replica-local sessions, so
/// two players matchmaking via different replicas got their own
/// fresh session each and were isolated.</item>
/// </list>
///
/// Both are fixed by moving to Postgres: bigserial allocates from a
/// shared sequence; the SELECT sees every replica's sessions.
/// Service is now <b>scoped</b> because it holds a DbContext.
/// </summary>
public class GameSessionService(DorkNetDbContext db)
{
// Dorm room is always available
private const string DormRoomId = "00000000-0000-0000-0000-000000000001";
private const string DormActivityId = "76d98498-60a1-430c-ab76-b54a29b7a163";
public async Task<GameSession> JoinOrCreateAsync(string? roomId, string? activityId, string region)
{
var effectiveRoom = roomId ?? DormRoomId;
// Find an existing non-full session for this room+region. ORDER BY
// PlayerCount DESC + LIMIT 1 packs joiners into the fullest matching
// session first, so we don't fragment players across half-empty
// rooms when the catalog is light.
var match = await db.GameSessions
.Where(s => s.RoomId == effectiveRoom
&& s.Region == region
&& s.PlayerCount < s.MaxCapacity)
.OrderByDescending(s => s.PlayerCount)
.FirstOrDefaultAsync();
if (match is not null)
{
match.PlayerCount += 1;
await db.SaveChangesAsync();
return ToDto(match);
}
var session = new GameSessionEntity
{
RoomId = effectiveRoom,
ActivityLevelId = activityId ?? DormActivityId,
Region = region,
// PhotonRoomName needs the eventual server-allocated Id baked
// in, so we save once with a placeholder, then patch and save
// again. Two SaveChangesAsync calls is cheap at our QPS.
PhotonRoomName = "pending",
MaxCapacity = 8,
PlayerCount = 1,
CreatedAt = DateTime.UtcNow,
};
db.GameSessions.Add(session);
await db.SaveChangesAsync();
session.PhotonRoomName = $"recroom_{session.Id}_{Guid.NewGuid():N}";
await db.SaveChangesAsync();
return ToDto(session);
}
public async Task<GameSession?> GetByIdAsync(long id)
{
var s = await db.GameSessions.AsNoTracking()
.FirstOrDefaultAsync(s => s.Id == id);
return s is null ? null : ToDto(s);
}
public async Task PlayerLeftAsync(long sessionId)
{
var s = await db.GameSessions.FirstOrDefaultAsync(s => s.Id == sessionId);
if (s is null) return;
s.PlayerCount = Math.Max(0, s.PlayerCount - 1);
if (s.PlayerCount == 0)
{
db.GameSessions.Remove(s);
}
await db.SaveChangesAsync();
}
private static GameSession ToDto(GameSessionEntity s) => new()
{
GameSessionId = s.Id,
RoomId = s.RoomId,
ActivityLevelId = s.ActivityLevelId,
RegionId = s.Region,
PhotonRoomName = s.PhotonRoomName,
Private = false,
MaxCapacity = s.MaxCapacity,
PlayerCount = s.PlayerCount,
IsFull = s.PlayerCount >= s.MaxCapacity,
GameInProgress = s.PlayerCount > 0,
};
}
@@ -0,0 +1,240 @@
using Google.Protobuf;
using Google.Protobuf.Reflection;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using RecRoom.Protobuf2020;
namespace DorkNet.Server.Services;
/// <summary>
/// Background mirror for the .htr assets that <c>HolotarPersistenceData</c>
/// and <c>AudioSamplerPersistenceData</c> embedded in a PersistedRoomData
/// blob reference. Each ref is a string BlobName (typically
/// <c>{hash}.htr</c>) the watch fetches from
/// <c>cdn.rec.net/data/{name}</c>; without those bytes mirrored locally,
/// the watch's holotar projectors render empty and audio sampler nodes
/// produce silence after the YarrHarrHeist-style import otherwise
/// succeeds.
///
/// The admin Import Room endpoint kicks this off as fire-and-forget
/// (<see cref="EnqueueAsync"/>) right after the synchronous import
/// finishes, so the upload response returns immediately and the
/// 25 MB-ish download stream happens in the background. Per-asset
/// progress + summary are logged.
///
/// Idempotent: assets already in <see cref="RoomDataBlobEntity"/> by
/// BlobName are skipped without re-fetching.
/// </summary>
public sealed class HtrAssetMirrorService(
IServiceScopeFactory scopeFactory,
IHttpClientFactory httpClientFactory,
ILogger<HtrAssetMirrorService> logger)
{
private const string CdnBase = "https://cdn.rec.net/data/";
// Two known persistence-view sub-message types that have a
// BlobName string referencing an external .htr (or extension-less
// base64 id) on the official CDN.
private static readonly HashSet<string> HtrCarrierMessageNames = new(StringComparer.Ordinal)
{
"HolotarPersistenceData",
"AudioSamplerPersistenceData",
};
/// <summary>Fire-and-forget: schedule extraction + download in a
/// background task. Returns immediately. Errors are logged, never
/// thrown — a failure here doesn't break the original import.</summary>
public void EnqueueAsync(IReadOnlyList<byte[]> roomBlobs, string contextLabel)
{
if (roomBlobs is null || roomBlobs.Count == 0) return;
// Snapshot the byte arrays now — by the time the background
// task runs, the calling controller's scope is disposed, but
// the byte[]s are owned by this list and live as long as the
// closure does.
_ = Task.Run(async () =>
{
try
{
await MirrorAsync(roomBlobs, contextLabel);
}
catch (Exception ex)
{
logger.LogError(ex, "[htr-mirror] background mirror crashed for {Context}", contextLabel);
}
});
}
/// <summary>Synchronous (awaitable) version. Use <see cref="EnqueueAsync"/>
/// for fire-and-forget from a controller; use this directly if you
/// want to wait for completion (admin tooling / one-shot scripts).</summary>
public async Task<MirrorResult> MirrorAsync(IReadOnlyList<byte[]> roomBlobs, string contextLabel)
{
// Step 1: parse each blob with the full 2020 schema and collect
// every Holotar/AudioSampler BlobName ref. Skip blobs that fail
// to parse — better to lose a few refs than to abort the mirror.
var refs = new HashSet<string>(StringComparer.Ordinal);
var parseFailures = 0;
foreach (var bytes in roomBlobs)
{
try
{
var msg = PersistedRoomData.Parser.ParseFrom(bytes);
CollectHtrRefs(msg, refs);
}
catch (InvalidProtocolBufferException)
{
parseFailures++;
}
}
if (refs.Count == 0)
{
logger.LogInformation(
"[htr-mirror] {Context}: no holotar/audio refs in {Count} blob(s); nothing to do",
contextLabel, roomBlobs.Count);
return new MirrorResult(0, 0, 0, parseFailures, Array.Empty<string>());
}
// Step 2: filter against what's already in the DB.
await using var scope = scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
var refList = refs.ToList();
var existing = await db.RoomDataBlobs
.Where(b => refList.Contains(b.BlobName))
.Select(b => b.BlobName)
.ToListAsync();
var existingSet = new HashSet<string>(existing, StringComparer.Ordinal);
var todo = refList.Where(n => !existingSet.Contains(n)).ToList();
logger.LogInformation(
"[htr-mirror] {Context}: {Total} unique refs; {Skip} already mirrored; {Todo} to download",
contextLabel, refList.Count, existingSet.Count, todo.Count);
if (todo.Count == 0)
{
return new MirrorResult(refList.Count, existingSet.Count, 0, parseFailures, Array.Empty<string>());
}
// Step 3: download + insert.
var http = httpClientFactory.CreateClient("htr-mirror");
http.Timeout = TimeSpan.FromSeconds(60);
http.DefaultRequestHeaders.UserAgent.ParseAdd("DorkNet-htr-mirror/1.0");
var failures = new List<string>();
var nowIso = DateTime.UtcNow;
long totalBytes = 0;
foreach (var name in todo)
{
try
{
using var resp = await http.GetAsync(CdnBase + Uri.EscapeDataString(name));
if (!resp.IsSuccessStatusCode)
{
failures.Add($"{name}: HTTP {(int)resp.StatusCode}");
logger.LogWarning("[htr-mirror] {Name} → HTTP {Code}", name, (int)resp.StatusCode);
continue;
}
var bytes = await resp.Content.ReadAsByteArrayAsync();
if (bytes.Length == 0)
{
failures.Add($"{name}: empty body");
continue;
}
// Dual-write: S3 (canonical post-migration) + Postgres
// BYTEA (read fallback for code that hasn't migrated).
// S3 PUT happens first so a transient Redis/Postgres
// hiccup doesn't leave us with a metadata row pointing
// at a missing object. Failures here cause this asset
// to be skipped; the import as a whole still succeeds.
var storage = scope.ServiceProvider.GetRequiredService<IObjectStorage>();
try
{
var (bucket, key) = BlobRouter.Route(name);
await storage.PutAsync(
bucket,
key,
bytes,
"application/octet-stream");
}
catch (Exception putEx)
{
failures.Add($"{name}: s3 put failed: {putEx.Message}");
logger.LogWarning(putEx, "[htr-mirror] {Name} S3 PUT failed", name);
continue;
}
db.RoomDataBlobs.Add(new RoomDataBlobEntity
{
RoomId = 0, // shared / unowned asset
BlobName = name,
UploadedByPlayerId = 1,
UploadedAt = nowIso,
ReferencedFilenamesCsv = string.Empty,
});
totalBytes += bytes.Length;
logger.LogInformation("[htr-mirror] + {Name} ({Bytes:N0} bytes)", name, bytes.Length);
}
catch (Exception ex)
{
failures.Add($"{name}: {ex.Message}");
logger.LogWarning(ex, "[htr-mirror] {Name} failed", name);
}
}
// SaveChanges in one batch at the end keeps the DB write fast
// for the 100+-asset case YarrHarrHeist-sized rooms produce.
await db.SaveChangesAsync();
var inserted = todo.Count - failures.Count;
logger.LogInformation(
"[htr-mirror] {Context} done: inserted {Ins}, failed {Fail}, total bytes {Bytes:N0}",
contextLabel, inserted, failures.Count, totalBytes);
return new MirrorResult(refList.Count, existingSet.Count, inserted, parseFailures, failures);
}
private static void CollectHtrRefs(IMessage msg, HashSet<string> sink)
{
var desc = msg.Descriptor;
if (HtrCarrierMessageNames.Contains(desc.Name))
{
// Field 1 is BlobName on both carrier types.
var f = desc.FindFieldByNumber(1);
if (f is not null)
{
var v = f.Accessor.GetValue(msg) as string;
if (!string.IsNullOrWhiteSpace(v)) sink.Add(v);
}
return;
}
foreach (var field in desc.Fields.InFieldNumberOrder())
{
if (field.FieldType != FieldType.Message) continue;
if (field.IsRepeated)
{
if (field.Accessor.GetValue(msg) is System.Collections.IEnumerable list)
{
foreach (var item in list)
{
if (item is IMessage child) CollectHtrRefs(child, sink);
}
}
}
else
{
// proto3: HasValue is true when sub-message is non-null.
var value = field.Accessor.GetValue(msg) as IMessage;
if (value is not null) CollectHtrRefs(value, sink);
}
}
}
public sealed record MirrorResult(
int TotalRefs,
int AlreadyMirrored,
int Inserted,
int RoomBlobParseFailures,
IReadOnlyList<string> AssetDownloadFailures);
}
@@ -0,0 +1,311 @@
using System.Security.Cryptography;
namespace DorkNet.Server.Services;
public sealed class ImageSignatureService
{
private readonly RSA? rsa;
private readonly string keyId;
private readonly string? configuredKeyIdHostSuffix;
private readonly ILogger<ImageSignatureService> logger;
public ImageSignatureService(IConfiguration config, ILogger<ImageSignatureService> logger)
{
this.logger = logger;
keyId = NormalizeKeyId(
config["ImageSigning:KeyId"]
?? Environment.GetEnvironmentVariable("DORKNET_IMAGE_SIGNING_KEY_ID")
?? "p1");
// `.rec.net` in the client's `KEY:RSA:p1.rec.net` literal gets byte-rewritten
// by patch-client.ps1 to match the server domain (e.g. `.localhost`). The
// header suffix we emit has to match that, or the client errors as
// "Signature malformed". By default we derive the suffix from the request
// Host, which works for the standard same-domain patcher setup. Override
// with ImageSigning:KeyIdHostSuffix if your client metadata was patched to
// a different value than the server's host (e.g. running the server at
// `dorknet.example` while the client metadata still says `localhost`).
configuredKeyIdHostSuffix =
config["ImageSigning:KeyIdHostSuffix"]
?? Environment.GetEnvironmentVariable("DORKNET_IMAGE_SIGNING_KEY_ID_SUFFIX");
var privateKey =
config["ImageSigning:PrivateKeyPem"]
?? Environment.GetEnvironmentVariable("DORKNET_IMAGE_SIGNING_PRIVATE_KEY");
var privateKeyBase64 =
config["ImageSigning:PrivateKeyPemBase64"]
?? Environment.GetEnvironmentVariable("DORKNET_IMAGE_SIGNING_PRIVATE_KEY_BASE64");
if (string.IsNullOrWhiteSpace(privateKey) && !string.IsNullOrWhiteSpace(privateKeyBase64))
{
try
{
privateKey = System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(privateKeyBase64));
}
catch (FormatException ex)
{
logger.LogError(ex, "[img-sign] ImageSigning private key base64 is invalid");
}
}
if (string.IsNullOrWhiteSpace(privateKey))
{
logger.LogWarning("[img-sign] no image signing private key configured; image downloads will use placeholder Content-Signature headers");
return;
}
var normalized = NormalizePem(privateKey);
var hasBegin = normalized.Contains("-----BEGIN", StringComparison.Ordinal);
var lineCount = normalized.Count(c => c == '\n') + 1;
logger.LogInformation(
"[img-sign] parsing key: rawLen={RawLen} normLen={NormLen} hasBegin={HasBegin} lines={Lines} firstChars={First} lastChars={Last}",
privateKey.Length, normalized.Length, hasBegin, lineCount,
normalized.Length > 30 ? normalized[..30].Replace('\n', '·') : normalized.Replace('\n', '·'),
normalized.Length > 30 ? normalized[^30..].Replace('\n', '·') : "");
rsa = RSA.Create();
try
{
rsa.ImportFromPem(normalized);
logger.LogInformation("[img-sign] image response signing enabled with key {KeyId} (PEM path)", keyId);
return;
}
catch (Exception pemEx)
{
logger.LogWarning(pemEx, "[img-sign] ImportFromPem failed, falling back to raw-DER import");
}
// PEM parse failed — try treating the input as raw DER bytes,
// base64-encoded with or without PEM headers. ImportFromPem is
// strict about line lengths and header forms; ImportPkcs8 /
// ImportRSA* only need the DER octets and tolerate any
// base64-encoded body.
try
{
var body = ExtractBase64Body(normalized);
var der = Convert.FromBase64String(body);
try { rsa.ImportPkcs8PrivateKey(der, out _); }
catch
{
try { rsa.ImportRSAPrivateKey(der, out _); }
catch
{
rsa.ImportEncryptedPkcs8PrivateKey(ReadOnlySpan<char>.Empty, der, out _);
}
}
logger.LogInformation("[img-sign] image response signing enabled with key {KeyId} (raw-DER fallback)", keyId);
}
catch (Exception derEx)
{
rsa.Dispose();
rsa = null;
logger.LogError(derEx, "[img-sign] failed to load image signing private key — neither PEM nor DER parse succeeded");
}
}
/// <summary>Strip PEM headers + all whitespace from
/// <paramref name="input"/>, leaving just the base64 body. Used by
/// the raw-DER fallback when ImportFromPem rejects the formatted
/// PEM.</summary>
private static string ExtractBase64Body(string input)
{
var s = input;
var beginIdx = s.IndexOf("-----BEGIN", StringComparison.Ordinal);
if (beginIdx >= 0)
{
var afterBegin = s.IndexOf("-----", beginIdx + 5, StringComparison.Ordinal);
if (afterBegin >= 0) s = s[(afterBegin + 5)..];
}
var endIdx = s.IndexOf("-----END", StringComparison.Ordinal);
if (endIdx >= 0) s = s[..endIdx];
return StripWhitespace(s);
}
/// <summary>Coerce the configured value into something
/// <c>RSA.ImportFromPem</c> will accept.
///
/// Coolify/docker-compose let operators paste PEMs into a single-line
/// env-var field. Depending on the panel, the multi-line key may
/// arrive with:
/// <list type="bullet">
/// <item>Surrounding quotes (<c>"-----BEGIN PRIVATE KEY-----..."</c>)</item>
/// <item>Literal <c>\n</c> in place of real line breaks</item>
/// <item>Windows CRLF line endings</item>
/// <item>All line breaks flattened to spaces — header and body on one line</item>
/// <item>The bare base64 body with the BEGIN/END markers stripped</item>
/// </list>
/// <c>ImportFromPem</c> rejects every variation except the canonical
/// PEM (header line, 64-char-wrapped body lines, footer line) and
/// throws <c>"No supported key formats were found. Check that the
/// input represents the contents of a PEM-encoded key file, not the
/// path to such a file."</c> — which masks every garbled-paste case
/// behind a path-vs-content red herring. We rebuild the canonical
/// form here.
///
/// Also covers the case where someone configured the path to a
/// file (e.g. <c>/run/secrets/img-sign-key.pem</c>); we read its
/// contents and feed those through the same normaliser.</summary>
private static string NormalizePem(string input)
{
input = (input ?? string.Empty).Trim();
// Strip a single layer of surrounding quotes ("..." or '...').
if (input.Length >= 2 &&
((input[0] == '"' && input[^1] == '"') ||
(input[0] == '\'' && input[^1] == '\'')))
{
input = input[1..^1];
}
// Path-to-file case: read it through.
if (!input.Contains("-----BEGIN", StringComparison.Ordinal)
&& !input.Contains("BEGIN PRIVATE", StringComparison.Ordinal)
&& input.Length < 4096
&& System.IO.File.Exists(input))
{
input = System.IO.File.ReadAllText(input);
}
// Unescape literal \n / \r\n / \r that survived env-var passthrough.
input = input
.Replace("\\r\\n", "\n", StringComparison.Ordinal)
.Replace("\\n", "\n", StringComparison.Ordinal)
.Replace("\\r", "\n", StringComparison.Ordinal)
.Replace("\r\n", "\n", StringComparison.Ordinal)
.Replace("\r", "\n", StringComparison.Ordinal);
// Strip shell / YAML line-continuation backslashes. Some operator
// tools (heredocs, YAML block-scalars, multi-line env-var UIs)
// emit each PEM line with a trailing <c>\</c> as a continuation
// marker. If that marker isn't expanded by the surrounding shell,
// the <c>\</c> ends up embedded right before every <c>\n</c> in
// the PEM body. ImportFromPem tolerates it (sees corrupted body),
// but the DER fallback's <c>FromBase64String</c> rejects <c>\</c>
// as an illegal base64 character. Diagnosed live: env var had
// <c>...-----END RSA PRIVATE KEY-----\\</c>+newline plus one <c>\</c>
// per body line; ~27 stray backslashes total. Strip them.
input = input.Replace("\\\n", "\n", StringComparison.Ordinal);
// Also strip a single trailing backslash before EOF (no newline
// after it — happens when the env var is set without a final \n).
if (input.EndsWith('\\')) input = input[..^1];
// If the headers are present but everything's on one line
// (e.g. "-----BEGIN PRIVATE KEY----- MIIEvgIBADAN... -----END PRIVATE KEY-----"),
// split it back into the canonical 64-char-wrapped form.
if (input.Contains("-----BEGIN", StringComparison.Ordinal) && !input.Contains('\n'))
{
input = ReflowSingleLinePem(input);
}
// Still no headers? Treat the whole thing as the raw base64 body
// and synthesize a PKCS#8 PEM wrapper. ImportFromPem will figure
// out whether it's actually PKCS#1 or PKCS#8 from the DER inside.
if (!input.Contains("-----BEGIN", StringComparison.Ordinal))
{
var body = WrapBase64(StripWhitespace(input));
input = $"-----BEGIN PRIVATE KEY-----\n{body}\n-----END PRIVATE KEY-----";
}
return input;
}
/// <summary>Pull the BEGIN/END headers off a flattened single-line
/// PEM and re-emit it with the conventional 64-char body lines.</summary>
private static string ReflowSingleLinePem(string flat)
{
var beginEnd = flat.IndexOf("-----", flat.IndexOf("-----BEGIN") + 5);
if (beginEnd < 0) return flat;
var headerEnd = flat.IndexOf("-----", beginEnd + 5);
if (headerEnd < 0) return flat;
// BEGIN line is from start to first "-----" closer + 5.
var begin = flat[..(beginEnd + 5)].Trim();
// END line starts at "-----END ...-----" — find the trailing block.
var endStart = flat.LastIndexOf("-----END", StringComparison.Ordinal);
if (endStart < 0) return flat;
var end = flat[endStart..].Trim();
var body = StripWhitespace(flat[(beginEnd + 5)..endStart]);
return $"{begin}\n{WrapBase64(body)}\n{end}";
}
private static string StripWhitespace(string s)
{
// Drops whitespace AND backslashes — the latter survive when
// line-continuation markers are still embedded in the base64
// body and would otherwise trip FromBase64String's strict
// "illegal character" check. Every legitimate base64 char is
// in [A-Za-z0-9+/=], so a `\` is always wrong here.
var sb = new System.Text.StringBuilder(s.Length);
foreach (var c in s)
if (!char.IsWhiteSpace(c) && c != '\\') sb.Append(c);
return sb.ToString();
}
private static string WrapBase64(string body)
{
var sb = new System.Text.StringBuilder(body.Length + (body.Length / 64) + 1);
for (var i = 0; i < body.Length; i += 64)
{
sb.Append(body, i, Math.Min(64, body.Length - i));
if (i + 64 < body.Length) sb.Append('\n');
}
return sb.ToString();
}
public bool IsEnabled => rsa is not null;
public void AddContentSignature(HttpResponse response, byte[] bytes)
{
var signature = rsa is not null && bytes.Length > 0
? rsa.SignData(bytes, HashAlgorithmName.SHA1, RSASignaturePadding.Pkcs1)
: PlaceholderSignature(bytes);
// The client appends ?sig=<key> to image CDN requests and then
// requires Content-Signature's key-id to match that exact signing
// key. Prefer the request value over config so localhost/prod or
// stale env settings cannot make otherwise-valid image bytes fail
// as "Signature malformed".
var requestKeyId = response.HttpContext.Request.Query["sig"].ToString();
var headerKeyId = string.IsNullOrWhiteSpace(requestKeyId)
? keyId
: NormalizeKeyId(requestKeyId);
var suffix = configuredKeyIdHostSuffix is { Length: > 0 }
? configuredKeyIdHostSuffix
: ExtractParentDomain(response.HttpContext.Request.Host.Host);
response.Headers["Content-Signature"] =
$"key-id=KEY:RSA:{headerKeyId}.{suffix}; data={Convert.ToBase64String(signature)}";
}
private static string ExtractParentDomain(string host)
{
if (string.IsNullOrEmpty(host)) return "rec.net";
var dot = host.IndexOf('.');
return dot > 0 && dot < host.Length - 1 ? host[(dot + 1)..] : host;
}
private static byte[] PlaceholderSignature(byte[] bytes)
{
var signature = new byte[128];
var seed = SHA1.HashData(bytes);
for (var i = 0; i < signature.Length; i++)
signature[i] = seed[i % seed.Length];
return signature;
}
private static string NormalizeKeyId(string keyId)
{
keyId = keyId.Trim();
if (keyId.StartsWith("key-id=", StringComparison.OrdinalIgnoreCase))
keyId = keyId["key-id=".Length..].Trim();
if (keyId.StartsWith("KEY:RSA:", StringComparison.OrdinalIgnoreCase))
keyId = keyId["KEY:RSA:".Length..].Trim();
// Strip a trailing host suffix if one slipped into the key-id (e.g.
// someone passed `?sig=p1.localhost`). We only care about the bare
// "p1"/"d1" tag; the host part is appended fresh by AddContentSignature.
var dot = keyId.IndexOf('.');
if (dot > 0) keyId = keyId[..dot];
return string.IsNullOrWhiteSpace(keyId) ? "p1" : keyId;
}
}
@@ -0,0 +1,65 @@
using SkiaSharp;
namespace DorkNet.Server.Services;
/// <summary>
/// img.* CDN transforms. The 2020 watch posts a full-frame capture
/// (e.g. the avatar standing in their dorm) and then re-requests the
/// same blob with <c>?cropSquare=1&amp;width=256</c> on the profile
/// thumbnail surface. We honour <c>cropSquare</c> server-side so the
/// profile pic is face-zoomed, but we deliberately ignore the watch's
/// <c>width=256</c> hint and return the cropped image at the source's
/// native resolution: the watch's UI renders the same blob across
/// multiple sizes (small thumb chip and the big profile frame), and a
/// pre-shrunk 256² source gets stretched across a ~500px UI element and
/// looks blurry. Keeping the source resolution lets the watch's image
/// pipeline downsample for display without losing detail.
/// </summary>
public static class ImageTransformService
{
public readonly record struct Result(byte[] Bytes, string ContentType);
public static Result? TryTransform(
byte[] sourceBytes,
string sourceContentType,
bool cropSquare,
int? widthHint)
{
// widthHint is intentionally unused — see class comment. Kept on the
// signature so the controller can pass the parsed value without the
// call site having to know which params we currently respect.
_ = widthHint;
if (!cropSquare) return null;
if (sourceBytes.Length == 0) return null;
using var input = SKBitmap.Decode(sourceBytes);
if (input is null) return null;
if (input.Width == input.Height) return null; // already square, pass through
using var cropped = CenterCropSquare(input);
using var image = SKImage.FromBitmap(cropped);
// Re-encode in the same family the source advertised, so an uploaded
// JPEG stays JPEG (size budget for profile pic round-trips) and
// anything PNG-shaped stays PNG (alpha preserved for UI overlays).
var (format, contentType, quality) = sourceContentType switch
{
"image/jpeg" or "image/jpg" => (SKEncodedImageFormat.Jpeg, "image/jpeg", 92),
"image/webp" => (SKEncodedImageFormat.Webp, "image/webp", 92),
_ => (SKEncodedImageFormat.Png, "image/png", 100),
};
using var encoded = image.Encode(format, quality);
return new Result(encoded.ToArray(), contentType);
}
private static SKBitmap CenterCropSquare(SKBitmap input)
{
var side = Math.Min(input.Width, input.Height);
var x = (input.Width - side) / 2;
var y = (input.Height - side) / 2;
var dst = new SKBitmap(new SKImageInfo(side, side, input.ColorType, input.AlphaType));
using var canvas = new SKCanvas(dst);
canvas.DrawBitmap(input, new SKRect(x, y, x + side, y + side), new SKRect(0, 0, side, side));
return dst;
}
}
+102
View File
@@ -0,0 +1,102 @@
using Microsoft.EntityFrameworkCore;
using DorkNet.Models.Notification;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Services;
/// <summary>
/// Awards XP, computes level transitions, and adjusts currency
/// balances. Centralising these mutations behind one service avoids
/// scattered <c>db.Players.Find()</c>; <c>p.XP += n</c>; calls and
/// gives us a single place to adjust the reward curve.
///
/// Curve: <c>level = floor(sqrt(xp / 100)) + 1</c> — i.e. each level
/// costs <c>100 * (level^2 - (level-1)^2)</c> XP, scaling
/// quadratically. Level 1 starts at 0 XP, Level 2 at 100, Level 3 at
/// 400, Level 5 at 1600, Level 10 at 8100. Loose match for the
/// 2020 client's expected curve.
/// </summary>
public class LevelService(DorkNetDbContext db, NotificationService notifications)
{
public const int FirstLoginXp = 50;
public const int InventionSavedXp = 25;
public const int CheerReceivedXp = 10;
public const int RoomVisitXp = 5;
/// <summary>Add <paramref name="amount"/> XP to the player. Returns
/// the new (level, xp) pair. Level transitions push a
/// <c>SubscriptionUpdateProfile</c> notification with the new
/// level. Idempotent at the row level — concurrent calls might
/// double-grant if both load the same XP value, but the curve
/// tolerates that.</summary>
public async Task<(int level, int xp)> AwardXpAsync(long playerId, int amount, string reason)
{
if (amount <= 0) return (1, 0);
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == playerId);
if (player is null) return (1, 0);
var oldLevel = player.Level;
player.XP += amount;
player.Level = LevelForXp(player.XP);
await db.SaveChangesAsync();
if (player.Level > oldLevel)
{
// Level-up grants a small currency reward and a push so
// the watch can play its level-up animation.
await GrantCurrencyAsync(playerId, currencyType: 2,
amount: 100 * (player.Level - oldLevel),
reason: $"levelup:{oldLevel}->{player.Level}");
await notifications.NotifyAsync(playerId,
PushNotificationId.SubscriptionUpdateProfile,
new
{
Reason = "LevelUp",
Level = player.Level,
PreviousLevel = oldLevel,
});
}
return (player.Level, player.XP);
}
/// <summary>Grant or deduct currency. <paramref name="amount"/>
/// can be negative for deductions (e.g. spending in storefronts).
/// Pushes <c>StorefrontBalanceUpdated</c> when the balance moves.</summary>
public async Task<long> GrantCurrencyAsync(
long playerId, int currencyType, long amount, string reason)
{
if (amount == 0) return await GetBalanceAsync(playerId, currencyType);
var row = await db.CurrencyBalances.FirstOrDefaultAsync(
c => c.PlayerId == playerId && c.CurrencyType == currencyType);
if (row is null)
{
row = new CurrencyBalanceEntity
{
PlayerId = playerId,
CurrencyType = currencyType,
Balance = 0,
};
db.CurrencyBalances.Add(row);
}
row.Balance = Math.Max(0, row.Balance + amount);
row.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
await notifications.NotifyAsync(playerId,
PushNotificationId.StorefrontBalanceUpdate,
new { CurrencyType = currencyType, row.Balance, Reason = reason });
return row.Balance;
}
public async Task<long> GetBalanceAsync(long playerId, int currencyType) =>
await db.CurrencyBalances
.Where(c => c.PlayerId == playerId && c.CurrencyType == currencyType)
.Select(c => c.Balance)
.FirstOrDefaultAsync();
/// <summary>Inverse of the level→xp curve. <c>floor(sqrt(xp/100)) + 1</c>.</summary>
public static int LevelForXp(int xp) =>
xp <= 0 ? 1 : (int)Math.Floor(Math.Sqrt(xp / 100.0)) + 1;
}
@@ -0,0 +1,355 @@
using DorkNet.Models.Notification;
using DorkNet.Server.Controllers.Match;
using DorkNet.Server.Hubs;
using Microsoft.AspNetCore.SignalR;
using System.Text.Json;
namespace DorkNet.Server.Services;
/// <summary>
/// Pushes <see cref="PushNotification"/>s to specific players over the
/// SignalR <see cref="NotifyHub"/>. Each connected player belongs to a
/// per-player group named <c>player:&lt;id&gt;</c>; sending to that group
/// reaches every active connection for that player without us tracking
/// individual connection ids.
///
/// **Wire contract** (verified against
/// <c>Cpp2IL_ISIL/.../RecNet/Notifications.txt:1979-1994</c>):
/// - Hub method name: <c>"Notification"</c> (singular). The client
/// calls <c>connection.On("Notification", Action&lt;string&gt;)</c>.
/// - Payload: a SINGLE <c>string</c> argument containing JSON of
/// the form <c>{"Id":int, "Msg":object}</c>. The client's handler
/// then parses that string into a <c>Dictionary&lt;string, object&gt;</c>
/// and dispatches by Id to per-handler callbacks registered via
/// <c>RegisterHandler(PushNotificationId, NotificationHandler)</c>.
///
/// Earlier versions sent <c>SendAsync("ReceiveNotification", obj)</c>
/// — wrong method name AND wrong arg type (object instead of string).
/// All push notifications were silently dropped client-side; admin
/// kicks / broadcasts / friend-request alerts never reached the
/// in-game client. Keep the method name + string serialisation
/// in sync with the decompiled client when extending.
///
/// Higher-level helpers (FriendRequestReceived, FriendRequestAccepted,
/// PlayerEnteredRoom, etc.) wrap NotifyAsync so callers don't have to
/// pick the right <see cref="PushNotificationId"/> by hand.
/// </summary>
public class NotificationService(
IHubContext<NotifyHub> hub,
OnlinePresenceService onlinePresence,
ILogger<NotificationService> logger)
{
private const string HubMethod = "Notification";
private static readonly JsonSerializerOptions Json = new()
{
// Client uses LitJson which reads PascalCase keys verbatim;
// no camelCase / snake_case conversion in the wire payload.
PropertyNamingPolicy = null,
};
/// <summary>The watch's <c>Notifications.OnNotificationReceived</c>
/// reads <c>Id</c> as a string and dispatches against
/// <c>handlers : Dictionary&lt;string, NotificationHandler&gt;</c>
/// (verified at <c>Cpp2IL_ISIL/.../RecNet/Notifications.txt:2097-2222</c>).
/// Wire name per id is whatever the registering class actually
/// stored in the handlers dict — verified live by tracing every
/// <c>Notifications.RegisterHandler</c> call at boot via
/// <c>tools/SaveDebugMod</c>'s <c>[notify-register]</c> log lines.
/// The handlers fall into three buckets:
/// <list type="bullet">
/// <item>Registered ONLY under the bare integer string (no
/// enum-name companion): <c>MessageReceived (2)</c>,
/// <c>MessageDeleted (3)</c>, <c>ModerationKick (22)</c>,
/// <c>ModerationKickAttemptFailed (23)</c>,
/// <c>ModerationRoomBan (24)</c>, and the entire
/// <c>PlayerEvent*</c> family (80-85). Sending the enum
/// name (e.g. <c>"MessageReceived"</c>) makes lookup miss
/// and the push drops silently — this is exactly what
/// broke DMs / game-invite delivery: the server logged
/// <c>[notify] sending id=MessageReceived ...
/// connections=1</c> AND the watch's topmost
/// <c>OnNotificationReceived</c> hook saw the payload, but
/// <c>Messages.OnMessageReceived</c> never fired because
/// <c>handlers["MessageReceived"]</c> didn't exist —
/// <c>handlers["2"]</c> did.</item>
/// <item>Registered under a CUSTOM short name (no integer
/// companion): <c>SubscriptionUpdate*</c> →
/// <c>"PresenceUpdate"</c> / <c>"RoomInstanceUpdate"</c> /
/// <c>"AccountUpdate"</c>, and <c>ChatMessageReceived
/// (90)</c> which is registered only as
/// <c>"ChatMessageReceived"</c>, NOT <c>"90"</c>.</item>
/// <item>Registered through the enum overload, which the IL2CPP
/// client lowers to the numeric string value. Gifts are a
/// concrete example: <c>GiftPackageReceived</c> registers
/// <c>"30"</c> and <c>GiftPackageReceivedImmediate</c>
/// registers <c>"31"</c>; sending the enum name misses.</item>
/// </list></summary>
private static string WireName(PushNotificationId id) => id switch
{
// Custom short names — registered under a bespoke string,
// no integer companion in the dispatch dict.
PushNotificationId.SubscriptionUpdatePresence => "PresenceUpdate",
PushNotificationId.SubscriptionUpdateGameSession => "RoomInstanceUpdate",
PushNotificationId.SubscriptionUpdateProfile => "AccountUpdate",
// Int-only registrations — sending the enum name misses.
PushNotificationId.MessageReceived => "2",
PushNotificationId.MessageDeleted => "3",
PushNotificationId.ModerationKick => "22",
PushNotificationId.ModerationKickAttemptFailed => "23",
PushNotificationId.ModerationRoomBan => "24",
PushNotificationId.GiftPackageReceived => "30",
PushNotificationId.GiftPackageReceivedImmediate => "31",
PushNotificationId.PlayerEventCreated => "80",
PushNotificationId.PlayerEventUpdated => "81",
PushNotificationId.PlayerEventDeleted => "82",
PushNotificationId.PlayerEventResponseChanged => "83",
PushNotificationId.PlayerEventResponseDeleted => "84",
PushNotificationId.PlayerEventStateChanged => "85",
// Everything else: enum.ToString() matches because the watch's
// enum-overload RegisterHandler registers both forms.
_ => id.ToString(),
};
private static string Serialize(PushNotificationId id, object? msg) =>
JsonSerializer.Serialize(new { Id = WireName(id), Msg = msg }, Json);
/// <summary>Push a single notification to one player. No-op if the
/// player has no active connections.</summary>
public async Task NotifyAsync(long playerId, PushNotificationId id, object? msg = null)
{
var payload = Serialize(id, msg);
// Include the connection count so we can tell at a glance
// whether the push had a recipient. Zero connections = the
// watch isn't on SignalR right now (transport churn, just-
// logged-out session, etc.) and the message is dropped.
// The save flow specifically REQUIRES the watch to have a
// live SignalR connection at send time — without it,
// SubscriptionUpdateRoom evaporates into the hub and
// MasterUploadRoomDataBlobCoroutine waits forever on
// roomDetailsUpdated.
var connections = onlinePresence.GetConnections(playerId);
logger.LogInformation(
"[notify] sending id={Id} wireName={WireName} to player={PlayerId} bytes={Bytes} connections={ConnectionCount}",
id, WireName(id), playerId, payload.Length, connections.Count);
if (id == PushNotificationId.SubscriptionUpdateRoom)
{
logger.LogInformation(
"[notify] room-update payload player={PlayerId} json={Payload}",
playerId, payload.Length <= 6000 ? payload : payload[..6000] + "...<truncated>");
}
await hub.Clients.Group(NotifyHub.GroupForPlayer(playerId))
.SendAsync(HubMethod, payload);
}
public async Task NotifyTypedAsync(long playerId, string notificationType, object? msg = null)
{
try
{
var payload = JsonSerializer.Serialize(new { Id = notificationType, Msg = msg }, Json);
await hub.Clients.Group(NotifyHub.GroupForPlayer(playerId))
.SendAsync(HubMethod, payload);
}
catch (Exception ex)
{
logger.LogWarning(ex,
"[notify] failed to send typed notification {Type} to player {PlayerId}",
notificationType, playerId);
}
}
/// <summary>Push a notification to every currently-connected
/// player. Used for global announcements and admin broadcasts.</summary>
public async Task BroadcastAsync(PushNotificationId id, object? msg = null)
{
await hub.Clients.All.SendAsync(HubMethod, Serialize(id, msg));
}
/// <summary>Push the same notification to every player in
/// <paramref name="playerIds"/>. Used for "your friends are online"
/// fan-out.</summary>
public async Task NotifyManyAsync(
IEnumerable<long> playerIds, PushNotificationId id, object? msg = null)
{
var groups = playerIds.Select(NotifyHub.GroupForPlayer).ToArray();
if (groups.Length == 0) return;
await hub.Clients.Groups(groups).SendAsync(HubMethod, Serialize(id, msg));
}
// ── Higher-level event helpers ─────────────────────────────────────
public Task FriendRequestReceived(long targetPlayerId, long fromPlayerId) =>
NotifyAsync(targetPlayerId, PushNotificationId.RelationshipChanged,
new { Reason = "FriendRequestReceived", From = fromPlayerId });
public Task FriendRequestAccepted(long requesterPlayerId, long byPlayerId) =>
NotifyAsync(requesterPlayerId, PushNotificationId.RelationshipChanged,
new { Reason = "FriendRequestAccepted", By = byPlayerId });
public Task FriendRequestDeclined(long requesterPlayerId, long byPlayerId) =>
NotifyAsync(requesterPlayerId, PushNotificationId.RelationshipChanged,
new { Reason = "FriendRequestDeclined", By = byPlayerId });
public Task FriendRemoved(long otherPlayerId, long byPlayerId) =>
NotifyAsync(otherPlayerId, PushNotificationId.RelationshipChanged,
new { Reason = "FriendRemoved", By = byPlayerId });
public Task FriendBlocked(long blockedPlayerId, long byPlayerId) =>
NotifyAsync(blockedPlayerId, PushNotificationId.RelationshipChanged,
new { Reason = "FriendBlocked", By = byPlayerId });
/// <summary>Push a presence delta so subscribed friends re-render
/// their friend-list row without waiting for the watch's 15-45 s
/// bulk-presence poll. The <c>Msg</c> body MUST match
/// <c>RecNet.PlayerPresence</c>'s wire shape — the watch's
/// <c>OnSubscriptionUpdatePresence</c> handler (dump.cs:579282)
/// passes the raw dict straight into <c>PlayerPresence.Deserialize</c>
/// then <c>UpdateCachedPresence</c>. Anything other than that exact
/// shape (e.g. an ad-hoc <c>{ PlayerId, RoomId }</c>) is silently
/// dropped client-side and the friend-list room stays stuck on
/// whatever was last bulk-fetched.
///
/// Builds the payload directly as <c>JsonObject</c> (instead of
/// boxing a <see cref="PlayerPresenceDto"/> through
/// <see cref="PushNotification.Msg"/> as <c>object</c>) so the inner
/// camelCase keys don't depend on System.Text.Json's
/// polymorphic-property heuristics. With <c>object?</c>-typed Msg,
/// 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)
{
var groups = friendIds.Select(NotifyHub.GroupForPlayer).ToArray();
if (groups.Length == 0) return;
var redacted = RoomInstanceDto.Redact(room);
var msg = new System.Text.Json.Nodes.JsonObject
{
["playerId"] = (int)playerId,
["statusVisibility"] = 1,
["deviceClass"] = 0,
["vrMovementMode"] = 0,
["roomInstance"] = redacted is null
? null
: new System.Text.Json.Nodes.JsonObject
{
["roomInstanceId"] = redacted.RoomInstanceId,
["roomId"] = redacted.RoomId,
["subRoomId"] = redacted.SubRoomId,
["roomInstanceType"] = redacted.RoomInstanceType,
["location"] = redacted.Location,
["dataBlob"] = redacted.DataBlob,
["eventId"] = redacted.EventId,
["clubId"] = redacted.ClubId,
["roomCode"] = redacted.RoomCode,
["photonRegionId"] = redacted.PhotonRegionId,
["photonRoomId"] = redacted.PhotonRoomId,
["name"] = redacted.Name,
["maxCapacity"] = redacted.MaxCapacity,
["isFull"] = redacted.IsFull,
["isPrivate"] = redacted.IsPrivate,
["isInProgress"] = redacted.IsInProgress,
["EncryptVoiceChat"] = redacted.EncryptVoiceChat,
},
// 2020.12 PlayerPresence.Deserialize requires these.
["isOnline"] = redacted is not null,
["appVersion"] = 20201210,
};
var envelope = new System.Text.Json.Nodes.JsonObject
{
// String dispatch key (NOT the int enum value) —
// see WireName for rationale.
["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);
}
public Task PlayerCameOnline(IEnumerable<long> friendIds, long playerId, RoomInstanceDto? room) =>
PlayerPresenceChanged(friendIds, playerId, room);
public Task PlayerWentOffline(IEnumerable<long> friendIds, long playerId) =>
PlayerPresenceChanged(friendIds, playerId, null);
public Task PlayerEnteredRoom(IEnumerable<long> friendIds, long playerId, RoomInstanceDto room) =>
PlayerPresenceChanged(friendIds, playerId, room);
/// <summary>Force every currently-connected SignalR session for
/// <paramref name="playerId"/> to disconnect via the watch's
/// ModerationKick flow. Wire path verified against decompilation:
/// <c>PushNotificationId.ModerationKick = 22</c> is registered
/// at <c>PlayerReporting.txt:244</c> via the enum-overload, so
/// the wire name resolves to <c>"ModerationKick"</c>.
/// <c>OnModerationKick(Dictionary)</c> at
/// <c>PlayerReporting.txt:1857</c> shows a confirmation dialog
/// then calls <c>LocalModerationKickSelf</c>
/// (<c>PlayerReporting.txt:2647</c>), which queues the
/// disconnect through <c>Core.ShutdownForLogout</c> — the same
/// graceful path the watch uses for real moderator kicks.
///
/// Used by <see cref="Controllers.Match.MatchPlayerController.PlayerLogin"/>
/// to enforce single-session-per-account: when a second login on
/// the same account replaces the LoginLock, the older SignalR
/// connections are kicked. The new session hasn't called
/// <c>negotiate</c> yet (it happens AFTER /player/login on the
/// boot sequence), so the kick only hits old sessions.
/// </summary>
public Task KickStaleSession(long playerId, string reason) =>
NotifyAsync(playerId, PushNotificationId.ModerationKick,
new
{
// Field names follow PlayerReporting's wire convention
// for moderation-related dialogs; the watch's
// OnModerationKick deserializes via Util.GetKeyOrDefault
// so missing fields are tolerated, but populating them
// makes the dialog read sensibly.
Reason = reason,
DisplayReason = reason,
BannedByPlayerId = 1, // Coach
Source = "session_replaced",
});
/// <summary>Push a ModerationKick to a specific set of SignalR
/// connection ids — used by <see cref="DorkNet.Server.Hubs.NotifyHub.OnConnectedAsync"/>
/// to evict the previous session when a new device connects on the
/// same account. Targets specific ids (not the player group) so
/// the brand-new connection joining the group right after this
/// returns can't accidentally receive its own eviction notice.
/// Works across replicas because SignalR's Redis backplane routes
/// <c>Clients.Clients(ids)</c> to whichever node owns each id.</summary>
public Task KickConnectionsAsync(IReadOnlyCollection<string> connectionIds, string reason)
{
if (connectionIds.Count == 0) return Task.CompletedTask;
var payload = Serialize(PushNotificationId.ModerationKick, new
{
Reason = reason,
DisplayReason = reason,
BannedByPlayerId = 1,
Source = "session_replaced",
});
return hub.Clients.Clients(connectionIds).SendAsync(HubMethod, payload);
}
/// <summary>Notify a private-instance owner that someone tried to
/// follow them in via "Go to Player" but was rejected for not
/// being on the invite list. Reuses the MessageReceived channel
/// since the watch already renders that as an inbox toast — no
/// new client-side handler needed. The body wording surfaces it
/// as "Player X tried to join your private match" so the owner
/// knows to send an explicit invite if they want to let them in.</summary>
public Task PrivateInstanceJoinRejected(long ownerPlayerId, long attemptedByPlayerId) =>
NotifyAsync(ownerPlayerId, PushNotificationId.MessageReceived,
new
{
Reason = "PrivateInstanceJoinRejected",
AttemptedBy = attemptedByPlayerId,
Preview = $"Player {attemptedByPlayerId} tried to join your private match",
});
}
@@ -0,0 +1,360 @@
using Amazon.S3;
using Amazon.S3.Model;
using Amazon.Runtime;
namespace DorkNet.Server.Services;
/// <summary>
/// Thin S3-compatible object-storage abstraction. Targets Garage in
/// production Coolify (one-click service); same SDK works against any
/// S3 endpoint for local dev (MinIO / LocalStack) or future migration
/// (Cloudflare R2, real AWS).
///
/// Two buckets, split by mutability rather than feature:
/// <list type="bullet">
/// <item><c>Buckets.Content</c> — hash-addressed, immutable, dedup'd.
/// HTR audio/holotar bytes, PV preview images, polaroids, camera
/// photos, profile avatars, room thumbnails. Same bytes are
/// referenced by many rooms / players, so they're keyed primarily
/// by hash and (for player-attributed content) sharded by
/// <c>{kind}/{playerId}/{filename}</c>.</item>
/// <item><c>Buckets.Saves</c> — owner-mutable user data. Dorm save
/// snapshots, custom-room save snapshots, invention saves, holotar
/// / video user uploads. Keyed by <c>{kind}/{ownerId or roomId}/
/// {filename}</c>. Old versions can be lifecycle'd off; daily
/// backup priority lives here.</item>
/// </list>
///
/// Routing happens via <see cref="BlobRouter"/> — every BlobName has
/// exactly one canonical (bucket, key). Removed the candidate-list
/// fallback the older 3-bucket layout needed because content was
/// scattered across feature-themed buckets.
///
/// **Fallback when no S3 is configured**: writes go to disk under
/// <c>data/object-fallback/{bucket}/{key}</c>. Keeps local single-instance
/// dev working without spinning up MinIO. Production Coolify always
/// has the env vars set so the disk path is a dev-only convenience.
///
/// Falls back silently to disk so the controllers don't have to
/// branch on configuration. <see cref="IsS3Configured"/> is exposed
/// for the migrator + admin tooling that needs to know the actual mode.
/// </summary>
public interface IObjectStorage
{
bool IsS3Configured { get; }
/// <summary>Upload bytes. Overwrites any existing object with the
/// same key. Returns the byte count actually written (matches
/// <paramref name="bytes"/>.Length in success cases; differs only
/// if the underlying store reports otherwise).</summary>
Task<long> PutAsync(string bucket, string key, byte[] bytes, string contentType, CancellationToken ct = default);
/// <summary>Download bytes for a given key. Returns null when the
/// object doesn't exist. Throws on transport errors so callers can
/// distinguish "not there" from "broken".</summary>
Task<byte[]?> GetAsync(string bucket, string key, CancellationToken ct = default);
/// <summary>True when an object with this key exists. Cheaper than
/// GetAsync when the caller only needs existence (e.g. CdnController
/// MISS check before falling back to default blob).</summary>
Task<bool> ExistsAsync(string bucket, string key, CancellationToken ct = default);
}
public static class Buckets
{
public const string Content = "dorknet-content";
public const string Saves = "dorknet-saves";
}
/// <summary>
/// Single source of truth for "where does this filename live in S3?".
/// Read path (Cdn/Img controllers) and write path (Storage/Images
/// controllers, importers, mirrors) BOTH call <see cref="Route(string)"/>
/// with the same filename — guaranteed-consistent placement, no
/// candidate fallback chain, no read/write divergence.
///
/// Routing rules, in priority order:
/// 1. Save-shaped filenames (<c>dorm_p&lt;player&gt;_v*.dat</c>,
/// <c>room_&lt;id&gt;_v*.dat</c>, <c>invention_p&lt;player&gt;_*.dat</c>)
/// → <see cref="Buckets.Saves"/>, sharded by owner/room so per-owner
/// lifecycle policies can prune old versions independently.
/// 2. Everything else routes to <see cref="Buckets.Content"/> by file
/// extension: <c>.htr</c> → <c>htr/</c>, image extensions →
/// <c>image/</c>, <c>.mp4</c> → <c>video/</c>, anything else →
/// <c>blob/</c>. Ownership is already encoded in the filename
/// (<c>img_p&lt;player&gt;_*</c>, <c>holotar_p&lt;player&gt;_*</c>,
/// …) so we don't shard by player in Content — a query like "all
/// of player N's stuff" goes through the DB
/// (<c>RoomDataBlobs.UploadedByPlayerId</c>) which is the source
/// of truth anyway.
///
/// Net effect: any two paths that share the same BlobName produce the
/// same (bucket, key). Same holotar bytes uploaded by a player then
/// later referenced from a room save → one S3 object, not two.
/// </summary>
public static class BlobRouter
{
public static (string Bucket, string Key) Route(string fileName)
{
// Saves first — these filename shapes are unambiguous.
if (TryGetPlayerId(fileName, "dorm_p", out var p)) return (Buckets.Saves, $"dorm/{p}/{fileName}");
if (TryGetPlayerId(fileName, "invention_p", out p)) return (Buckets.Saves, $"invention/{p}/{fileName}");
if (TryGetRoomId (fileName, out var r)) return (Buckets.Saves, $"room/{r}/{fileName}");
// Content, by file extension. Owner (if any) stays in the filename.
var ext = Path.GetExtension(fileName).ToLowerInvariant();
if (ext == ".htr") return (Buckets.Content, $"htr/{fileName}");
if (ext is ".png" or ".jpg" or ".jpeg" or ".gif" or ".webp")
return (Buckets.Content, $"image/{fileName}");
// All video formats land under video/ — the CdnController's
// `/video/{*path}` route maps every BlobName here verbatim, and
// keeping the prefix consistent across extensions means an
// admin grep'ing the bucket finds every uploaded clip in one
// place regardless of source container format.
if (ext is ".mp4" or ".m4v" or ".webm" or ".mov")
return (Buckets.Content, $"video/{fileName}");
return (Buckets.Content, $"blob/{fileName}");
}
private static bool TryGetPlayerId(string fileName, string prefix, out long playerId)
{
playerId = 0;
if (!fileName.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) return false;
var start = prefix.Length;
var end = fileName.IndexOf('_', start);
return end > start && long.TryParse(fileName[start..end], out playerId);
}
private static bool TryGetRoomId(string fileName, out long roomId)
{
roomId = 0;
const string prefix = "room_";
if (!fileName.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) return false;
// Permissive: anything starting with "room_<digits>_" or
// "room_<digits>." routes to that room's save folder. Covers
// the canonical save snapshot (room_<id>_v<N>.dat) AND any
// room-tagged sidecar (room_<id>_<hash>.meta, room_<id>_thumb.jpg,
// future per-room attachments) — same parser, same folder,
// no separate matcher per content type.
var endUnderscore = fileName.IndexOf('_', prefix.Length);
var endDot = fileName.IndexOf('.', prefix.Length);
var end =
endUnderscore > 0 && (endDot < 0 || endUnderscore < endDot) ? endUnderscore :
endDot > 0 ? endDot : -1;
return end > prefix.Length && long.TryParse(fileName[prefix.Length..end], out roomId);
}
}
public class ObjectStorageService : IObjectStorage, IDisposable
{
private readonly IAmazonS3? _s3;
private readonly string _diskFallbackRoot;
private readonly ILogger<ObjectStorageService> _log;
public bool IsS3Configured => _s3 is not null;
public ObjectStorageService(IConfiguration config, ILogger<ObjectStorageService> log)
{
_log = log;
_diskFallbackRoot = Path.Combine(AppContext.BaseDirectory, "data", "object-fallback");
// Provider switch: env-driven. The Garage one-click service in
// Coolify exposes its endpoint on http://garage:3900 and supplies
// access/secret keys via env. ForcePathStyle is required because
// Garage (like MinIO) doesn't do virtual-host-style.
var endpoint = config["S3:Endpoint"];
var accessKey = config["S3:AccessKey"];
var secretKey = config["S3:SecretKey"];
if (string.IsNullOrWhiteSpace(endpoint)
|| string.IsNullOrWhiteSpace(accessKey)
|| string.IsNullOrWhiteSpace(secretKey))
{
_log.LogInformation(
"[storage] no S3 endpoint/credentials configured — falling back to disk at {Root}",
_diskFallbackRoot);
return;
}
var s3Config = new AmazonS3Config
{
ServiceURL = endpoint,
ForcePathStyle = true,
MaxErrorRetry = config.GetValue<int?>("S3:MaxErrorRetry") ?? 1,
// S3:TimeoutSeconds is the HARD ceiling enforced by the AWS
// SDK at the HttpClient level — it kills the entire upload
// request once exceeded, even if a CancellationToken passed
// by the caller is more generous. The 8-second value we
// shipped originally was too tight for the storage
// migrator (some HTR blobs are 25-50 MB; on a typical
// 10 Mbps home upload that's 20-40 s). 300 s gives plenty
// of headroom; per-call CancellationTokens still impose
// tighter bounds where the caller needs them (e.g. the
// 8 s CTS in StorageController.StoreBlobObjectAsync keeps
// in-game upload paths fast-failing).
Timeout = TimeSpan.FromSeconds(config.GetValue<int?>("S3:TimeoutSeconds") ?? 300),
// Garage requires *some* AuthenticationRegion string, value
// irrelevant. The S3 SDK signs requests against this region;
// using a non-empty placeholder avoids a SignatureDoesNotMatch
// error. R2 / real AWS will set this via env.
AuthenticationRegion = config["S3:Region"] ?? "garage",
// AWSSDK 3.7.412+ Flexible Checksums sends x-amz-checksum-*
// headers Garage rejects, breaking payload signatures. Limit
// checksum computation to operations that genuinely require one.
RequestChecksumCalculation = Amazon.Runtime.RequestChecksumCalculation.WHEN_REQUIRED,
ResponseChecksumValidation = Amazon.Runtime.ResponseChecksumValidation.WHEN_REQUIRED,
};
if (config.GetValue<bool>("S3:DisableTlsCertificateValidation"))
{
_log.LogWarning(
"[storage] S3 TLS certificate validation is disabled. Use only for private/self-hosted endpoints.");
s3Config.HttpClientFactory = new UnsafeCertificateHttpClientFactory();
}
_s3 = new AmazonS3Client(accessKey, secretKey, s3Config);
_log.LogInformation("[storage] S3 client configured for endpoint={Endpoint}", endpoint);
}
public async Task<long> PutAsync(string bucket, string key, byte[] bytes, string contentType, CancellationToken ct = default)
{
if (_s3 is { } s3)
{
async Task<PutObjectResponse> PutObjectAsync()
{
using var stream = new MemoryStream(bytes);
return await s3.PutObjectAsync(new PutObjectRequest
{
BucketName = bucket,
Key = key,
InputStream = stream,
ContentType = contentType,
UseChunkEncoding = false,
}, ct);
}
PutObjectResponse resp;
try
{
resp = await PutObjectAsync();
}
catch (AmazonS3Exception ex) when (IsNoSuchBucket(ex))
{
_log.LogWarning(
ex,
"[storage] bucket {Bucket} was missing while writing {Key}; creating it and retrying",
bucket,
key);
await CreateBucketAsync(s3, bucket, ct);
resp = await PutObjectAsync();
}
if (resp.HttpStatusCode is not (System.Net.HttpStatusCode.OK or System.Net.HttpStatusCode.Created))
throw new InvalidOperationException($"S3 PUT {bucket}/{key} returned {resp.HttpStatusCode}");
return bytes.Length;
}
// Disk fallback — used in dev when no S3 is configured.
var path = DiskPath(bucket, key);
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
await File.WriteAllBytesAsync(path, bytes, ct);
return bytes.Length;
}
public async Task<byte[]?> GetAsync(string bucket, string key, CancellationToken ct = default)
{
if (_s3 is { } s3)
{
try
{
using var resp = await s3.GetObjectAsync(bucket, key, ct);
using var ms = new MemoryStream();
await resp.ResponseStream.CopyToAsync(ms, ct);
return ms.ToArray();
}
catch (AmazonS3Exception ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
{
return null;
}
}
var path = DiskPath(bucket, key);
return File.Exists(path) ? await File.ReadAllBytesAsync(path, ct) : null;
}
public async Task<bool> ExistsAsync(string bucket, string key, CancellationToken ct = default)
{
if (_s3 is { } s3)
{
try
{
await s3.GetObjectMetadataAsync(bucket, key, ct);
return true;
}
catch (AmazonS3Exception ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
{
return false;
}
catch (AmazonS3Exception ex)
{
// Anything else — 403 (Garage's admin key lacks the
// HeadObject permission on this bucket), 5xx, transport
// wobble — is NOT a definitive "object missing" signal.
// Treat as "unknown" and return true so the upload path
// doesn't return 500 just because verification couldn't
// run. The caller logs the warning so the asymmetric
// ACL still shows up in telemetry.
_log.LogWarning(ex,
"[storage] HEAD {Bucket}/{Key} returned {Status}; treating as 'present (unverifiable)'",
bucket, key, ex.StatusCode);
return true;
}
catch (Exception ex)
{
_log.LogWarning(ex,
"[storage] HEAD {Bucket}/{Key} threw {Type}; treating as 'present (unverifiable)'",
bucket, key, ex.GetType().Name);
return true;
}
}
return File.Exists(DiskPath(bucket, key));
}
private string DiskPath(string bucket, string key) =>
Path.Combine(_diskFallbackRoot, bucket, key.Replace('/', Path.DirectorySeparatorChar));
private static bool IsNoSuchBucket(AmazonS3Exception ex) =>
ex.StatusCode == System.Net.HttpStatusCode.NotFound
|| string.Equals(ex.ErrorCode, "NoSuchBucket", StringComparison.OrdinalIgnoreCase);
private async Task CreateBucketAsync(IAmazonS3 s3, string bucket, CancellationToken ct)
{
try
{
await s3.PutBucketAsync(new PutBucketRequest
{
BucketName = bucket,
}, ct);
_log.LogInformation("[storage] created S3 bucket {Bucket}", bucket);
}
catch (AmazonS3Exception ex) when (
ex.StatusCode == System.Net.HttpStatusCode.Conflict
|| string.Equals(ex.ErrorCode, "BucketAlreadyExists", StringComparison.OrdinalIgnoreCase)
|| string.Equals(ex.ErrorCode, "BucketAlreadyOwnedByYou", StringComparison.OrdinalIgnoreCase))
{
_log.LogInformation("[storage] S3 bucket {Bucket} already exists", bucket);
}
}
private sealed class UnsafeCertificateHttpClientFactory : HttpClientFactory
{
public override HttpClient CreateHttpClient(IClientConfig clientConfig)
{
var handler = new HttpClientHandler
{
ServerCertificateCustomValidationCallback =
HttpClientHandler.DangerousAcceptAnyServerCertificateValidator,
};
return new HttpClient(handler);
}
}
public void Dispose() => _s3?.Dispose();
}
@@ -0,0 +1,146 @@
using System.Collections.Concurrent;
using StackExchange.Redis;
namespace DorkNet.Server.Services;
/// <summary>
/// Cross-replica tracker for the set of currently-online players (anyone
/// with at least one active SignalR connection somewhere in the cluster).
///
/// **Replaces the static <c>NotifyHub._byPlayer</c> dictionary** which
/// only tracked connections on the local replica. With horizontal scale,
/// admin tools that asked "is player X online" got a per-replica answer
/// — they'd see a player as offline if that player happened to be
/// connected to a different replica. This service centralises the
/// answer in Redis so all replicas agree.
///
/// **Two Redis keys per player**:
/// <list type="bullet">
/// <item><c>notify:conns:{playerId}</c> — Set of active connection ids
/// for that player on any replica. Connections add themselves on
/// <see cref="Hubs.NotifyHub.OnConnectedAsync"/> and remove on
/// <see cref="Hubs.NotifyHub.OnDisconnectedAsync"/>. The set has
/// a 24h EXPIRE backstop so a replica that crashes mid-flight
/// doesn't leak connection ids forever.</item>
/// <item><c>notify:online</c> — Master set of player ids that have at
/// least one active connection. Maintained explicitly: SADD when
/// the conns set transitions empty→non-empty, SREM when it goes
/// non-empty→empty. <see cref="OnlinePlayerIds"/> returns SMEMBERS
/// in one round-trip.</item>
/// </list>
///
/// Falls back to in-process state when no Redis is configured (local dev).
/// Same semantics — just per-replica visibility.
/// </summary>
public class OnlinePresenceService
{
private readonly IConnectionMultiplexer? _redis;
// Local fallback only. The outer dict maps playerId → connection-id set.
private readonly ConcurrentDictionary<long, HashSet<string>> _localConns = new();
/// <summary>Aggressive backstop: if a replica vanishes ungracefully,
/// the conns set ages out instead of leaking forever. Real disconnect
/// events should remove specific connection ids well before this.</summary>
public static TimeSpan ConnectionsKeyTtl { get; } = TimeSpan.FromHours(24);
public OnlinePresenceService(IConnectionMultiplexer? redis = null) => _redis = redis;
public async Task AddConnectionAsync(long playerId, string connectionId)
{
if (_redis is { } mux)
{
var db = mux.GetDatabase();
var connsKey = ConnsKey(playerId);
var added = await db.SetAddAsync(connsKey, connectionId);
await db.KeyExpireAsync(connsKey, ConnectionsKeyTtl);
// Only flip the master `online` set when this is the FIRST
// connection for the player — saves a SADD on every retry
// reconnect and keeps the online set tight.
if (added)
{
var size = await db.SetLengthAsync(connsKey);
if (size == 1) await db.SetAddAsync(OnlineSetKey, playerId);
}
return;
}
var set = _localConns.GetOrAdd(playerId, _ => new HashSet<string>());
lock (set) set.Add(connectionId);
}
public async Task RemoveConnectionAsync(long playerId, string connectionId)
{
if (_redis is { } mux)
{
var db = mux.GetDatabase();
var connsKey = ConnsKey(playerId);
var removed = await db.SetRemoveAsync(connsKey, connectionId);
if (removed)
{
var remaining = await db.SetLengthAsync(connsKey);
if (remaining == 0)
{
// Last connection for this player gone — drop them
// from the online set so admin queries see "offline".
await db.SetRemoveAsync(OnlineSetKey, playerId);
await db.KeyDeleteAsync(connsKey);
}
}
return;
}
if (_localConns.TryGetValue(playerId, out var set))
{
lock (set) set.Remove(connectionId);
if (set.Count == 0) _localConns.TryRemove(playerId, out _);
}
}
/// <summary>Snapshot of SignalR connection ids that currently belong
/// to <paramref name="playerId"/>. Used by <see cref="DorkNet.Server.Hubs.NotifyHub.OnConnectedAsync"/>
/// to detect a second-session join and kick the existing
/// connection(s) via a targeted ModerationKick push. Returns an
/// empty collection if nobody (or only the current caller) is
/// connected — never null.</summary>
public IReadOnlyCollection<string> GetConnections(long playerId)
{
if (_redis is { } mux)
{
try
{
var members = mux.GetDatabase().SetMembers(ConnsKey(playerId));
if (members.Length == 0) return Array.Empty<string>();
var result = new string[members.Length];
for (int i = 0; i < members.Length; i++) result[i] = members[i].ToString();
return result;
}
catch
{
// Redis sick → fall through to local fallback rather
// than pretend there are no existing connections.
}
}
if (!_localConns.TryGetValue(playerId, out var set)) return Array.Empty<string>();
lock (set) return set.ToArray();
}
/// <summary>Snapshot of currently-online player ids across the
/// cluster. Returns an empty array when nobody's online; never null.
/// Callers typically <c>.ToHashSet()</c> the result for membership
/// queries inside a tight loop.</summary>
public IReadOnlyCollection<long> OnlinePlayerIds()
{
if (_redis is { } mux)
{
var members = mux.GetDatabase().SetMembers(OnlineSetKey);
var result = new long[members.Length];
for (int i = 0; i < members.Length; i++)
result[i] = (long)members[i];
return result;
}
return _localConns.Where(kv => kv.Value.Count > 0).Select(kv => kv.Key).ToArray();
}
private const string OnlineSetKey = "notify:online";
private static string ConnsKey(long playerId) => $"notify:conns:{playerId}";
}
@@ -0,0 +1,145 @@
using System.Collections.Concurrent;
using Microsoft.Extensions.Logging;
using StackExchange.Redis;
namespace DorkNet.Server.Services;
/// <summary>
/// Cross-replica ledger of "freshly created accounts that haven't logged
/// in yet." Backs the orphan-cleanup pattern around <c>account/create</c>:
///
/// • The 2020 watch boot flow always calls <c>account/create</c>
/// after <c>cachedlogin</c> as a defensive "ensure my account
/// exists" probe, regardless of whether the user is picking a
/// cached account or making a brand-new one. We can't tell which
/// intent it is from the request alone — both flows look identical
/// on the wire.
///
/// • Solution: <c>account/create</c> ALWAYS creates a new row (so the
/// "Create new account" UI is honoured), but the new id gets
/// registered here. When the watch's next call is
/// <c>cached_login</c> with a DIFFERENT account_id (the boot-flow
/// case — user picked their cached account), the cached_login
/// handler reaches into this tracker and deletes the just-created
/// orphan. When cached_login picks the same id (the "create new"
/// case — user is logging in to the new account), we leave it.
///
/// Entries expire after <see cref="Ttl"/> via Redis EXPIRE so a stale
/// boot doesn't keep a row orphaned indefinitely. Keys are namespaced
/// <c>orphan:{deviceId}|{platformId}</c>.
///
/// **Multi-replica**: when <see cref="IConnectionMultiplexer"/> is
/// available (Coolify production), the state lives in Redis and is
/// visible across every replica — so a <c>POST account/create</c> on
/// replica A followed by <c>POST connect/token (cached_login)</c> on
/// replica B sees the same orphan id and cleans it up correctly. When
/// no Redis is configured (local single-instance dev), falls back to
/// a process-local <see cref="ConcurrentDictionary"/> with the same
/// semantics — identical to pre-PR-2 behaviour.
/// </summary>
public class OrphanAccountTracker
{
private record Entry(long AccountId, DateTime CreatedAt);
private readonly ConcurrentDictionary<string, Entry> _local = new();
private readonly IConnectionMultiplexer? _redis;
private readonly ILogger<OrphanAccountTracker>? _logger;
public OrphanAccountTracker(IConnectionMultiplexer? redis = null,
ILogger<OrphanAccountTracker>? logger = null)
{
_redis = redis;
_logger = logger;
}
/// <summary>Time window after creation during which a different
/// cached_login call counts as a boot-flow orphan.</summary>
public static TimeSpan Ttl { get; } = TimeSpan.FromMinutes(5);
/// <summary>Record that <paramref name="accountId"/> was just
/// created via account/create on the given device. Overwrites any
/// older pending entry for the same device — we only care about
/// the most recent in-flight creation.</summary>
public void TrackCreation(string? deviceId, string? platformId, long accountId)
{
var key = MakeKey(deviceId, platformId);
if (_redis is { } mux)
{
try
{
// SET key value EX 300 — atomic create-or-overwrite with TTL.
mux.GetDatabase().StringSet(key, accountId, Ttl);
return;
}
catch (RedisException ex)
{
// Redis sick (AOF full, MISCONF, connection drop). Fall
// through to local tracking so login keeps working — the
// worst case is that orphan recovery loses cross-replica
// visibility until Redis recovers.
_logger?.LogWarning(ex,
"[orphan-tracker] Redis SET failed for {Key}; falling back to local", key);
}
}
_local[key] = new Entry(accountId, DateTime.UtcNow);
}
/// <summary>Look up the in-flight creation for this device. Returns
/// null when there's no pending entry, or when the entry has
/// expired. Read-only — the entry stays around for a possible
/// later <see cref="Clear"/> call.</summary>
public long? PeekPending(string? deviceId, string? platformId)
{
var key = MakeKey(deviceId, platformId);
if (_redis is { } mux)
{
try
{
var v = mux.GetDatabase().StringGet(key);
// RedisValue is implicitly convertible — null/missing returns
// RedisValue.Null which TryParse would bail on.
if (v.HasValue && long.TryParse(v.ToString(), out var id)) return id;
// Fall through to local check — a previous TrackCreation
// might have fallen back to local while Redis was sick.
}
catch (RedisException ex)
{
_logger?.LogWarning(ex,
"[orphan-tracker] Redis GET failed for {Key}; falling back to local", key);
}
}
if (!_local.TryGetValue(key, out var entry)) return null;
if (DateTime.UtcNow - entry.CreatedAt > Ttl)
{
_local.TryRemove(key, out _);
return null;
}
return entry.AccountId;
}
/// <summary>Drop the pending entry for this device — typically
/// called after we've decided whether to delete the orphan or keep
/// it. Idempotent.</summary>
public void Clear(string? deviceId, string? platformId)
{
var key = MakeKey(deviceId, platformId);
if (_redis is { } mux)
{
try { mux.GetDatabase().KeyDelete(key); }
catch (RedisException ex)
{
// Best-effort delete. Stale orphan keys expire after
// Ttl anyway, so swallowing this is safe.
_logger?.LogWarning(ex,
"[orphan-tracker] Redis DEL failed for {Key}; ignoring (TTL will collect)", key);
}
}
_local.TryRemove(key, out _);
}
/// <summary>Composite key. Empty platformId is fine — single-Steam-
/// emu setups still differentiate by deviceId alone. Redis keys
/// are namespaced under <c>orphan:</c> so they don't collide with
/// other tenants in the same instance.</summary>
private static string MakeKey(string? deviceId, string? platformId) =>
$"orphan:{deviceId ?? ""}|{platformId ?? ""}";
}
+143
View File
@@ -0,0 +1,143 @@
using System.Collections.Concurrent;
using System.Text.Json;
using System.Text.Json.Serialization;
using StackExchange.Redis;
namespace DorkNet.Server.Services;
/// <summary>
/// Rolling per-player request log used by the admin UI's "Player logs"
/// tab. The HTTP trace middleware in <c>Program.cs</c> appends one entry
/// per authenticated request; the admin endpoint reads the most-recent
/// N entries back for a single player so we can spot what API call a
/// player was making when they hit a bug.
///
/// Storage:
/// - When Redis is configured, entries live in a Redis LIST keyed by
/// <c>playerlog:{id}</c>. <c>LPUSH</c> + <c>LTRIM</c> keeps it at the
/// most recent <see cref="MaxEntriesPerPlayer"/> records, so memory
/// per player is bounded regardless of traffic.
/// - Without Redis, falls back to an in-process per-player ring buffer
/// capped to the same size; lost on restart and only visible on the
/// replica that handled the request, but fine for single-instance
/// dev workflows.
///
/// Entries are intentionally ephemeral — there's no Postgres mirror,
/// no long retention, no cross-replica fan-out beyond Redis. Treat
/// this as a "what just happened" diagnostic, not an audit trail.
/// </summary>
public class PlayerLogService
{
/// <summary>Cap per player. Redis list takes ~250 bytes per entry
/// with truncated bodies, so ~125 KB per player at 500 entries.
/// At 5-second heartbeat cadence that's ~40 minutes of recent
/// activity, plenty for "what was the player doing when X
/// happened" diagnostics.</summary>
public const int MaxEntriesPerPlayer = 500;
private readonly IConnectionMultiplexer? _redis;
private readonly ILogger<PlayerLogService>? _log;
// Process-local fallback when Redis isn't configured. Each player
// gets a bounded queue we trim on append. ConcurrentQueue itself
// has no built-in cap so we trim explicitly under a per-player lock.
private readonly ConcurrentDictionary<long, ConcurrentQueue<PlayerLogEntry>> _local = new();
private static readonly JsonSerializerOptions JsonOpts = new()
{
PropertyNamingPolicy = null,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
public PlayerLogService(
IConnectionMultiplexer? redis = null,
ILogger<PlayerLogService>? log = null)
{
_redis = redis;
_log = log;
}
/// <summary>Append one entry for a player. Fire-and-forget — never
/// throws so a Redis blip can't 500 a request that just succeeded.</summary>
public void Record(PlayerLogEntry entry)
{
if (entry.PlayerId <= 0) return;
if (_redis is { } mux)
{
try
{
var json = JsonSerializer.Serialize(entry, JsonOpts);
var db = mux.GetDatabase();
var key = Key(entry.PlayerId);
// LPUSH + LTRIM 0..(N-1) keeps the list at most N long
// with the newest entry at index 0. The two ops are
// batched so a concurrent reader never sees an
// intermediate over-long list.
var batch = db.CreateBatch();
_ = batch.ListLeftPushAsync(key, json);
_ = batch.ListTrimAsync(key, 0, MaxEntriesPerPlayer - 1);
// 7-day TTL so abandoned-account log keys age out.
_ = batch.KeyExpireAsync(key, TimeSpan.FromDays(7));
batch.Execute();
}
catch (Exception ex)
{
_log?.LogWarning(ex, "[playerlog] redis append failed for player={PlayerId}", entry.PlayerId);
}
return;
}
// Process-local fallback: append + trim under per-player lock.
var queue = _local.GetOrAdd(entry.PlayerId, _ => new ConcurrentQueue<PlayerLogEntry>());
queue.Enqueue(entry);
while (queue.Count > MaxEntriesPerPlayer && queue.TryDequeue(out _)) { }
}
/// <summary>Most recent <paramref name="take"/> entries for a player,
/// newest-first. Returns an empty list when the player has no
/// recorded activity (or when Redis is down on the read path —
/// admin UI shows "no entries" instead of erroring out).</summary>
public IReadOnlyList<PlayerLogEntry> GetRecent(long playerId, int take)
{
take = Math.Clamp(take, 1, MaxEntriesPerPlayer);
if (_redis is { } mux)
{
try
{
var raw = mux.GetDatabase().ListRange(Key(playerId), 0, take - 1);
var result = new List<PlayerLogEntry>(raw.Length);
foreach (var v in raw)
{
var e = JsonSerializer.Deserialize<PlayerLogEntry>(v.ToString(), JsonOpts);
if (e is not null) result.Add(e);
}
return result;
}
catch (Exception ex)
{
_log?.LogWarning(ex, "[playerlog] redis read failed for player={PlayerId}", playerId);
return Array.Empty<PlayerLogEntry>();
}
}
if (!_local.TryGetValue(playerId, out var queue)) return Array.Empty<PlayerLogEntry>();
// ConcurrentQueue enumerates oldest-first; reverse + take to
// match Redis newest-first ordering.
return queue.ToArray().Reverse().Take(take).ToList();
}
private static string Key(long playerId) => $"playerlog:{playerId}";
}
/// <summary>One captured request. Bodies are pre-truncated by the
/// caller so the JSON we serialise into Redis stays bounded.</summary>
public sealed class PlayerLogEntry
{
[JsonPropertyName("ts")] public DateTime Timestamp { get; set; }
[JsonPropertyName("playerId")] public long PlayerId { get; set; }
[JsonPropertyName("method")] public string Method { get; set; } = string.Empty;
[JsonPropertyName("host")] public string Host { get; set; } = string.Empty;
[JsonPropertyName("path")] public string Path { get; set; } = string.Empty;
[JsonPropertyName("query")] public string Query { get; set; } = string.Empty;
[JsonPropertyName("status")] public int Status { get; set; }
[JsonPropertyName("elapsedMs")] public long ElapsedMs { get; set; }
[JsonPropertyName("reqBody")] public string? ReqBody { get; set; }
[JsonPropertyName("respBody")] public string? RespBody { get; set; }
}
@@ -0,0 +1,285 @@
using System.Collections.Concurrent;
using System.Text.Json;
using DorkNet.Server.Controllers.Match;
using Microsoft.Extensions.Logging;
using StackExchange.Redis;
namespace DorkNet.Server.Services;
/// <summary>
/// Cross-replica tracker for which RoomInstance each authenticated
/// player most recently went to via /goto. The presence heartbeat
/// reads this back so the client's local state matches what the
/// server says.
///
/// Why this exists: every ~5s the client POSTs /player/heartbeat,
/// expecting a PlayerPresence response that mirrors its own cached
/// presence. If the server returns roomInstance:null but the client
/// knows it's in DormRoom, RecNet.Matchmaking.OnPresenceHeartbeatResponse
/// logs "presence heartbeat response indicates local presence is
/// out-of-sync" repeatedly, the client gives up after a few rounds,
/// and shows the "Error: -2 / We lost the connection to Rec Room"
/// UI even though no network connection actually dropped.
///
/// **Multi-replica**: when <see cref="IConnectionMultiplexer"/> is
/// available, presence lives in Redis as <c>presence:{playerId}</c>
/// (JSON-serialised <see cref="RoomInstanceDto"/>). EXPIRE is set to
/// <see cref="PresenceTtl"/> so a player who silently drops
/// (uninstall, kill -9) ages out automatically; their next /goto
/// refreshes the value AND the TTL. When no Redis is configured,
/// falls back to a process-local <see cref="ConcurrentDictionary"/>
/// — identical to pre-PR-2 behaviour and fine for single-instance
/// dev workflows.
///
/// State is intentionally non-persistent — a server restart drops
/// every presence record, and clients re-establish via their next
/// /goto call. Method signatures stay sync because the call sites
/// (heartbeat, /goto, room-state queries) are too numerous to pay
/// the cost of an async cascade. Sync StackExchange.Redis ops are
/// sub-millisecond on the same network at our QPS.
/// </summary>
public class PlayerPresenceService
{
private readonly ConcurrentDictionary<long, RoomInstanceDto> _local = new();
private readonly IConnectionMultiplexer? _redis;
private readonly ILogger<PlayerPresenceService>? _log;
/// <summary>
/// Presence must survive long room loads. The 2020 client can go
/// quiet for ~45 seconds while downloading/deserializing large
/// uploaded rooms; a 45s Redis TTL expires right before the next
/// heartbeat, making the server synthesize dorm fallback presence
/// and effectively bouncing the player back to dorm. Ten minutes
/// still ages out abandoned sessions, but does not punish slow
/// room loads or temporary network stalls.
/// </summary>
public static TimeSpan PresenceTtl { get; } = TimeSpan.FromMinutes(10);
private static readonly JsonSerializerOptions JsonOpts = new()
{
// Match the wire format used by the matchmaking response so
// the round-trip is lossless. Properties are already tagged
// with [JsonPropertyName] for camelCase wire keys.
PropertyNamingPolicy = null,
};
public PlayerPresenceService(
IConnectionMultiplexer? redis = null,
ILogger<PlayerPresenceService>? log = null)
{
_redis = redis;
_log = log;
}
public void SetRoom(long playerId, RoomInstanceDto room)
{
if (_redis is { } mux)
{
try
{
var json = JsonSerializer.Serialize(room, JsonOpts);
mux.GetDatabase().StringSet(Key(playerId), json, PresenceTtl);
_log?.LogDebug("[presence] set redis player={PlayerId} room={Room} bytes={Bytes}",
playerId, room?.Name ?? "<null>", json.Length);
}
catch (Exception ex)
{
// Don't let a Redis blip take down the goto handler — log
// and fall through. Heartbeat will see no presence and the
// watch will resync via its next /goto.
_log?.LogWarning(ex, "[presence] redis SET failed for player={PlayerId}", playerId);
}
}
else
{
_local[playerId] = room;
_log?.LogDebug("[presence] set local player={PlayerId} room={Room}",
playerId, room?.Name ?? "<null>");
}
}
public RoomInstanceDto? GetRoom(long playerId)
{
if (_redis is { } mux)
{
try
{
var db = mux.GetDatabase();
var key = Key(playerId);
var v = db.StringGet(key);
if (!v.HasValue)
{
_log?.LogDebug("[presence] miss redis player={PlayerId}", playerId);
return null;
}
db.KeyExpire(key, PresenceTtl);
return JsonSerializer.Deserialize<RoomInstanceDto>(v.ToString(), JsonOpts);
}
catch (Exception ex)
{
_log?.LogWarning(ex, "[presence] redis GET failed for player={PlayerId}", playerId);
return null;
}
}
return _local.TryGetValue(playerId, out var room) ? room : null;
}
public void Clear(long playerId)
{
if (_redis is { } mux) mux.GetDatabase().KeyDelete(Key(playerId));
else _local.TryRemove(playerId, out _);
}
/// <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
/// (<c>GET match.*/room/{id}/instances</c>) so players can see
/// which instance their friends are in.
///
/// Redis caveat: without a secondary index per room we'd need a
/// SCAN over every presence key — too expensive to do on each
/// request. For now the Redis path returns empty and the local
/// in-process dictionary is the source of truth. Single-instance
/// deployments work; multi-replica Redis deployments need a
/// per-room set index to be added before the browser is reliable
/// there.</summary>
public IEnumerable<(RoomInstanceDto Room, List<long> PlayerIds)> EnumerateActiveInstances(long roomId)
{
if (_redis is not null) return Array.Empty<(RoomInstanceDto, List<long>)>();
var groups = new Dictionary<string, (RoomInstanceDto Room, List<long> PlayerIds)>(StringComparer.Ordinal);
foreach (var kv in _local)
{
var room = kv.Value;
if (room is null || room.RoomId != roomId) continue;
// Skip private instances — those are surfaced separately
// in the controller via PrivateInstanceService so we never
// leak a photonRoomId for an invite-only match into the
// public browser.
if (room.IsPrivate) continue;
var key = string.IsNullOrEmpty(room.PhotonRoomId)
? $"<{room.RoomInstanceId}>"
: room.PhotonRoomId;
if (!groups.TryGetValue(key, out var entry))
{
entry = (room, new List<long>());
groups[key] = entry;
}
entry.PlayerIds.Add(kv.Key);
}
return groups.Values;
}
private static string Key(long playerId) => $"presence:{playerId}";
// ── LoginLock single-session enforcement ─────────────────────────
// The 2020 watch generates a fresh GUID per session and sends it
// in the `LoginLock` form field on every match.* request. Real
// Rec Room rejects heartbeats whose lock doesn't match the
// most-recent /player/login — that's how a second login on the
// same account kicks the first session. We mirror that by storing
// the most-recent lock in Redis (or the in-process fallback)
// keyed by player id, with the same 45-s TTL as presence.
//
// SetLock OVERWRITES regardless of prior value — newest login
// wins. ValidateLock returns false when the caller's lock is stale
// or absent, which the heartbeat handler maps to a
// session-terminated response so the watch logs out.
private readonly ConcurrentDictionary<long, string> _localLocks = new();
public bool SetLock(long playerId, string loginLock)
{
if (string.IsNullOrWhiteSpace(loginLock)) return false;
var replacedActiveSession = false;
if (_redis is { } mux)
{
try
{
var db = mux.GetDatabase();
var key = LockKey(playerId);
var previous = db.StringGet(key);
replacedActiveSession = previous.HasValue &&
!string.Equals(previous.ToString(), loginLock, StringComparison.Ordinal);
db.StringSet(key, loginLock, PresenceTtl);
}
catch (Exception ex) { _log?.LogWarning(ex, "[lock] redis SET failed for player={PlayerId}", playerId); }
}
else
{
replacedActiveSession = _localLocks.TryGetValue(playerId, out var previous) &&
!string.Equals(previous, loginLock, StringComparison.Ordinal);
_localLocks[playerId] = loginLock;
}
if (replacedActiveSession)
_log?.LogWarning("[lock] concurrent login detected for player={PlayerId}; newest LoginLock wins", playerId);
return replacedActiveSession;
}
public bool ValidateLock(long playerId, string? loginLock)
{
if (string.IsNullOrWhiteSpace(loginLock)) return false;
if (_redis is { } mux)
{
try
{
var v = mux.GetDatabase().StringGet(LockKey(playerId));
if (!v.HasValue) return false;
// Refresh TTL on each successful validate so the
// active session keeps its lock alive across heartbeats.
if (string.Equals(v.ToString(), loginLock, StringComparison.Ordinal))
{
mux.GetDatabase().KeyExpire(LockKey(playerId), PresenceTtl);
return true;
}
return false;
}
catch (Exception ex)
{
_log?.LogWarning(ex, "[lock] redis GET failed for player={PlayerId}", playerId);
return true; // fail open — better double-session than mass kick on Redis blip
}
}
return _localLocks.TryGetValue(playerId, out var stored)
&& string.Equals(stored, loginLock, StringComparison.Ordinal);
}
private static string LockKey(long playerId) => $"loginlock:{playerId}";
// ── Photon region pings ──────────────────────────────────────────
// The 2020 watch reports its measured Photon-region latencies in a
// single POST after the matchmaking init handshake (see
// MatchPlayerController.PhotonRegionPings). We hold the latest
// reading per player in process memory only — region preferences
// change infrequently, lose-on-restart is fine, and pushing this
// through Redis would just bloat the key space for no gain.
private readonly ConcurrentDictionary<long, IReadOnlyDictionary<string, int>> _regionPings = new();
public void SetPhotonRegionPings(long playerId, IReadOnlyDictionary<string, int> pings)
{
if (pings.Count == 0) return;
_regionPings[playerId] = pings;
}
/// <summary>Returns the region code with the lowest reported ping,
/// or <c>null</c> when no pings were ever received for the
/// player (matchmaking falls back to its default region in that
/// case).</summary>
public string? GetPreferredPhotonRegion(long playerId)
{
if (!_regionPings.TryGetValue(playerId, out var pings) || pings.Count == 0)
return null;
string? best = null;
var bestMs = int.MaxValue;
foreach (var kv in pings)
{
if (kv.Value < bestMs)
{
bestMs = kv.Value;
best = kv.Key;
}
}
return best;
}
}
+753
View File
@@ -0,0 +1,753 @@
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<PlayerService> logger)
{
/// <summary>
/// Default outfit GUIDs piped into <see cref="AvatarEntity.OutfitSelections"/>
/// 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.
/// </summary>
/// <summary>Wire format for <see cref="AvatarEntity.OutfitSelections"/>
/// — a SEMICOLON-separated list of <c>AvatarItemSelection</c>
/// entries, each entry is COMMA-separated as
/// <c>{prefabGuid},{maskGuid},{swatchGuid},{decalGuid},{bodyPart}</c>
/// (exactly 5 parts; the inner <c>AvatarItemVisualData</c> takes
/// 4 of them, then <c>AvatarItemSelection.ToRecNetString</c>
/// appends the int <c>BodyPart</c>). Verified at:
/// <list type="bullet">
/// <item>Cpp2IL_ISIL/.../PlayerOutfit.txt:4601 — split delimiter
/// literal <c>0x3B</c> = <c>;</c>.</item>
/// <item>Cpp2IL_ISIL/.../AvatarItemSelection.txt:887 — exactly
/// 5 comma-segments expected, anything else throws
/// <c>AvatarParseException("Attempting to parse bad Avatar Item
/// string …")</c>.</item>
/// <item>Cpp2IL_CS/.../Player.cs:262 — BodyPart enum
/// {None=-1, Head=0, Torso=1, LeftHand=2, RightHand=3, Mouth=4}.</item>
/// <item>Cpp2IL_ISIL/.../OutfitTypeExt.txt:23-39 —
/// <c>GetMatchingBodyPart</c>: 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.</item>
/// </list>
///
/// 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
/// <c>data/avatar_item_lookup.json</c>'s safe set.</summary>
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
/// <summary>JSON list of GUIDs the player owns at signup. Stored
/// on <see cref="AvatarEntity.InventoryJson"/> and surfaced via
/// <c>GET api/avatar/v4/items</c> 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 <c>data/avatar_item_lookup.json</c>'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 <c>tools/AvatarCatalogDumpPatch</c> against an
/// updated client and pull GUIDs from the dump.</summary>
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"
]
""";
/// <summary>Color vault GUIDs for the starter avatar. The 2020
/// client's <c>OutfitManager.FindColorFromVault</c> expects IDs from
/// its baked skin/hair color vaults here, not packed RGBA strings.</summary>
public const string StarterHairColor = "81_c6R0my0qK9hYM_0a7LQ";
public const string StarterSkinColor = "cl2EzJ4v6kW3g4Oo9ZQ3hA";
/// <summary>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 <c>balance &gt;= price</c>; 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 <see cref="EnsurePlayerWalletAsync"/> at signup
/// time and from <see cref="BackfillStarterWalletsAsync"/> on
/// startup (idempotent, only inserts a row when one doesn't
/// already exist for the player+currency).</summary>
public const long StarterRecCenterTokens = 5000;
/// <summary>The system-account player id. Reserved for the
/// <c>Coach</c> account that owns every Rec Room Original room
/// (RecCenter, Paintball, Dodgeball, etc.) — those rows hard-code
/// <c>CreatorPlayerId = 1</c> in the seed data. Real players are
/// assigned ids in [100_000, 10_000_000), so id=1 stays uncontested
/// forever.</summary>
public const long SystemAccountId = 1;
public const string SystemAccountUsername = "Coach";
/// <summary>Ensure the Coach system account exists at id=<see cref="SystemAccountId"/>.
/// Idempotent — repeated calls no-op once the row is in place. Called
/// from <c>Program.cs</c> at startup BEFORE <see cref="RoomService.SeedAsync"/>
/// because the RR-Original room seeder sets <c>CreatorPlayerId = 1</c>
/// 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: <c>DeviceId = "system"</c> never matches any
/// real client deviceId hash (those are 40-char hex), <c>PasswordHash</c>
/// is null so the password grant rejects, <c>BannedUntil</c> 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.</summary>
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.
if (existing.Username != SystemAccountUsername || existing.DisplayName != SystemAccountUsername)
{
await db.Players
.Where(p => p.Id == SystemAccountId)
.ExecuteUpdateAsync(s => s
.SetProperty(p => p.Username, SystemAccountUsername)
.SetProperty(p => p.DisplayName, SystemAccountUsername));
}
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.
BannedUntil = new DateTime(9999, 12, 31, 0, 0, 0, DateTimeKind.Utc),
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);
}
/// <summary>Insert a starter <see cref="CurrencyBalanceEntity"/>
/// row for the player if they don't already have one for
/// <c>CurrencyType=2</c> (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 <see cref="BackfillStarterWalletsAsync"/> at startup so
/// pre-existing accounts (created before this seed shipped) get
/// the same one-time grant.</summary>
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();
}
/// <summary>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 <see cref="EnsureAvatarSeedAsync"/> — 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.</summary>
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);
}
/// <summary>
/// Backfill <see cref="AvatarEntity.InventoryJson"/>,
/// <see cref="AvatarEntity.OutfitSelections"/>, 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 <c>Program.cs</c> startup AFTER
/// <see cref="EnsureSystemAccountAsync"/>.
/// </summary>
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 <c>{guid},{m},{s},{d},{bp}</c> 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 <c>Contains(";")</c> (string overload,
// which EF Core translates to a Postgres LIKE) NOT
// <c>Contains(';')</c> (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);
}
/// <summary>
/// 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.
/// </summary>
/// <summary>Lookup-only counterpart to
/// <see cref="GetOrCreateByDeviceAsync"/>. 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.</summary>
public async Task<PlayerEntity?> 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<PlayerEntity> 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;
}
/// <summary>
/// Always creates a new account row, regardless of whether one
/// already exists for the given <paramref name="deviceId"/>. Used
/// by <c>account/create</c> 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 <see cref="OrphanAccountTracker"/> + the
/// cached_login handler's "if you logged in to a different id, the
/// just-created one was an orphan" heuristic.
/// </summary>
public async Task<PlayerEntity> 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;
}
/// <summary>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.</summary>
public async Task<bool> 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<PlayerEntity?> GetByIdAsync(long id) =>
await db.Players.Include(p => p.Avatar).FirstOrDefaultAsync(p => p.Id == id);
/// <summary>
/// Stamp the calling platform onto an existing account so the
/// next boot's <c>/cachedlogin/forplatformid</c> 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.
/// </summary>
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();
}
/// <summary>
/// Returns every account that has ever logged in with the given platform
/// + platformId combination, most-recent-first. Backs the
/// <c>auth.rec.net/cachedlogin/forplatformid/{platform}/{platformId}</c>
/// "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.
/// </summary>
public async Task<List<PlayerEntity>> 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<PlayerEntity?> GetByUsernameAsync(string username) =>
await db.Players.FirstOrDefaultAsync(p => p.Username == username);
public async Task<List<PlayerEntity>> 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);
return await db.Players
.Where(p => p.Username.Contains(query) || p.DisplayName.Contains(query))
.Take(clamped)
.ToListAsync();
}
/// <summary>
/// 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.
/// </summary>
public async Task<bool> 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<bool> 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<bool> 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)}";
/// <summary>
/// Append a numeric suffix until the username is unique. Skips the
/// suffix entirely if the requested name is already free.
/// </summary>
private async Task<string> 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)}";
}
}
+356
View File
@@ -0,0 +1,356 @@
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Services;
/// <summary>
/// Backs the watch's /roomserver/roomsandplaylists/* + /api/curatedroomplaylists
/// surfaces. The 2020.12 client deserializes responses as
/// <c>List&lt;MKAMHOIHOJK&gt;</c>, an abstract union of Room
/// (<c>KLCOGEIGEBJ</c>, discriminator = presence of lowercase
/// <c>roomId</c>) and Playlist (<c>KMKPEOGJDFK</c>). This service owns the
/// playlist half of that union — Rooms come from <see cref="RoomService"/>.
/// </summary>
public class PlaylistService(DorkNetDbContext db)
{
/// <summary>
/// Hot playlists — drives <c>/roomserver/roomsandplaylists/hot</c>.
/// Score is <c>CheerCount + VisitorCount * 0.25</c> (cheers weighted
/// heavier than passive visits) so a freshly-popular list with a
/// burst of cheers can outrank an evergreen list with high traffic.
/// Tag filter mirrors <see cref="RoomService.HotAsync"/> — substring
/// match against <see cref="PlaylistEntity.TagsCsv"/> after stripping
/// a leading '#'.
/// </summary>
public async Task<List<PlaylistEntity>> HotAsync(string? tag, int take = 24)
{
IQueryable<PlaylistEntity> q = db.Playlists;
if (!string.IsNullOrWhiteSpace(tag))
{
var bareTag = tag.TrimStart('#').Trim();
if (bareTag.Length > 0)
{
var needle = $"%{bareTag}%";
q = q.Where(p => EF.Functions.Like(p.TagsCsv, needle));
}
}
// Score is computed client-side after pulling rows because
// EF's SQLite provider can't translate ordering expressions
// with a constant double multiplier predictably; the table
// is tiny so the in-memory sort is fine.
var rows = await q.ToListAsync();
return rows
.OrderByDescending(p => p.CheerCount + p.VisitorCount * 0.25)
.Take(take)
.ToList();
}
/// <summary>
/// Curated playlists for <c>/api/curatedroomplaylists</c>. Returns
/// only seed-curated rows in <see cref="PlaylistEntity.OrderIndex"/>
/// order so the editorial sort holds.
/// </summary>
public Task<List<PlaylistEntity>> CuratedAsync() =>
db.Playlists
.Where(p => p.IsCurated)
.OrderBy(p => p.OrderIndex)
.ToListAsync();
/// <summary>
/// Free-text search across playlists — drives the playlist half of
/// <c>/roomserver/search_roomsandplaylists/{query}</c>. Matches Name
/// or TagsCsv substrings (same predicate shape as
/// <see cref="RoomService.SearchAsync"/>).
/// </summary>
public async Task<List<PlaylistEntity>> SearchAsync(string query, int take = 24)
{
if (string.IsNullOrWhiteSpace(query)) return new();
var needle = $"%{query}%";
return await db.Playlists
.Where(p => EF.Functions.Like(p.Name, needle)
|| EF.Functions.Like(p.TagsCsv, needle))
.OrderByDescending(p => p.CheerCount)
.Take(take)
.ToListAsync();
}
/// <summary>Member-room ids for a playlist, in display order.
/// Callers fan-out per-id with <see cref="RoomService.GetByIdAsync"/>
/// when they need the full Room rows (the watch's playlist detail
/// screen does this).</summary>
public Task<List<long>> RoomIdsAsync(long playlistId) =>
db.PlaylistRooms
.Where(pr => pr.PlaylistId == playlistId)
.OrderBy(pr => pr.OrderIndex)
.Select(pr => pr.RoomId)
.ToListAsync();
/// <summary>Single-playlist lookup. Returns null when missing so
/// the controller can 404 cleanly.</summary>
public Task<PlaylistEntity?> GetByIdAsync(long playlistId) =>
db.Playlists.FirstOrDefaultAsync(p => p.Id == playlistId);
/// <summary>Bulk-by-id lookup for <c>playlists/bulk</c>. Preserves
/// caller order; missing rows are silently dropped.</summary>
public async Task<List<PlaylistEntity>> BulkAsync(IEnumerable<long> playlistIds)
{
var ids = playlistIds.Distinct().ToList();
if (ids.Count == 0) return new();
var rows = await db.Playlists.Where(p => ids.Contains(p.Id)).ToListAsync();
var byId = rows.ToDictionary(p => p.Id);
return ids.Select(i => byId.TryGetValue(i, out var p) ? p : null)
.Where(p => p is not null).Select(p => p!).ToList();
}
/// <summary>Playlists created by a player. Drives
/// <c>playlists/createdby/me</c> and any future profile-card
/// equivalent.</summary>
public Task<List<PlaylistEntity>> CreatedByAsync(long playerId) =>
db.Playlists
.Where(p => p.CreatorPlayerId == playerId)
.OrderByDescending(p => p.CreatedAt)
.ToListAsync();
/// <summary>Playlists the player has cheered. Drives
/// <c>playlists/cheeredby/me</c>.</summary>
public async Task<List<PlaylistEntity>> CheeredByAsync(long playerId)
{
var ids = await db.PlaylistInteractions
.Where(i => i.PlayerId == playerId && i.Cheered)
.Select(i => i.PlaylistId).ToListAsync();
return await db.Playlists.Where(p => ids.Contains(p.Id)).ToListAsync();
}
/// <summary>Playlists the player has favorited. Drives
/// <c>playlists/favoritedby/me</c>.</summary>
public async Task<List<PlaylistEntity>> FavoritedByAsync(long playerId)
{
var ids = await db.PlaylistInteractions
.Where(i => i.PlayerId == playerId && i.Favorited)
.Select(i => i.PlaylistId).ToListAsync();
return await db.Playlists.Where(p => ids.Contains(p.Id)).ToListAsync();
}
/// <summary>Read the caller's interaction row for a playlist —
/// drives <c>playlists/{id}/interactionby/me</c>. Returns (false,
/// false) sentinel when no row exists yet (no toggle has fired).</summary>
public async Task<(bool Cheered, bool Favorited)> InteractionForAsync(long playlistId, long playerId)
{
var row = await db.PlaylistInteractions
.FirstOrDefaultAsync(i => i.PlaylistId == playlistId && i.PlayerId == playerId);
return row is null ? (false, false) : (row.Cheered, row.Favorited);
}
/// <summary>Toggle a single interaction flag for the caller. The
/// playlist's denormalized counter is bumped/decremented in the
/// same SaveChanges so the hot ranking + tile badges stay in sync.
/// Returns the new row state.</summary>
public async Task<PlaylistInteractionEntity> SetInteractionAsync(
long playlistId, long playerId, bool? cheered = null, bool? favorited = null)
{
var playlist = await db.Playlists.FirstOrDefaultAsync(p => p.Id == playlistId)
?? throw new InvalidOperationException($"playlist {playlistId} not found");
var row = await db.PlaylistInteractions
.FirstOrDefaultAsync(i => i.PlaylistId == playlistId && i.PlayerId == playerId);
if (row is null)
{
row = new PlaylistInteractionEntity
{
PlaylistId = playlistId,
PlayerId = playerId,
};
db.PlaylistInteractions.Add(row);
}
if (cheered is bool c && row.Cheered != c)
{
row.Cheered = c;
playlist.CheerCount = Math.Max(0, playlist.CheerCount + (c ? 1 : -1));
}
if (favorited is bool f && row.Favorited != f)
{
row.Favorited = f;
playlist.FavoriteCount = Math.Max(0, playlist.FavoriteCount + (f ? 1 : -1));
}
row.UpdatedAt = DateTime.UtcNow;
playlist.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return row;
}
/// <summary>Create a new player-owned playlist. Curated lists are
/// admin-only; user-created lists set IsCurated=false. Returns the
/// new row; the controller projects it into the BMFAGMFKODA wire
/// shape on the way out.</summary>
public async Task<PlaylistEntity> CreateAsync(
long creatorId, string name, string? description, string? imageName, string? tagsCsv)
{
var p = new PlaylistEntity
{
Name = (name ?? string.Empty).Trim(),
Description = description ?? string.Empty,
ImageName = imageName ?? string.Empty,
CreatorPlayerId = creatorId,
IsCurated = false,
TagsCsv = tagsCsv ?? string.Empty,
};
db.Playlists.Add(p);
await db.SaveChangesAsync();
return p;
}
/// <summary>Apply an arbitrary mutation to a playlist after
/// checking ownership. Returns the row on success, null on missing
/// playlist; throws <see cref="UnauthorizedAccessException"/> when
/// the caller isn't the creator. Used by every modify endpoint.</summary>
public async Task<PlaylistEntity?> ModifyAsync(long playlistId, long callerId, Action<PlaylistEntity> mutator)
{
var p = await db.Playlists.FirstOrDefaultAsync(x => x.Id == playlistId);
if (p is null) return null;
if (p.CreatorPlayerId != callerId) throw new UnauthorizedAccessException();
mutator(p);
p.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return p;
}
/// <summary>Delete a player-owned playlist + its member-room rows
/// + every per-player interaction row. Curated playlists are
/// admin-only — the controller still calls this method but only
/// after gating on the caller's ownership.</summary>
public async Task<bool> DeleteAsync(long playlistId, long callerId)
{
var p = await db.Playlists.FirstOrDefaultAsync(x => x.Id == playlistId);
if (p is null) return false;
if (p.CreatorPlayerId != callerId) throw new UnauthorizedAccessException();
var memberRows = await db.PlaylistRooms.Where(pr => pr.PlaylistId == playlistId).ToListAsync();
db.PlaylistRooms.RemoveRange(memberRows);
var interactionRows = await db.PlaylistInteractions
.Where(i => i.PlaylistId == playlistId).ToListAsync();
db.PlaylistInteractions.RemoveRange(interactionRows);
db.Playlists.Remove(p);
await db.SaveChangesAsync();
return true;
}
/// <summary>Add a room to a playlist at the end. Idempotent — a
/// repeat add is a no-op. Throws <see cref="UnauthorizedAccessException"/>
/// when the caller isn't the owner.</summary>
public async Task<bool> AddRoomAsync(long playlistId, long roomId, long callerId)
{
var p = await db.Playlists.FirstOrDefaultAsync(x => x.Id == playlistId);
if (p is null) return false;
if (p.CreatorPlayerId != callerId) throw new UnauthorizedAccessException();
var existing = await db.PlaylistRooms
.AnyAsync(pr => pr.PlaylistId == playlistId && pr.RoomId == roomId);
if (existing) return true;
var nextOrder = await db.PlaylistRooms
.Where(pr => pr.PlaylistId == playlistId)
.Select(pr => (int?)pr.OrderIndex).MaxAsync() ?? -1;
db.PlaylistRooms.Add(new PlaylistRoomEntity
{
PlaylistId = playlistId,
RoomId = roomId,
OrderIndex = nextOrder + 1,
});
p.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return true;
}
/// <summary>Remove a room from a playlist. Idempotent — missing
/// row is treated as success so the watch's repeat-delete path
/// doesn't render a phantom error.</summary>
public async Task<bool> RemoveRoomAsync(long playlistId, long roomId, long callerId)
{
var p = await db.Playlists.FirstOrDefaultAsync(x => x.Id == playlistId);
if (p is null) return false;
if (p.CreatorPlayerId != callerId) throw new UnauthorizedAccessException();
var row = await db.PlaylistRooms
.FirstOrDefaultAsync(pr => pr.PlaylistId == playlistId && pr.RoomId == roomId);
if (row is null) return true;
db.PlaylistRooms.Remove(row);
p.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return true;
}
/// <summary>
/// Idempotent seed of the default curated playlists. Inserts three
/// editorial lists (Featured, Newcomers, Sports) on first launch and
/// populates each with up to five matching seeded rooms (looked up by
/// the tag substrings each list curates). Re-running on a populated
/// table is a no-op — once any playlist exists, this method bails.
/// </summary>
public async Task SeedCuratedAsync()
{
if (await db.Playlists.AnyAsync()) return;
var curated = new (string Name, string Description, string Image, string Tags, string[] RoomLookupTags)[]
{
("Featured", "Hand-picked rooms the team is loving this week — start here.",
"featured.png", "featured",
new[] { "featured" }),
("Newcomers", "Easy-to-pick-up rooms perfect for your first Rec Room sessions.",
"newcomers.png", "newcomer,easy",
new[] { "recroomoriginal" }),
("Sports", "Get sweaty — paintball, dodgeball, soccer, and the rest of the field.",
"sports.png", "paintball,soccer,dodgeball",
new[] { "sport" }),
};
long playlistId = 1;
int orderIndex = 0;
foreach (var entry in curated)
{
var p = new PlaylistEntity
{
Id = playlistId++,
Name = entry.Name,
Description = entry.Description,
ImageName = entry.Image,
CreatorPlayerId = 1,
IsCurated = true,
OrderIndex = orderIndex++,
TagsCsv = entry.Tags,
CheerCount = 50 + orderIndex * 10,
FavoriteCount = 25 + orderIndex * 5,
VisitorCount = 1000 + orderIndex * 250,
VisitCount = 4000 + orderIndex * 750,
};
db.Playlists.Add(p);
await db.SaveChangesAsync(); // need the Id for the join rows
// Find 3-5 matching seeded rooms for this list. Search by
// tag substring against RoomEntity.TagsCsv so the
// playlists naturally pick up new seeded rooms that
// carry the right tags without per-name hardcoding.
var memberRooms = new List<long>();
foreach (var lookupTag in entry.RoomLookupTags)
{
var needle = $"%{lookupTag}%";
var ids = await db.Rooms
.Where(r => !r.IsDormRoom && !r.HiddenFromBrowse && EF.Functions.Like(r.TagsCsv, needle))
.OrderByDescending(r => r.HotScore)
.Select(r => r.Id)
.Take(5)
.ToListAsync();
memberRooms.AddRange(ids);
}
// De-dupe in case two lookup tags catch the same room.
memberRooms = memberRooms.Distinct().Take(5).ToList();
int memberOrder = 0;
foreach (var roomId in memberRooms)
{
db.PlaylistRooms.Add(new PlaylistRoomEntity
{
PlaylistId = p.Id,
RoomId = roomId,
OrderIndex = memberOrder++,
});
}
await db.SaveChangesAsync();
}
}
}
@@ -0,0 +1,271 @@
using DorkNet.Server.Controllers.Match;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using Microsoft.EntityFrameworkCore;
namespace DorkNet.Server.Services;
/// <summary>
/// Cross-replica registry of invite-only matches created via
/// <c>POST /goto/room/{name}?CreatePrivateInstance=True</c>.
///
/// Why this exists: the watch sets <c>CreatePrivateInstance=True</c> in
/// the matchmaking form when the player picks "Private Instance". Our
/// /goto handler responds with a unique <c>photonRoomId</c> so randos
/// can't matchmake into it. Invitees later POST
/// <c>/goto/instance/{roomInstanceId}</c> carrying the inviter's
/// instance id; that handler looks up the registered photon room
/// here so they end up in the same Photon match instead of being
/// routed to a fresh public lobby.
///
/// **Multi-replica**: state lives in two Postgres tables —
/// <see cref="PrivateInstanceEntity"/> for the instance metadata and
/// <see cref="PrivateInstanceInviteeEntity"/> for the per-invite
/// access list. Pre-PR-3 used <c>ConcurrentDictionary</c> singletons
/// which split across replicas: an invite issued on replica A wasn't
/// visible to a goto/instance hitting replica B.
///
/// Service is now <b>scoped</b> rather than singleton because it
/// holds a DbContext reference. The previous singleton lifetime would
/// have leaked the DbContext for the lifetime of the app.
/// </summary>
public class PrivateInstanceService(DorkNetDbContext db)
{
/// <summary>Generate a fresh <c>roomInstanceId</c> + <c>photonRoomId</c>
/// pair for a private match and persist the row. Caller is responsible
/// for plumbing the result onto the outgoing <see cref="RoomInstanceDto"/>.</summary>
public async Task<PrivateInstance> RegisterAsync(
long roomId, long subRoomId, long ownerPlayerId,
string baseName, string location, string dataBlob, string photonRegion, int maxCapacity)
{
// Random component so the same player can have multiple
// concurrent private instances of the same room (e.g. one in
// the lobby, one in a sub-room) — without it the photon room
// ids would collide and the second instance would silently
// matchmake into the first.
var nonce = Random.Shared.Next(0x10000, 0xFFFFFF);
// Pack roomId/subId/ownerId into the high bits of the instance
// id so it's unique-by-construction across rooms+players. The
// low 24 bits carry the nonce so multiple concurrent instances
// by the same player still get distinct ids. Stays inside int63
// for safe LitJson marshalling on the watch.
var instanceId = (long)(((ulong)(uint)nonce & 0xFFFFFFul)
^ ((ulong)roomId << 24)
^ ((ulong)subRoomId << 40)
^ ((ulong)(uint)ownerPlayerId << 48));
if (instanceId < 0) instanceId = -instanceId;
var photonRoomId = $"^{baseName}_{roomId}_sub{subRoomId}_priv{ownerPlayerId}_{nonce:x6}";
var entity = new PrivateInstanceEntity
{
Id = instanceId,
RoomId = roomId,
SubRoomId = subRoomId,
OwnerPlayerId = ownerPlayerId,
PhotonRoomId = photonRoomId,
Location = location,
DataBlob = dataBlob,
PhotonRegion = photonRegion,
MaxCapacity = maxCapacity,
Name = baseName,
CreatedAt = DateTime.UtcNow,
};
db.PrivateInstances.Add(entity);
await db.SaveChangesAsync();
return ToRecord(entity);
}
public async Task<PrivateInstance?> GetAsync(long instanceId)
{
var e = await db.PrivateInstances.AsNoTracking()
.FirstOrDefaultAsync(p => p.Id == instanceId);
return e is null ? null : ToRecord(e);
}
/// <summary>
/// Idempotent registration of a player's dorm as a private instance.
/// Dorms are always owner-only by design — anyone reaching the
/// dorm's PhotonRoom name uninvited would otherwise be able to
/// drop in. We mark every dorm <c>IsPrivate=true</c> and back it
/// with a deterministic-id PrivateInstance row so
/// <see cref="CanJoinAsync"/> works the same way it does for
/// player-created private matches: owner always allowed, everyone
/// else needs an explicit invite.
///
/// The id is derived from <c>(ownerPlayerId, roomId, subRoomId)</c>
/// with a fixed high-bit marker so it never collides with the
/// random-nonce ids produced by <see cref="RegisterAsync"/>. Reusing
/// the same id across repeat <c>/goto/room/DormRoom</c> calls keeps
/// the registry from accumulating one row per dorm join.
/// </summary>
public async Task<PrivateInstance> EnsureForDormAsync(
long ownerPlayerId, long roomId, long subRoomId,
string baseName, string location, string dataBlob,
string photonRegion, int maxCapacity, string photonRoomId)
{
// Deterministic-and-distinct from RegisterAsync's nonce layout.
// Marker 0xDD0000 in the low 24 bits separates dorms from
// player-created private matches so the id spaces never alias.
var instanceId = (long)(
0xDD0000ul
^ ((ulong)(uint)roomId << 24)
^ ((ulong)(uint)subRoomId << 40)
^ ((ulong)(uint)ownerPlayerId << 48));
if (instanceId < 0) instanceId = -instanceId;
var existing = await db.PrivateInstances
.FirstOrDefaultAsync(p => p.Id == instanceId);
if (existing is not null)
{
// Keep PhotonRoomId / DataBlob / PhotonRegion current. The
// OWNER's most recent /goto/room/DormRoom is the source of
// truth for which Photon region their dorm lives in — if
// we serve invitees a stale region (because Photon:CloudRegion
// config changed since the row was first written), the
// invitee's watch connects to a different Photon master and
// joining "^dormroom_p{owner}" there creates a SEPARATE room
// from where the owner is. Both watches report Photon
// joinresult=Success but never see each other in-game —
// exact symptom 1362428/1811750 hit: same server-side
// instance id, two parallel Photon rooms.
var changed = false;
if (existing.PhotonRoomId != photonRoomId) { existing.PhotonRoomId = photonRoomId; changed = true; }
if (existing.DataBlob != dataBlob) { existing.DataBlob = dataBlob; changed = true; }
if (existing.Location != location) { existing.Location = location; changed = true; }
if (existing.PhotonRegion != photonRegion) { existing.PhotonRegion = photonRegion; changed = true; }
if (changed) await db.SaveChangesAsync();
return ToRecord(existing);
}
var entity = new PrivateInstanceEntity
{
Id = instanceId,
RoomId = roomId,
SubRoomId = subRoomId,
OwnerPlayerId = ownerPlayerId,
PhotonRoomId = photonRoomId,
Location = location,
DataBlob = dataBlob,
PhotonRegion = photonRegion,
MaxCapacity = maxCapacity,
Name = baseName,
CreatedAt = DateTime.UtcNow,
};
db.PrivateInstances.Add(entity);
try
{
await db.SaveChangesAsync();
}
catch (DbUpdateException)
{
// Concurrent insert from another replica raced us to the
// same deterministic id — re-fetch the winning row.
db.Entry(entity).State = EntityState.Detached;
var raced = await db.PrivateInstances.AsNoTracking()
.FirstAsync(p => p.Id == instanceId);
return ToRecord(raced);
}
return ToRecord(entity);
}
public async Task RemoveAsync(long instanceId)
{
// Cascade-delete invitees first so we don't leave orphan rows
// referencing a non-existent instance.
await db.PrivateInstanceInvitees
.Where(i => i.PrivateInstanceId == instanceId)
.ExecuteDeleteAsync();
await db.PrivateInstances
.Where(p => p.Id == instanceId)
.ExecuteDeleteAsync();
}
/// <summary>Add a player to the invite list for a private instance.
/// Idempotent on (instance, player) — repeat invites refresh
/// <c>LatestInviteMessageId</c> so the most-recent invite's
/// message id is always recoverable for /goto/invite resolution.
/// Returns false if the instance isn't registered (probably
/// expired / wrong id).</summary>
public async Task<bool> InviteAsync(long instanceId, long inviteePlayerId, long? messageId = null)
{
if (!await db.PrivateInstances.AnyAsync(p => p.Id == instanceId)) return false;
var existing = await db.PrivateInstanceInvitees
.FirstOrDefaultAsync(i => i.PrivateInstanceId == instanceId && i.PlayerId == inviteePlayerId);
if (existing is not null)
{
// Refresh the latest-invite pointer if this call carries
// a new messageId — the watch may send several invites in
// a row, and /goto/invite needs the freshest one.
if (messageId is long m && existing.LatestInviteMessageId != m)
{
existing.LatestInviteMessageId = m;
await db.SaveChangesAsync();
}
return true;
}
db.PrivateInstanceInvitees.Add(new PrivateInstanceInviteeEntity
{
PrivateInstanceId = instanceId,
PlayerId = inviteePlayerId,
InvitedAt = DateTime.UtcNow,
LatestInviteMessageId = messageId,
});
await db.SaveChangesAsync();
return true;
}
/// <summary>Drop an invitee from a private instance's allow-list.
/// Used when the invitee declines via
/// <c>DELETE match.*/invite/{messageId}</c> — without this, an
/// invitee who clicks "decline" then changes their mind could
/// still join the instance without a fresh invite. No-op if the
/// invite never existed.</summary>
public async Task RevokeInviteAsync(long instanceId, long inviteePlayerId)
{
await db.PrivateInstanceInvitees
.Where(i => i.PrivateInstanceId == instanceId && i.PlayerId == inviteePlayerId)
.ExecuteDeleteAsync();
}
/// <summary>True when <paramref name="playerId"/> is the owner of
/// the instance OR has been explicitly invited. The owner is
/// implicitly allowed even without an Invite() call so they can
/// rejoin their own private match after disconnects.</summary>
public async Task<bool> CanJoinAsync(long instanceId, long playerId)
{
var ownerId = await db.PrivateInstances.AsNoTracking()
.Where(p => p.Id == instanceId)
.Select(p => (long?)p.OwnerPlayerId)
.FirstOrDefaultAsync();
if (ownerId is null) return false;
if (ownerId == playerId) return true;
return await db.PrivateInstanceInvitees.AsNoTracking()
.AnyAsync(i => i.PrivateInstanceId == instanceId && i.PlayerId == playerId);
}
private static PrivateInstance ToRecord(PrivateInstanceEntity e) => new(
InstanceId: e.Id,
RoomId: e.RoomId,
SubRoomId: e.SubRoomId,
OwnerPlayerId: e.OwnerPlayerId,
PhotonRoomId: e.PhotonRoomId,
Location: e.Location,
DataBlob: e.DataBlob,
PhotonRegion: e.PhotonRegion,
MaxCapacity: e.MaxCapacity,
Name: e.Name,
CreatedAt: DateTime.SpecifyKind(e.CreatedAt, DateTimeKind.Utc));
public sealed record PrivateInstance(
long InstanceId,
long RoomId,
long SubRoomId,
long OwnerPlayerId,
string PhotonRoomId,
string Location,
string DataBlob,
string PhotonRegion,
int MaxCapacity,
string Name,
DateTime CreatedAt);
}
@@ -0,0 +1,92 @@
namespace DorkNet.Server.Services;
/// <summary>
/// Minimal word-list profanity filter for
/// <c>api/sanitize/v1/purifyString</c> + <c>requestIsStringPure</c>.
/// Deliberately conservative — only catches the obvious slur / NSFW
/// terms a 12-year-old would type. Real Rec Room used a third-party
/// service (WebPurify); we match the API surface, not the depth.
///
/// To extend: add words to <see cref="BadWords"/>. The filter is
/// case-insensitive and matches whole-word + leetspeak variants
/// (4→a, 0→o, 1→i, 3→e, 5→s, 7→t).
/// </summary>
public static class ProfanityFilter
{
/// <summary>Lowercase canonical forms. Keep this list short
/// and curate manually — false positives are worse than
/// misses for a private friends-only server.</summary>
private static readonly string[] BadWords =
{
"fuck", "shit", "bitch", "asshole", "cunt", "nigger",
"faggot", "retard", "rape", "kys",
};
/// <summary>Returns true if the string contains no detectable
/// profanity. The wire response is a JSON primitive bool.</summary>
public static bool IsClean(string? s)
{
if (string.IsNullOrWhiteSpace(s)) return true;
var norm = Normalize(s);
foreach (var bad in BadWords)
{
if (norm.Contains(bad)) return false;
}
return true;
}
/// <summary>Replaces detected profanity with asterisks of the
/// same length. Returns the original string when clean.</summary>
public static string Purify(string? s)
{
if (string.IsNullOrWhiteSpace(s)) return s ?? string.Empty;
var result = s;
var lower = s.ToLowerInvariant();
var leet = Normalize(s);
foreach (var bad in BadWords)
{
// Replace by lowercase first (preserves original casing
// for surrounding text) — then leet-form. We only
// replace contiguous matches; partial overlaps stay.
var idx = lower.IndexOf(bad, StringComparison.Ordinal);
while (idx >= 0)
{
result = string.Concat(
result.AsSpan(0, idx),
new string('*', bad.Length),
result.AsSpan(idx + bad.Length));
lower = string.Concat(
lower.AsSpan(0, idx),
new string('*', bad.Length),
lower.AsSpan(idx + bad.Length));
idx = lower.IndexOf(bad, idx + bad.Length, StringComparison.Ordinal);
}
var leetIdx = leet.IndexOf(bad, StringComparison.Ordinal);
while (leetIdx >= 0)
{
result = string.Concat(
result.AsSpan(0, leetIdx),
new string('*', bad.Length),
result.AsSpan(leetIdx + bad.Length));
leet = string.Concat(
leet.AsSpan(0, leetIdx),
new string('*', bad.Length),
leet.AsSpan(leetIdx + bad.Length));
leetIdx = leet.IndexOf(bad, leetIdx + bad.Length, StringComparison.Ordinal);
}
}
return result;
}
/// <summary>Lowercase + de-leetspeak so common substitutions
/// (a→4, e→3, i→1, o→0, s→5, t→7) don't slip past the filter.</summary>
private static string Normalize(string s) => s.ToLowerInvariant()
.Replace('4', 'a')
.Replace('0', 'o')
.Replace('1', 'i')
.Replace('3', 'e')
.Replace('5', 's')
.Replace('7', 't')
.Replace('@', 'a')
.Replace('$', 's');
}
@@ -0,0 +1,443 @@
using Google.Protobuf;
using RecRoom.Protobuf2020;
namespace DorkNet.Server.Services;
/// <summary>
/// Normalises an uploaded <c>.room</c> protobuf blob so the 2020.12
/// watch can actually render it.
///
/// Two passes:
///
/// 1. <b>Quaternion → Euler projection</b>. Modern Rec Room (2024+)
/// writes every <c>TransformData</c>'s rotation as a quaternion
/// at field 6 (<c>quaternion_rotation</c>, a
/// <c>core.QuaternionData</c> sub-message), leaving the legacy
/// Euler Vector3 at field 2 empty. The 2020.12 watch reads only
/// field 2 — so every shape, gizmo, and child view loads at
/// <c>rotation=(0,0,0)</c>. The result is a room whose pieces
/// are at the right positions but pointing the wrong way: trains
/// floating off tracks, walls turned 90° wrong, panels facing
/// the floor, etc. (Verified empirically against several Studio-
/// imported scenes vs. the working Rockulator.)
///
/// We walk <c>PersistedRoomData.persistence_views</c>, recurse
/// into each <c>PersistenceViewData.transform</c> and
/// <c>child_views[].data.transform</c>, and when we find a
/// <c>quaternion_rotation</c> field with no
/// <c>rotation</c> Euler set, convert the quaternion to Euler
/// degrees (Unity convention, Tait-Bryan ZXY) and emit it as
/// field 2.
///
/// 2. <b>Round-trip through the 2020 schema</b>. After the
/// projection pass, parse the bytes with
/// <c>RecRoom.Protobuf2020.PersistedRoomData.Parser</c> and
/// re-serialise. Strips non-canonical encodings that the
/// stricter 2020 parser sometimes rejected and ensures the
/// output is exactly what the watch expects on the wire.
///
/// On parse failure we hand the original bytes back unchanged —
/// better to upload a blob that MAY work than to drop the user's
/// content. Callers (the admin import endpoint) log the outcome.
/// </summary>
public sealed class RoomBlobNormalizerService(ILogger<RoomBlobNormalizerService> logger)
{
public sealed record Result(byte[] Bytes, bool Normalized, int InputBytes, int OutputBytes, string? Error, int QuaternionsProjected);
public Result Normalize(byte[] input)
{
if (input is null || input.Length == 0)
return new Result(Array.Empty<byte>(), false, 0, 0, "empty_input", 0);
try
{
// Pass 1: project quaternion rotations to Euler so the
// 2020.12 watch's TransformData.rotation deserialiser
// sees something at field 2.
var (projected, quatsProjected) = ProjectQuaternionsToEulerOnTransforms(input);
// Pass 2: canonical re-encode via the 2020 schema.
var msg = PersistedRoomData.Parser.ParseFrom(projected);
var output = msg.ToByteArray();
logger.LogInformation(
"[normalize] OK quats_projected={Quats} in={In:N0} bytes, after_projection={Mid:N0} bytes, out={Out:N0} bytes ({Pct:F2}%)",
quatsProjected, input.Length, projected.Length, output.Length, (double)output.Length / input.Length * 100);
return new Result(output, true, input.Length, output.Length, null, quatsProjected);
}
catch (InvalidProtocolBufferException ex)
{
logger.LogWarning(
"[normalize] parse FAIL ({Bytes:N0} bytes): {Message} — passing original bytes through",
input.Length, ex.Message);
return new Result(input, false, input.Length, input.Length, ex.Message, 0);
}
catch (Exception ex)
{
logger.LogWarning(ex,
"[normalize] unexpected exception ({Bytes:N0} bytes) — passing original bytes through",
input.Length);
return new Result(input, false, input.Length, input.Length, ex.Message, 0);
}
}
// ── Wire-format rewriter ──────────────────────────────────────────
//
// Doing this through google.protobuf would require either compiling
// the modern proto schema as a parallel C# namespace OR using
// reflection + UnknownFieldSet hacks. Neither is appealing —
// straight wire-format manipulation is ~100 lines and surgical.
//
// Protobuf wire format primer:
// tag = (field_number << 3) | wire_type, encoded as varint.
// wire_type 0 = varint, 1 = fixed64, 2 = length-delimited,
// 5 = fixed32.
// Length-delimited values are prefixed with a varint length. To
// edit children we have to recompute the length after editing,
// so we accumulate children into a per-level MemoryStream and
// emit `tag + length + payload` once we know the final byte count.
private const int FIELD_PERSISTED_ROOM_DATA_PERSISTENCE_VIEWS = 2;
private const int FIELD_PERSISTENCE_VIEW_DATA_CHILD_VIEWS = 3;
private const int FIELD_PERSISTENCE_VIEW_DATA_TRANSFORM = 10;
private const int FIELD_CHILD_PERSISTENCE_VIEW_DATA_DATA = 2;
private const int FIELD_TRANSFORM_ROTATION_EULER = 2;
private const int FIELD_TRANSFORM_QUATERNION_ROTATION = 6;
private const int FIELD_QUATERNION_W = 1;
private const int FIELD_QUATERNION_X = 2;
private const int FIELD_QUATERNION_Y = 3;
private const int FIELD_QUATERNION_Z = 4;
private static (byte[] Bytes, int QuatsProjected) ProjectQuaternionsToEulerOnTransforms(byte[] input)
{
using var ms = new MemoryStream();
int quatsProjected = 0;
RewriteRoot(input, 0, input.Length, ms, ref quatsProjected);
return (ms.ToArray(), quatsProjected);
}
private static void RewriteRoot(byte[] buf, int pos, int end, Stream output, ref int quatsProjected)
{
while (pos < end)
{
ulong tag = ReadVarint(buf, ref pos);
int field = (int)(tag >> 3);
int wire = (int)(tag & 0x07);
if (wire == 2 && field == FIELD_PERSISTED_ROOM_DATA_PERSISTENCE_VIEWS)
{
int len = (int)ReadVarint(buf, ref pos);
using var childMs = new MemoryStream();
RewritePersistenceView(buf, pos, pos + len, childMs, ref quatsProjected);
EmitLengthDelimited(output, field, childMs.ToArray());
pos += len;
}
else
{
CopyField(buf, ref pos, wire, output, tag);
}
}
}
private static void RewritePersistenceView(byte[] buf, int pos, int end, Stream output, ref int quatsProjected)
{
while (pos < end)
{
ulong tag = ReadVarint(buf, ref pos);
int field = (int)(tag >> 3);
int wire = (int)(tag & 0x07);
if (wire == 2 && field == FIELD_PERSISTENCE_VIEW_DATA_TRANSFORM)
{
int len = (int)ReadVarint(buf, ref pos);
using var childMs = new MemoryStream();
RewriteTransform(buf, pos, pos + len, childMs, ref quatsProjected);
EmitLengthDelimited(output, field, childMs.ToArray());
pos += len;
}
else if (wire == 2 && field == FIELD_PERSISTENCE_VIEW_DATA_CHILD_VIEWS)
{
int len = (int)ReadVarint(buf, ref pos);
using var childMs = new MemoryStream();
RewriteChildPersistenceView(buf, pos, pos + len, childMs, ref quatsProjected);
EmitLengthDelimited(output, field, childMs.ToArray());
pos += len;
}
else
{
CopyField(buf, ref pos, wire, output, tag);
}
}
}
private static void RewriteChildPersistenceView(byte[] buf, int pos, int end, Stream output, ref int quatsProjected)
{
// ChildPersistenceViewData has field 1 (child_id, varint) and
// field 2 (data, a nested PersistenceViewData).
while (pos < end)
{
ulong tag = ReadVarint(buf, ref pos);
int field = (int)(tag >> 3);
int wire = (int)(tag & 0x07);
if (wire == 2 && field == FIELD_CHILD_PERSISTENCE_VIEW_DATA_DATA)
{
int len = (int)ReadVarint(buf, ref pos);
using var childMs = new MemoryStream();
RewritePersistenceView(buf, pos, pos + len, childMs, ref quatsProjected);
EmitLengthDelimited(output, field, childMs.ToArray());
pos += len;
}
else
{
CopyField(buf, ref pos, wire, output, tag);
}
}
}
private static void RewriteTransform(byte[] buf, int pos, int end, Stream output, ref int quatsProjected)
{
// Pass 1 over the TransformData: detect whether field 2 (Euler)
// is set and whether field 6 (quaternion) is set. If we have
// a quaternion but no Euler, we'll synthesise an Euler at the
// end and skip the quaternion field on the second pass.
bool hasEuler = false;
bool hasQuat = false;
float qw = 1, qx = 0, qy = 0, qz = 0;
int scanPos = pos;
while (scanPos < end)
{
ulong tag = ReadVarint(buf, ref scanPos);
int field = (int)(tag >> 3);
int wire = (int)(tag & 0x07);
if (wire == 2)
{
int len = (int)ReadVarint(buf, ref scanPos);
if (field == FIELD_TRANSFORM_ROTATION_EULER) hasEuler = true;
else if (field == FIELD_TRANSFORM_QUATERNION_ROTATION)
{
hasQuat = true;
ReadQuaternion(buf, scanPos, scanPos + len, out qw, out qx, out qy, out qz);
}
scanPos += len;
}
else
{
SkipField(buf, ref scanPos, wire);
}
}
bool shouldProject = hasQuat && !hasEuler;
// Pass 2: re-emit the TransformData. Copy every field through
// unchanged EXCEPT the quaternion (field 6) which we drop when
// we're going to synthesise an Euler from it.
int rewritePos = pos;
while (rewritePos < end)
{
int fieldStart = rewritePos;
ulong tag = ReadVarint(buf, ref rewritePos);
int field = (int)(tag >> 3);
int wire = (int)(tag & 0x07);
if (shouldProject && field == FIELD_TRANSFORM_QUATERNION_ROTATION)
{
// Skip — we're replacing it.
SkipField(buf, ref rewritePos, wire);
continue;
}
// Re-emit field unchanged (tag + value bytes).
rewritePos = fieldStart;
CopyFieldWithTag(buf, ref rewritePos, output);
}
if (shouldProject)
{
var (ex, ey, ez) = QuaternionToUnityEulerDegrees(qx, qy, qz, qw);
using var eulerMs = new MemoryStream();
// Vector3Data: float x = 1, y = 2, z = 3 (all fixed32 wire type 5).
WriteFloat(eulerMs, 1, ex);
WriteFloat(eulerMs, 2, ey);
WriteFloat(eulerMs, 3, ez);
EmitLengthDelimited(output, FIELD_TRANSFORM_ROTATION_EULER, eulerMs.ToArray());
quatsProjected++;
}
}
private static void ReadQuaternion(byte[] buf, int pos, int end, out float w, out float x, out float y, out float z)
{
w = 1; x = 0; y = 0; z = 0;
while (pos < end)
{
ulong tag = ReadVarint(buf, ref pos);
int field = (int)(tag >> 3);
int wire = (int)(tag & 0x07);
if (wire == 5) // fixed32 = float
{
float v = BitConverter.ToSingle(buf, pos);
pos += 4;
switch (field)
{
case FIELD_QUATERNION_W: w = v; break;
case FIELD_QUATERNION_X: x = v; break;
case FIELD_QUATERNION_Y: y = v; break;
case FIELD_QUATERNION_Z: z = v; break;
}
}
else
{
SkipField(buf, ref pos, wire);
}
}
}
// Unity uses the ZXY Tait-Bryan convention for Quaternion.eulerAngles.
// Formula adapted from Unity's source (Quaternion → euler in
// degrees), with a clamped asin to guard against floating-point
// values just outside [-1, 1] from non-unit quaternions.
private static (float x, float y, float z) QuaternionToUnityEulerDegrees(float qx, float qy, float qz, float qw)
{
const double rad2deg = 180.0 / Math.PI;
double sinrCosp = 2.0 * (qw * qx + qy * qz);
double cosrCosp = 1.0 - 2.0 * (qx * qx + qy * qy);
double xRad = Math.Atan2(sinrCosp, cosrCosp);
double sinp = 2.0 * (qw * qy - qz * qx);
if (sinp > 1.0) sinp = 1.0; else if (sinp < -1.0) sinp = -1.0;
double yRad = Math.Asin(sinp);
double sinyCosp = 2.0 * (qw * qz + qx * qy);
double cosyCosp = 1.0 - 2.0 * (qy * qy + qz * qz);
double zRad = Math.Atan2(sinyCosp, cosyCosp);
return ((float)(xRad * rad2deg), (float)(yRad * rad2deg), (float)(zRad * rad2deg));
}
// ── Varint + field helpers ────────────────────────────────────────
private static ulong ReadVarint(byte[] buf, ref int pos)
{
ulong val = 0;
int shift = 0;
while (true)
{
byte b = buf[pos++];
val |= (ulong)(b & 0x7F) << shift;
if ((b & 0x80) == 0) return val;
shift += 7;
if (shift >= 64) throw new InvalidDataException("varint too long");
}
}
private static void WriteVarint(Stream s, ulong v)
{
while (v >= 0x80)
{
s.WriteByte((byte)(v | 0x80));
v >>= 7;
}
s.WriteByte((byte)v);
}
private static void EmitLengthDelimited(Stream s, int field, byte[] payload)
{
ulong tag = ((ulong)field << 3) | 2;
WriteVarint(s, tag);
WriteVarint(s, (ulong)payload.Length);
s.Write(payload, 0, payload.Length);
}
private static void WriteFloat(Stream s, int field, float value)
{
ulong tag = ((ulong)field << 3) | 5;
WriteVarint(s, tag);
var bytes = BitConverter.GetBytes(value);
s.Write(bytes, 0, 4);
}
private static void SkipField(byte[] buf, ref int pos, int wire)
{
switch (wire)
{
case 0: ReadVarint(buf, ref pos); break;
case 1: pos += 8; break;
case 2: { int n = (int)ReadVarint(buf, ref pos); pos += n; break; }
case 5: pos += 4; break;
default: throw new InvalidDataException($"unsupported wire type {wire}");
}
}
/// <summary>Copy a single field's value (post-tag) verbatim from
/// <paramref name="buf"/> to <paramref name="output"/>, advancing
/// <paramref name="pos"/> past the value. <paramref name="tag"/>
/// is the already-decoded wire tag the caller already consumed
/// from the input — we re-emit it to <paramref name="output"/>
/// before the value bytes.</summary>
private static void CopyField(byte[] buf, ref int pos, int wire, Stream output, ulong tag)
{
WriteVarint(output, tag);
switch (wire)
{
case 0: // varint
{
int start = pos;
ReadVarint(buf, ref pos);
output.Write(buf, start, pos - start);
break;
}
case 1: // fixed64
output.Write(buf, pos, 8); pos += 8; break;
case 2: // length-delimited
{
int lengthStart = pos;
int len = (int)ReadVarint(buf, ref pos);
output.Write(buf, lengthStart, pos - lengthStart); // length varint
output.Write(buf, pos, len);
pos += len;
break;
}
case 5: // fixed32
output.Write(buf, pos, 4); pos += 4; break;
default:
throw new InvalidDataException($"unsupported wire type {wire}");
}
}
/// <summary>Copy an entire field (tag + value) verbatim to
/// <paramref name="output"/>. Used when re-emitting fields we
/// didn't decide to rewrite — we re-read the tag from
/// <paramref name="buf"/> rather than pass it through, which makes
/// the call site cleaner.</summary>
private static void CopyFieldWithTag(byte[] buf, ref int pos, Stream output)
{
int tagStart = pos;
ulong tag = ReadVarint(buf, ref pos);
int wire = (int)(tag & 0x07);
// Write tag verbatim from original bytes (cheaper than re-encoding).
output.Write(buf, tagStart, pos - tagStart);
switch (wire)
{
case 0:
{
int start = pos;
ReadVarint(buf, ref pos);
output.Write(buf, start, pos - start);
break;
}
case 1: output.Write(buf, pos, 8); pos += 8; break;
case 2:
{
int lengthStart = pos;
int len = (int)ReadVarint(buf, ref pos);
output.Write(buf, lengthStart, pos - lengthStart);
output.Write(buf, pos, len);
pos += len;
break;
}
case 5: output.Write(buf, pos, 4); pos += 4; break;
default:
throw new InvalidDataException($"unsupported wire type {wire}");
}
}
}
@@ -0,0 +1,21 @@
namespace DorkNet.Server.Services;
public static class RoomCodeService
{
private const string Alphabet = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ";
public static string Normalize(string? code)
=> (code ?? string.Empty).Trim().Replace("-", string.Empty).ToUpperInvariant();
public static string Generate(long instanceId)
{
var value = (ulong)HashCode.Combine(instanceId, 0x52_52_4d_43);
Span<char> chars = stackalloc char[6];
for (var i = 0; i < chars.Length; i++)
{
chars[i] = Alphabet[(int)(value % (uint)Alphabet.Length)];
value /= (uint)Alphabet.Length;
}
return new string(chars);
}
}
@@ -0,0 +1,111 @@
using Google.Protobuf;
using RecRoom.Protobuf;
namespace DorkNet.Server.Services;
/// <summary>
/// Builds the binary `PersistedRoomData` protobuf the client downloads when
/// it loads a room scene. The 2020 RecRoom client gates the Maker Pen,
/// invention spawning, room-role editing and a dozen other tools behind
/// `RoomRoleCollectionData.RoomRoles[i].CanUseMakerPen.InnerValue` etc.,
/// all of which default to `false` if the bytes we serve are empty.
///
/// We emit a minimal blob with one PlayerRoomRoleData entry that flips
/// every relevant `OverridableBoolData` to `(overrides=true, inner_value=true)`,
/// which the client picks up regardless of which RoomRoleId the local
/// player ends up assigned to. Net result: the room's creator (and
/// everyone else, since this is a private server) gets all build/admin
/// tools the moment they spawn into the scene.
///
/// Wire compatibility: our trimmed .proto only defines the messages and
/// field numbers we set — the 2020 client's full PersistedRoomData has
/// many more fields, but proto3 silently ignores unknown wire entries and
/// defaults missing ones, so the bytes we generate deserialize cleanly.
/// </summary>
public class RoomDataBlobService
{
/// <summary>
/// Cached bytes of the "all-permissions-on" blob. Identical for every
/// room — there's no per-room state we vary yet, so building once at
/// startup beats rebuilding on every download request.
/// </summary>
private readonly byte[] _allPermsBlob = BuildAllPermsBlob();
public byte[] GetDefaultBlob() => _allPermsBlob;
private static byte[] BuildAllPermsBlob()
{
// OverridableBoolData(overrides=true, inner_value=true) — used for
// every Can* field on the role.
OverridableBoolData OverrideTrue() => new()
{
Overrides = true,
InnerValue = true,
};
var role = new PlayerRoomRoleData
{
// Identity fields — these are deprecated in the 2026 schema
// but the 2020 client still reads them. role_id=0 = AG_EVERYONE
// which the client treats as "applies to all players in an AG
// room"; combined with all permissions overridden true, every
// player slot inherits maker pen.
DEPRECATEDRoleId = 0,
DEPRECATEDRoleRank = 100,
DEPRECATEDIsRoleActive = true,
DEPRECATEDIsAgRole = true,
RoleName = "Creator",
RoleVersion = 1,
RoleGuid = Guid.NewGuid().ToString(),
// Display name override — keeps the watch's role-list UI from
// showing a blank label.
Name = new OverridableStringData
{
Overrides = true,
InnerValue = "Creator",
},
// Permission grants. Each Overridable*Data(overrides=true,
// inner_value=true) tells the client "the room has explicitly
// configured this permission to true". Without overrides=true
// the client falls back to its own defaults, which for most
// perms is false.
CanAssignRoles = OverrideTrue(),
CanInvite = OverrideTrue(),
CanStartGames = OverrideTrue(),
CanTalk = OverrideTrue(),
CanPrintPhotos = OverrideTrue(),
CanEditRoomRoles = OverrideTrue(),
CanSelfRevive = OverrideTrue(),
CanEndGamesEarly = OverrideTrue(),
CanChangeGameMode = OverrideTrue(),
CanUseMakerPen = OverrideTrue(),
CanUseDeleteAllButton = OverrideTrue(),
CanSaveInventions = OverrideTrue(),
DisableMicAutoMute = OverrideTrue(),
CanUseShareCam = OverrideTrue(),
// Vote-kick permission — int field, set to a permissive value.
// 0 = AnyoneCanVoteKickAnyone in the 2020 enum (verified via
// VoteKickPermission disasm). overrides=true to make sure the
// client respects it rather than falling back to a stricter
// default.
VoteKickPermission = new OverridableIntData
{
Overrides = true,
InnerValue = 0,
},
};
var collection = new RoomRoleCollectionData();
collection.RoomRoles.Add(role);
var msg = new PersistedRoomData
{
RoomRoleData = collection,
};
return msg.ToByteArray();
}
}
+1045
View File
@@ -0,0 +1,1045 @@
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Services;
/// <summary>
/// Backs the Rooms watch tab — search, hot, by-name, by-id, my-rooms,
/// my-bookmarks, room details. Also seeds the canonical "Rec Room
/// Original" rooms on first DB creation so the watch isn't empty.
/// </summary>
public class RoomService(DorkNetDbContext db)
{
/// <summary>
/// Idempotent seed of the well-known Rec Room Original rooms — pulled
/// from the AGRoomRuntimeConfig.Locations array we walked in
/// resources.assets. Each entry maps a friendly name (the URL segment
/// used by /goto/room/{name}) to the LocationReplicationId GUID the
/// client expects in MatchmakingResponse.RoomInstance.location.
///
/// Seeded rooms are:
/// - public (Accessibility=1)
/// - AG (IsAGRoom=true — they are baked into the client's resources.assets,
/// not user-created; the client's matchmaking gates require the AG flag
/// to use the right join path)
/// - tagged appropriately so the watch's hot/search filters surface them
/// - high HotScore so they sort to the top of "Trending"
/// </summary>
public async Task SeedAsync()
{
// Idempotent fix-up: an earlier seed shipped these rooms with
// IsAGRoom=false, which broke the rec center join (client returned
// MatchmakingErrorCode.InsufficientSpace because the response shape
// didn't match an AG instance). Patch any existing seeded rows that
// still have the old flag instead of forcing a DB wipe.
await db.Rooms
.Where(r => r.Id >= 100 && r.Id < 1000 && !r.IsAGRoom && !r.IsDormRoom)
.ExecuteUpdateAsync(s => s.SetProperty(r => r.IsAGRoom, true));
// Backfill: existing DBs from before image thumbnails were
// seeded have ImageName="" — fill them in by name so the watch
// tile renders something rather than the broken-image fallback.
// Idempotent: setting an existing image again is a no-op.
await BackfillSeededImagesAsync();
// Backfill: rooms predating Phase20RoomVisitsAndStats have
// VisitorCount=0 even when VisitCount is seeded high. Estimate
// unique visitors as ~1/3 of total joins (the official Rec.Net
// ratio for a popular room); admin dashboards stay coherent
// until live traffic catches up via RoomVisitEntity upserts.
await BackfillVisitorCountAsync();
if (await db.Rooms.AnyAsync())
{
// Seeds already ran — but earlier versions of the seed
// didn't include all rooms. Add the missing ones.
await EnsureMissingSeededRoomsAsync();
return;
}
// Tags stored WITHOUT leading '#'. The watch UI prepends '#' when
// rendering chips, so storing "#recroomoriginal" produces double-#
// chips like "##recroomoriginal".
//
// ImageName is the cdn filename the watch downloads as the room
// tile thumbnail (the small square card that shows up on the
// "Trending"/"Rooms" tabs and on each room's detail page). The
// watch prepends `https://cdn.rec.net/` and downloads as PNG.
// We seed with the well-known RR room thumbnail filenames so the
// catch-all CDN fallback returns the all-perms blob (an empty/
// zero-byte image) and the watch falls back to the room's name
// tile rather than crashing on a malformed thumbnail.
var seeded = new (string Slug, string DisplayName, string Description, string LocationId, string Tags, string Image)[]
{
// All LocationReplicationIds below are AGRoomRuntimeConfig.Locations[].ReplicationId
// values extracted from the 2020.03.06 client's resources.assets via
// tools/extract-locations-binary.py. Output cached at data/room_locations.json.
// Each Location has a SceneName matching one of the .unity scenes the client
// ships (see tools/dump-all-scenes.py for the 96-scene index).
("RecCenter", "Rec Center", "A social hub to meet and mingle with friends new and old.", "cbad71af-0831-44d8-b8ef-69edafa841f6", "recroomoriginal,featured", "image_RecCenter.png"),
("3DCharades", "3D Charades", "Take turns drawing, acting, and guessing funny phrases with your friends!", "4078dfed-24bb-4db7-863f-578ba48d726b", "recroomoriginal", "image_3DCharades.png"),
("DiscGolfLake", "DiscGolf Lake", "A leisurely stroll through the grass. Throw your disc into the goal.", "f6f7256c-e438-4299-b99e-d20bef8cf7e0", "recroomoriginal,sport", "image_DiscGolfLake.png"),
("DiscGolfPropulsion","DiscGolf Propulsion","Throw your disc through hazards and around wind machines on this challenging course!", "d9378c9f-80bc-46fb-ad1e-1bed8a674f55", "recroomoriginal,sport", "image_DiscGolfPropulsion.png"),
("Dodgeball", "Dodgeball", "Throw dodgeballs to knock out your friends in this gym classic!", "3d474b26-26f7-45e9-9a36-9b02847d5e6f", "recroomoriginal,sport", "image_Dodgeball.png"),
("Paddleball", "Paddleball", "A simple rally game between two players in a plexiglass tube with a zero-g ball.", "d89f74fa-d51e-477a-a425-025a891dd499", "recroomoriginal,sport", "image_Paddleball.png"),
("Paintball", "Paintball", "Red and Blue teams splat each other in capture the flag and team battle.", "e122fe98-e7db-49e8-a1b1-105424b6e1f0", "recroomoriginal,sport", "image_Paintball.png"),
// Soccer scene = soccer.unity, Location ReplicationId verified
// (was previously mis-mapped to IsleOfLostSkulls' GUID).
("Soccer", "Soccer", "Teams of three run around slamming themselves into an over-sized soccer ball. Goal!", "6d5eea4b-f069-4ed0-9916-0e2f07df0d03", "recroomoriginal,sport", "image_Soccer.png"),
// LaserTag in the 2020 build IS the CyberJunkCity arena scene
// (Arena_Cyberjunk_City.unity). Was previously mis-mapped to
// soccer's GUID. Hangar (Arena_Hangar3.unity) is the alternate
// map players reach via the in-game lobby.
("LaserTag", "Laser Tag", "Teams battle each other and waves of robots in a totally cyber neon future city.", "9d6456ce-6264-48b4-808d-2d96b3d91038", "recroomoriginal,sport", "image_LaserTag.png"),
("CyberJunkCity", "Laser Tag CyberJunk","Teams battle each other and waves of robots in a totally cyber neon future city.", "9d6456ce-6264-48b4-808d-2d96b3d91038", "recroomoriginal,sport", "image_CyberJunkCity.png"),
("LaserTagHangar", "Laser Tag Hangar", "Teams battle in an industrial warehouse map.", "239e676c-f12f-489f-bf3a-d4c383d692c3", "recroomoriginal,sport", "image_LaserTag.png"),
("RecRoyaleSquads", "Rec Royale Squads", "Squads of three battle it out on Frontier Island. Last squad standing wins!", "253fa009-6e65-4c90-91a1-7137a56a267f", "recroomoriginal,featured,sport", "image_RecRoyaleSquads.png"),
// RecRoyaleSolos was previously pointing at a Home location
// GUID (85b43509-…); the real Solos Location is b010171f-….
("RecRoyaleSolos", "Rec Royale Solos", "Battle it out on Frontier Island. Last person standing wins!", "b010171f-4875-4e89-baba-61e878cd41e1", "recroomoriginal,sport", "image_RecRoyaleSolos.png"),
("GoldenTrophy", "Quest For The Golden Trophy", "The goblin king stole Coach's Golden Trophy. Team up and embark on an epic quest to recover it!", "91e16e35-f48f-4700-ab8a-a1b79e50e51b", "recroomoriginal,quest", "image_GoldenTrophy.png"),
("TheRiseofJumbotron","The Rise of Jumbotron", "Robot invaders threaten the galaxy! Team up with your friends and bring the laser heat!", "acc06e66-c2d0-4361-b0cd-46246a4c455c", "recroomoriginal,quest", "image_TheRiseofJumbotron.png"),
("CrimsonCauldron", "Curse of the Crimson Cauldron", "Can your band of adventurers brave the enchanted wilds, and lift the curse of the crimson cauldron?", "949fa41f-4347-45c0-b7ac-489129174045", "recroomoriginal,quest", "image_CrimsonCauldron.png"),
// IsleOfLostSkulls was previously pointing at Quarry's GUID;
// real Location ReplicationId is 7e01cfe0-… (scene Quest_Pirate1_additive).
("IsleOfLostSkulls", "The Isle of Lost Skulls", "Can your pirate crew get to the Isle, defeat its fearsome guardian, and escape with the gold?", "7e01cfe0-820a-406f-b1b3-0a5bf575235c", "recroomoriginal,quest", "image_IsleOfLostSkulls.png"),
("Crescendo", "Crescendo of the Blood Moon", "Brave the haunted halls of Castle Dracula and survive the night.", "49cb8993-a956-43e2-86f4-1318f279b22a", "recroomoriginal,quest", "image_Crescendo.png"),
("StuntRunner", "Stunt Runner", "A solo platforming gauntlet — sprint, climb and dodge to reach the trophy at the top.", "b7281665-a715-4051-826b-8e08e69c6172", "recroomoriginal,sport,quest", "image_StuntRunner.png"),
("Drive-In", "Rec Drive-In", "Watch movies, hang out with friends, or chill at the bar.", "65ddbb48-5a01-4e3e-972d-e5c7419e2bc3", "recroomoriginal,featured", "image_DriveIn.png"),
// Hub / hang rooms — no specific gameplay, just shared spaces.
("Park", "The Park", "An outdoor park with picnic tables and lawn games — a chill place to hang out.", "0a864c86-5a71-4e18-8041-8124e4dc9d98", "recroomoriginal,featured", "image_Park.png"),
("PerformanceHall", "Performance Hall", "A big stage for live performances. Sing, perform, or just enjoy the show.", "9932f88f-3929-43a0-a012-a40b5128e346", "recroomoriginal", "image_PerformanceHall.png"),
("EventRoom", "The Lounge", "A modular event hall with movable furniture for parties and meetings.", "a067557f-ca32-43e6-b6e5-daaec60b4f5a", "recroomoriginal", "image_EventRoom.png"),
("BowlingAlley", "Bowling Alley", "Classic ten-pin bowling. Roll strikes, beat your friends.", "ae929543-9a07-41d5-8ee9-dbbee8c36800", "recroomoriginal,sport", "image_BowlingAlley.png"),
// Sandbox templates (cloning canvases) — every "Home"-named
// Location with a recognizable scene. Players land in these
// when picking a stage for a custom room.
("River", "River", "An outdoor template for building. Mountains, river, and forest as your starting canvas.", "e122fe98-e7db-49e8-a1b1-105424b6e1f0", "recroomoriginal,template", "image_River.png"),
("Homestead", "Homestead", "A frontier-themed template — barn, fences, dusty trails. Build your homestead.", "a785267d-c579-42ea-be43-fec1992d1ca7", "recroomoriginal,template", "image_Homestead.png"),
("Quarry", "Quarry", "A rocky open-pit template, ideal for industrial and obstacle-course builds.", "ff4c6427-7079-4f59-b22a-69b089420827", "recroomoriginal,template", "image_Quarry.png"),
("Clearcut", "Clearcut", "An open clearing template — flat ground, mountains in the distance.", "380d18b5-de9c-49f3-80f7-f4a95c1de161", "recroomoriginal,template", "image_Clearcut.png"),
("Spillway", "Spillway", "A water-park template — pools, waterslides, and concrete chutes for action builds.", "58763055-2dfb-4814-80b8-16fac5c85709", "recroomoriginal,template", "image_Spillway.png"),
("MakerRoom", "Maker Room", "A blank-walled canvas. The classic starting point for a Maker Pen build.", "a75f7547-79eb-47c6-8986-6767abcb4f92", "recroomoriginal,template,featured", "image_RecCenter.png"),
};
// Stable id allocation: 100..1000 reserved for seeded rooms. User
// rooms get ids above 1000 so /goto/room/{id} never collides with
// a system room.
long id = 100;
// hot scores in descending order — first seeded entry sorts first.
var hotScore = 1_000_000.0;
foreach (var entry in seeded)
{
db.Rooms.Add(new RoomEntity
{
Id = id++,
Name = entry.Slug,
Description = entry.Description,
CreatorPlayerId = 1,
// Prefer the real Rec.Net CDN image (sourced via
// tools/fetch-room-images.py and cached in
// data/images/) over the placeholder image_X.png.
ImageName = RoomImagesByName.TryGetValue(entry.Slug, out var realImg)
? realImg : entry.Image,
State = 0,
Accessibility = 1,
// Seeded rooms ARE the official AG (Against Gravity / Rec Room
// Original) rooms — Rec Center, Paintball, Quests, etc. The
// client treats AG rooms specially during the matchmaking
// join flow (different room-property expectations, longer
// capacity caps); marking them non-AG breaks the rec center
// join with MatchmakingErrorCode.InsufficientSpace because
// the client decides the response shape doesn't match a
// real AG instance.
IsAGRoom = true,
IsDormRoom = false,
CloningAllowed = false,
LocationReplicationId = entry.LocationId,
TagsCsv = entry.Tags,
CheerCount = 1000,
FavoriteCount = 500,
VisitCount = 100_000,
HotScore = hotScore,
});
hotScore -= 1.0;
}
await db.SaveChangesAsync();
}
/// <summary>Per-room thumbnail filenames. Real names sourced from
/// the official Rec.Net public API
/// (<c>https://apim.rec.net/rooms/rooms?name={name}&amp;include=0</c>)
/// via <c>tools/fetch-room-images.py</c>; the cached PNGs live in
/// <c>data/images/</c> and are served by the merged
/// <see cref="Controllers.Cdn.CdnController"/> (img.* branch).
/// Fallback to <c>image_{name}.png</c> placeholders for sub-rooms
/// the public API doesn't expose by name (DormRoom, Paintball
/// sub-maps, CyberJunkCity, etc).</summary>
private static readonly Dictionary<string, string> RoomImagesByName = LoadRoomImages();
private static Dictionary<string, string> LoadRoomImages()
{
var fallback = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["CyberJunkCity"] = "image_CyberJunkCity.png",
["Drive-In"] = "image_DriveIn.png",
["River"] = "image_River.png",
["Homestead"] = "image_Homestead.png",
["Quarry"] = "image_Quarry.png",
["Clearcut"] = "image_Clearcut.png",
["Spillway"] = "image_Spillway.png",
};
var candidates = new[]
{
Path.Combine(AppContext.BaseDirectory, "data", "room_images.json"),
Path.Combine(Directory.GetCurrentDirectory(), "data", "room_images.json"),
};
var path = candidates.FirstOrDefault(System.IO.File.Exists);
if (path is null) return fallback;
try
{
using var fs = System.IO.File.OpenRead(path);
var real = System.Text.Json.JsonSerializer
.Deserialize<Dictionary<string, string>>(fs)
?? new();
// Real entries override fallbacks.
foreach (var (k, v) in real) fallback[k] = v;
}
catch { /* fall through to fallback */ }
return new Dictionary<string, string>(fallback, StringComparer.OrdinalIgnoreCase);
}
/// <summary>One-time fixup so existing DBs (seeded before
/// thumbnails were added, or when the image map updates with real
/// CDN names) get their ImageName populated. Looks up the seeded
/// image filename by room name and patches both blank rows AND
/// rows still carrying placeholder <c>image_X.png</c> names so
/// the real CDN-sourced image takes effect.</summary>
private async Task BackfillSeededImagesAsync()
{
var rows = await db.Rooms
.Where(r => r.Id >= 100 && r.Id < 1000)
.ToListAsync();
// The watch fetches each tile thumbnail by ImageName; a name
// that doesn't resolve to a file on disk (or in S3) lands on
// the transparent-PNG fallback, which the watch's image pipe
// renders as the red "?" placeholder. For each seeded row pick
// the best ImageName in this priority order: (1) the hash
// filename from room_images.json, (2) the original
// image_<slug>.png placeholder, (3) image_RecCenter.png as a
// guaranteed-present fallback. Anything we pick must exist as
// a file under data/images so the disk hit path serves it.
const string ultimateFallback = "image_RecCenter.png";
var imagesDir = Path.Combine(AppContext.BaseDirectory, "data", "images");
bool OnDisk(string name) =>
!string.IsNullOrEmpty(name) && File.Exists(Path.Combine(imagesDir, name));
var changed = 0;
foreach (var r in rows)
{
string preferred;
if (RoomImagesByName.TryGetValue(r.Name, out var realImg) && OnDisk(realImg))
preferred = realImg;
else if (OnDisk($"image_{r.Name}.png"))
preferred = $"image_{r.Name}.png";
else if (OnDisk(r.ImageName))
preferred = r.ImageName;
else
preferred = ultimateFallback;
if (r.ImageName != preferred)
{
r.ImageName = preferred;
changed++;
}
}
if (changed > 0) await db.SaveChangesAsync();
}
/// <summary>Set <see cref="RoomEntity.VisitorCount"/> for any
/// row that has joins but no unique-visitor estimate yet. Two
/// data sources, in priority order:
///
/// 1. Real <see cref="RoomVisitEntity"/> rows (post-Phase20):
/// VisitorCount = COUNT(DISTINCT PlayerId) for that room.
/// Authoritative — this is the value the live upsert
/// maintains going forward.
/// 2. Estimate from VisitCount when no per-player rows exist
/// (e.g. seeded rooms with VisitCount=100,000 from the
/// initial seed). Use VisitCount/3 — roughly the official
/// Rec.Net ratio of unique visitors to total joins for a
/// mature, popular room. Beats showing 0 visitors next
/// to 100K joins on the admin dashboard.
/// </summary>
private async Task BackfillVisitorCountAsync()
{
// Step 1: rebuild from real RoomVisitEntity rows.
var realCounts = await db.RoomVisits
.GroupBy(v => v.RoomId)
.Select(g => new { RoomId = g.Key, Count = g.Select(v => v.PlayerId).Distinct().Count() })
.ToListAsync();
var realByRoom = realCounts.ToDictionary(x => x.RoomId, x => x.Count);
// Step 2: for any room without per-player rows, fall back to
// VisitCount/3. Skip rooms whose VisitorCount already matches
// the canonical value to keep the write set small.
var rooms = await db.Rooms.ToListAsync();
var changed = 0;
foreach (var room in rooms)
{
int target;
if (realByRoom.TryGetValue(room.Id, out var real))
{
target = real;
}
else if (room.VisitorCount == 0 && room.VisitCount > 0)
{
target = Math.Max(1, room.VisitCount / 3);
}
else
{
continue;
}
if (room.VisitorCount != target)
{
room.VisitorCount = target;
changed++;
}
}
if (changed > 0) await db.SaveChangesAsync();
}
/// <summary>Adds any seeded rooms that aren't yet in the DB. Used
/// when an existing DB pre-dates a new room being added to the
/// seed list (e.g. StuntRunner, the building templates) — without
/// this, the user would have to wipe the DB to see them.</summary>
private async Task EnsureMissingSeededRoomsAsync()
{
// Drop any StuntRunner row carrying our old placeholder GUID
// (5b6e1a3f-…) so the next seed pass below can replace it with
// the canonical b7281665-… id. Without this, an existing DB
// keeps the broken row and /goto/room/StuntRunner still hits
// "RecNet game session contains unknown scene location ID".
const string oldBadGuid = "5b6e1a3f-3a8b-4c7c-9b88-4b51d2b0e1f4";
var bad = await db.Rooms
.Where(r => r.Name == "StuntRunner" && r.LocationReplicationId == oldBadGuid)
.ToListAsync();
if (bad.Count > 0)
{
db.Rooms.RemoveRange(bad);
await db.SaveChangesAsync();
}
// Fix up rows whose LocationReplicationId was wrong in earlier
// seed passes. Each (room name, real GUID) pair was verified
// against AGRoomRuntimeConfig.Locations in resources.assets via
// tools/extract-locations-binary.py. Only updates rows that
// currently hold the WRONG GUID — already-correct or user-placed
// rooms aren't touched.
var corrections = new (string Name, string CorrectGuid)[]
{
("Soccer", "6d5eea4b-f069-4ed0-9916-0e2f07df0d03"),
("LaserTag", "9d6456ce-6264-48b4-808d-2d96b3d91038"),
("RecRoyaleSolos", "b010171f-4875-4e89-baba-61e878cd41e1"),
("IsleOfLostSkulls", "7e01cfe0-820a-406f-b1b3-0a5bf575235c"),
};
foreach (var (name, correct) in corrections)
{
await db.Rooms
.Where(r => r.Name == name && r.LocationReplicationId != correct)
.ExecuteUpdateAsync(s => s.SetProperty(r => r.LocationReplicationId, correct));
}
var missing = new (string Slug, string DisplayName, string Description, string LocationId, string Tags, string Image)[]
{
("StuntRunner", "Stunt Runner", "A solo platforming gauntlet — sprint, climb and dodge to reach the trophy at the top.", "b7281665-a715-4051-826b-8e08e69c6172", "recroomoriginal,sport,quest", "image_StuntRunner.png"),
("Drive-In", "Rec Drive-In", "Watch movies, hang out with friends, or chill at the bar.", "65ddbb48-5a01-4e3e-972d-e5c7419e2bc3", "recroomoriginal,featured", "image_DriveIn.png"),
("River", "River", "An outdoor template for building. Mountains, river, and forest as your starting canvas.", "e122fe98-e7db-49e8-a1b1-105424b6e1f0", "recroomoriginal,template", "image_River.png"),
("Homestead", "Homestead", "A frontier-themed template — barn, fences, dusty trails. Build your homestead.", "a785267d-c579-42ea-be43-fec1992d1ca7", "recroomoriginal,template", "image_Homestead.png"),
("Quarry", "Quarry", "A rocky open-pit template, ideal for industrial and obstacle-course builds.", "ff4c6427-7079-4f59-b22a-69b089420827", "recroomoriginal,template", "image_Quarry.png"),
("Clearcut", "Clearcut", "An open clearing template — flat ground, mountains in the distance.", "380d18b5-de9c-49f3-80f7-f4a95c1de161", "recroomoriginal,template", "image_Clearcut.png"),
("Spillway", "Spillway", "A water-park template — pools, waterslides, and concrete chutes for action builds.", "58763055-2dfb-4814-80b8-16fac5c85709", "recroomoriginal,template", "image_Spillway.png"),
// Phase 22: hub / hang rooms + maker canvases that weren't
// in the original seed.
("Park", "The Park", "An outdoor park with picnic tables and lawn games — a chill place to hang out.", "0a864c86-5a71-4e18-8041-8124e4dc9d98", "recroomoriginal,featured", "image_Park.png"),
("PerformanceHall", "Performance Hall", "A big stage for live performances. Sing, perform, or just enjoy the show.", "9932f88f-3929-43a0-a012-a40b5128e346", "recroomoriginal", "image_PerformanceHall.png"),
("EventRoom", "The Lounge", "A modular event hall with movable furniture for parties and meetings.", "a067557f-ca32-43e6-b6e5-daaec60b4f5a", "recroomoriginal", "image_EventRoom.png"),
("BowlingAlley", "Bowling Alley", "Classic ten-pin bowling. Roll strikes, beat your friends.", "ae929543-9a07-41d5-8ee9-dbbee8c36800", "recroomoriginal,sport", "image_BowlingAlley.png"),
("Crescendo", "Crescendo of the Blood Moon", "Brave the haunted halls of Castle Dracula and survive the night.", "49cb8993-a956-43e2-86f4-1318f279b22a", "recroomoriginal,quest", "image_Crescendo.png"),
("LaserTagHangar", "Laser Tag Hangar", "Teams battle in an industrial warehouse map.", "239e676c-f12f-489f-bf3a-d4c383d692c3", "recroomoriginal,sport", "image_LaserTag.png"),
("MakerRoom", "Maker Room", "A blank-walled canvas. The classic starting point for a Maker Pen build.", "a75f7547-79eb-47c6-8986-6767abcb4f92", "recroomoriginal,template,featured", "image_RecCenter.png"),
};
// Allocate ids above the highest existing seeded id so we don't
// collide with a user room.
var maxSeededId = await db.Rooms
.Where(r => r.Id >= 100 && r.Id < 1000)
.Select(r => (long?)r.Id)
.MaxAsync() ?? 99;
var nextId = Math.Max(maxSeededId + 1, 100);
// Hot score below the existing seeded set so newcomers don't
// jump to the top of the trending feed.
var hotScore = 50_000.0;
var existingNames = await db.Rooms
.Where(r => r.Id >= 100 && r.Id < 1000)
.Select(r => r.Name)
.ToListAsync();
var existing = new HashSet<string>(existingNames, StringComparer.OrdinalIgnoreCase);
var added = false;
foreach (var entry in missing)
{
if (existing.Contains(entry.Slug)) continue;
db.Rooms.Add(new RoomEntity
{
Id = nextId++,
Name = entry.Slug,
Description = entry.Description,
CreatorPlayerId = 1,
// Prefer the real Rec.Net CDN image (sourced via
// tools/fetch-room-images.py and cached in
// data/images/) over the placeholder image_X.png.
ImageName = RoomImagesByName.TryGetValue(entry.Slug, out var realImg)
? realImg : entry.Image,
State = 0,
Accessibility = 1,
IsAGRoom = true,
IsDormRoom = false,
CloningAllowed = false,
LocationReplicationId = entry.LocationId,
TagsCsv = entry.Tags,
CheerCount = 200,
FavoriteCount = 100,
VisitCount = 10_000,
HotScore = hotScore,
});
hotScore -= 1.0;
added = true;
}
if (added) await db.SaveChangesAsync();
}
public Task<RoomEntity?> GetByIdAsync(long roomId) =>
db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
public Task<RoomEntity?> GetByNameAsync(string name) =>
db.Rooms.FirstOrDefaultAsync(r => r.Name == name);
/// <summary>
/// Get-or-create the personal-dorm <see cref="RoomEntity"/> for a
/// given player. Each account owns exactly one dorm row, identified
/// by <c>IsDormRoom = true</c> + <c>CreatorPlayerId == playerId</c>.
/// The watch's "DormRoom" matchmaking call resolves to this row, so
/// every player has their own first-class dorm with a real id (not
/// the synthetic shared id=1 we used to fake) — that means /myrooms
/// surfaces it, room-saves hit a real Rooms.id row, and a
/// long-term moderation tool can flag/clean a single dorm without
/// touching another player's.
///
/// LocationReplicationId is the shared dorm-scene GUID (every
/// dorm renders the same Unity scene; per-player customisation is
/// the DormStateEntity blob layered on top).
/// </summary>
public async Task<RoomEntity> EnsurePersonalDormAsync(long playerId)
{
// Two rows back this player's dorm: a RoomEntity (the canonical
// room record /goto/room/DormRoom resolves to) and a
// DormStateEntity (the per-player save-blob pointer the
// RoomsController.Details substitutes into the dorm response so
// each player loads THEIR save instead of whoever-saved-last's).
// Both are idempotent: we no-op if either side already exists,
// and we always end with both rows present so the watch's
// GetRoomData → /room/{blobName} flow has a deterministic
// CurrentDataBlobName to fetch (empty string until the player
// saves; CdnController.Serve treats empty as "return the
// default dorm bytes").
var existing = await db.Rooms
.FirstOrDefaultAsync(r => r.IsDormRoom && r.CreatorPlayerId == playerId);
var dormStateExists = await db.DormStates
.AnyAsync(d => d.PlayerId == playerId);
if (existing is not null && dormStateExists)
return existing;
if (existing is null)
{
// Idempotent unique name: collisions only happen if a
// previous boot crashed mid-create. Suffix with the player
// id so the unique-name constraint never bites.
existing = new RoomEntity
{
Name = $"Dorm_{playerId}",
Description = "Your private dorm — yours alone, decorated however you like.",
CreatorPlayerId = playerId,
ImageName = "",
State = 0,
Accessibility = 0, // private — only the owner enters
IsAGRoom = false,
IsDormRoom = true,
CloningAllowed = false,
SupportsVRLow = true,
SupportsMobile = false,
SupportsScreens = true,
SupportsWalkVR = true,
SupportsTeleportVR = true,
AllowsJuniors = true,
LocationReplicationId = "76d98498-60a1-430c-ab76-b54a29b7a163",
TagsCsv = "dorm",
CheerCount = 0,
FavoriteCount = 0,
VisitCount = 0,
HotScore = 0,
};
db.Rooms.Add(existing);
}
if (!dormStateExists)
{
db.DormStates.Add(new DormStateEntity
{
PlayerId = playerId,
// Empty until the player saves their dorm via Maker
// Pen. CdnController.Serve sees the empty blob name in
// the Details response and serves
// RoomDataBlobService.GetDefaultBlob() back to the
// watch — boots into the stock dorm scene without a
// 500.
CurrentDataBlobName = string.Empty,
});
}
await db.SaveChangesAsync();
return existing;
}
/// <summary>Run once at startup to backfill <see cref="RoomEntity"/>
/// + <see cref="DormStateEntity"/> rows for every account that
/// pre-dates the auto-create-on-signup work. Idempotent — calls
/// <see cref="EnsurePersonalDormAsync"/> per missing player so the
/// guarantee "every account has a dorm" holds even for accounts
/// that signed in via a code path that didn't run the lazy-create.
/// </summary>
public async Task EnsureDormsForAllPlayersAsync()
{
// Players who don't have a dorm RoomEntity yet. Skip the system
// account (Coach, id=1) — it owns the RR-Original rooms, not a
// personal dorm. Skip players who already have BOTH rows.
var missing = await db.Players
.Where(p => p.Id != 1 &&
(!db.Rooms.Any(r => r.IsDormRoom && r.CreatorPlayerId == p.Id) ||
!db.DormStates.Any(d => d.PlayerId == p.Id)))
.Select(p => p.Id)
.ToListAsync();
if (missing.Count == 0) return;
foreach (var pid in missing)
{
await EnsurePersonalDormAsync(pid);
}
// EnsurePersonalDormAsync already SaveChangesAsync'd per-call,
// but log the aggregate once for ops visibility.
// (Per-call save keeps each row's failure isolated; if one
// EnsurePersonalDorm throws we still backfilled the rest.)
}
/// <summary>
/// Hot-list query — drives `api/rooms/v1/hot?roomScoreType=...&tags=...`.
/// Filters by tag prefix-match and orders by HotScore desc.
/// The watch sends `?tags=%23community` (URL-encoded `#community`); we
/// strip the leading `#` so it matches the bare tag names stored in
/// TagsCsv.
/// </summary>
public async Task<List<RoomEntity>> HotAsync(string? tag, int take = 50)
{
// Exclude personal dorms — they're per-player private spaces
// and have no business appearing in the watch's public room
// browser (Trending / Search / BaseRooms all reach this query).
// Same filter applied below to SearchAsync and CreatedByAsync.
// HiddenFromBrowse keeps admin-utility rooms (MakerRoom,
// EventRoom) and rooms-folded-into-others (Paintball maps,
// LaserTag Hangar) out of every public discovery surface.
// /goto-by-name and admin tools still find them via GetByNameAsync.
IQueryable<RoomEntity> q = db.Rooms.Where(r => r.State == 0 && !r.IsDormRoom && !r.HiddenFromBrowse);
if (!string.IsNullOrWhiteSpace(tag))
{
var bareTag = tag.TrimStart('#').Trim();
if (bareTag.Length > 0)
{
var needle = $"%{bareTag}%";
q = q.Where(r => EF.Functions.Like(r.TagsCsv, needle));
}
}
return await q.OrderByDescending(r => r.HotScore).Take(take).ToListAsync();
}
/// <summary>
/// Free-text search — drives `api/rooms/v1/search`. Matches Name,
/// Description, and Tags substrings.
///
/// Also handles the watch's "rooms by player" convention: when the
/// watch's PlayerProfile screen opens its Rooms tab, it calls
/// <c>Rooms.SearchRooms("@" + accountId)</c> instead of a dedicated
/// per-player endpoint (verified via Cpp2IL — PlayerDetailsWatchUIFlow
/// formats `@{accountId}` then passes it straight to SearchRooms).
/// So a query of <c>@1</c> resolves to "every room Coach created"
/// and surfaces the seeded Rec Room Originals on Coach's profile.
/// </summary>
public async Task<List<RoomEntity>> SearchAsync(string query, int take = 50)
{
if (string.IsNullOrWhiteSpace(query))
return await HotAsync(null, take);
// @<token> — watch's PlayerProfile "Rooms" tab. The token can be
// either a numeric accountId (admin-SPA / API callers) OR a
// username (the 2020 watch's actual wire — it formats
// <c>"@" + Player.Username</c> at PlayerDetailsWatchUIFlow time
// and posts that straight to <c>rooms/v2/search</c>, so the
// value we receive is <c>@Alexa</c>, not <c>@1811750</c>).
// Resolve to the target accountId either way so Coach's seeded
// Rec Room Originals (CreatorPlayerId=1) show up on his
// profile's Rooms tab.
if (query.Length > 1 && query[0] == '@')
{
var suffix = query.AsSpan(1).ToString();
long? accountId = long.TryParse(suffix, out var id) ? id : null;
if (accountId is null)
{
// Case-insensitive username match — Rec Room treats
// @Alexa, @alexa, and @ALEXA as the same player.
var lower = suffix.ToLowerInvariant();
accountId = await db.Players
.Where(p => p.Username.ToLower() == lower)
.Select(p => (long?)p.Id)
.FirstOrDefaultAsync();
}
if (accountId is long aid)
{
return await db.Rooms
.Where(r => r.CreatorPlayerId == aid && !r.IsDormRoom && r.State == 0)
.OrderByDescending(r => r.UpdatedAt)
.Take(take)
.ToListAsync();
}
// Unknown username — return empty rather than fall through
// to substring search (which would match every room with
// "Alexa" in its name).
return new();
}
var needle = $"%{query}%";
return await db.Rooms
.Where(r => r.State == 0 && !r.IsDormRoom && !r.HiddenFromBrowse && (
EF.Functions.Like(r.Name, needle) ||
EF.Functions.Like(r.Description, needle) ||
EF.Functions.Like(r.TagsCsv, needle)))
.OrderByDescending(r => r.HotScore)
.Take(take)
.ToListAsync();
}
// Include the player's dorm row here. 2020.12's local rooms cache is
// populated from /api/rooms/v2/myrooms (which calls this); when the
// dorm isn't in the cache and matchmaking later returns roomId=116
// (DormRoom), OJMCBOKJFOF.NHBPIIGDAJP throws "No such room" and the
// dorm load is abandoned. Filtering out dorms here is an explicit
// 2020.03-era choice that no longer holds.
public Task<List<RoomEntity>> CreatedByAsync(long playerId) =>
db.Rooms.Where(r => r.CreatorPlayerId == playerId)
.OrderByDescending(r => r.UpdatedAt)
.ToListAsync();
public async Task<List<RoomEntity>> BookmarkedByAsync(long playerId)
{
var ids = await db.RoomBookmarks
.Where(b => b.PlayerId == playerId)
.Select(b => b.RoomId)
.ToListAsync();
return await db.Rooms
.Where(r => ids.Contains(r.Id))
.OrderByDescending(r => r.HotScore)
.ToListAsync();
}
public async Task BookmarkAsync(long playerId, long roomId)
{
var exists = await db.RoomBookmarks.AnyAsync(b => b.PlayerId == playerId && b.RoomId == roomId);
if (exists) return;
db.RoomBookmarks.Add(new RoomBookmarkEntity { PlayerId = playerId, RoomId = roomId });
await db.SaveChangesAsync();
}
public async Task UnbookmarkAsync(long playerId, long roomId)
{
var existing = await db.RoomBookmarks
.FirstOrDefaultAsync(b => b.PlayerId == playerId && b.RoomId == roomId);
if (existing is null) return;
db.RoomBookmarks.Remove(existing);
await db.SaveChangesAsync();
}
/// <summary>Every seeded RR-Original room id. Watch uses this to
/// flag "official" rooms in the browser.</summary>
public Task<List<long>> AgRoomIdsAsync() =>
db.Rooms.Where(r => r.IsAGRoom).Select(r => r.Id).ToListAsync();
/// <summary>Top-N AG rooms by HotScore — the watch's Featured
/// carousel.</summary>
public Task<List<long>> FeaturedAgRoomIdsAsync(int take) =>
db.Rooms
.Where(r => r.IsAGRoom)
.OrderByDescending(r => r.HotScore)
.Take(Math.Clamp(take, 1, 100))
.Select(r => r.Id)
.ToListAsync();
/// <summary>Idempotent room cheer. Returns the new count and
/// whether the caller had already cheered (true = no-op).</summary>
public async Task<(long NewCount, bool AlreadyCheered)> CheerRoomAsync(long playerId, long roomId, int type)
{
var existing = await db.Cheers.FirstOrDefaultAsync(c =>
c.FromPlayerId == playerId && c.TargetRoomId == roomId &&
c.TargetPlayerId == 0 && c.TargetPhotoId == 0 &&
c.TargetInventionId == 0 && c.Type == type);
if (existing is not null)
{
var cur = await db.Rooms.Where(r => r.Id == roomId)
.Select(r => r.CheerCount).FirstOrDefaultAsync();
return (cur, true);
}
db.Cheers.Add(new CheerEntity
{
FromPlayerId = playerId,
TargetRoomId = roomId,
Type = type,
});
await db.Rooms.Where(r => r.Id == roomId)
.ExecuteUpdateAsync(s => s
.SetProperty(r => r.CheerCount, r => r.CheerCount + 1)
.SetProperty(r => r.HotScore, r => r.HotScore + 10.0));
await db.SaveChangesAsync();
var newCount = await db.Rooms.Where(r => r.Id == roomId)
.Select(r => r.CheerCount).FirstOrDefaultAsync();
return (newCount, false);
}
/// <summary>Idempotent uncheer. Returns true when the player had
/// no prior cheer (no-op).</summary>
public async Task<bool> UncheerRoomAsync(long playerId, long roomId, int type)
{
var row = await db.Cheers.FirstOrDefaultAsync(c =>
c.FromPlayerId == playerId && c.TargetRoomId == roomId && c.Type == type);
if (row is null) return true;
db.Cheers.Remove(row);
await db.Rooms.Where(r => r.Id == roomId && r.CheerCount > 0)
.ExecuteUpdateAsync(s => s
.SetProperty(r => r.CheerCount, r => r.CheerCount - 1)
.SetProperty(r => r.HotScore, r => r.HotScore - 10.0));
await db.SaveChangesAsync();
return false;
}
/// <summary>Cheer count for a room + whether <paramref name="playerId"/>
/// has cheered it. <paramref name="playerId"/> = 0 returns
/// <c>iCheered = false</c> (anonymous reader).</summary>
public async Task<(long Count, bool ICheered)> GetCheerStateAsync(long playerId, long roomId)
{
var count = await db.Rooms.Where(r => r.Id == roomId)
.Select(r => r.CheerCount).FirstOrDefaultAsync();
var iCheered = playerId != 0 && await db.Cheers
.AnyAsync(c => c.FromPlayerId == playerId && c.TargetRoomId == roomId);
return (count, iCheered);
}
/// <summary>
/// Result of a CloneAsync call. Status codes match Rooms
/// .CreateModifyRoomStatus (TypeDefIndex 11848):
/// 0 = Success, 1 = Unknown, 4 = RoomDoesNotExist, 10 = DuplicateName,
/// 11 = ReservedName, 12 = InappropriateName.
/// On Success, Room is the new entity; otherwise null.
/// </summary>
public sealed record CloneResult(int Status, RoomEntity? Room);
/// <summary>
/// Clone an existing room into a new room owned by `playerId`. Copies
/// most of the source room's properties (scene location, capability
/// flags, accessibility) but resets visit/cheer counts and switches
/// IsAGRoom=true so the cloned room shows up in `#community` rather
/// than under Rec Room Originals.
///
/// Returns a CloneResult with the matching CreateModifyRoomStatus code
/// so the watch can show the right "name taken" / "no source" message.
/// </summary>
public async Task<CloneResult> CloneAsync(long sourceRoomId, string newName, long playerId)
{
if (string.IsNullOrWhiteSpace(newName))
return new CloneResult(12 /* InappropriateName */, null);
newName = newName.Trim();
var source = await db.Rooms.FirstOrDefaultAsync(r => r.Id == sourceRoomId);
if (source is null)
return new CloneResult(4 /* RoomDoesNotExist */, null);
// Status 10 (DuplicateName) — surfaces in the watch as a friendlier
// "name already taken" toast instead of a generic Unknown failure.
if (await db.Rooms.AnyAsync(r => r.Name == newName))
return new CloneResult(10 /* DuplicateName */, null);
// User-room ids start above 1000 to avoid colliding with the
// 100..1000 range reserved for seeded rooms.
var nextId = await db.Rooms
.Where(r => r.Id > 1000)
.MaxAsync(r => (long?)r.Id) ?? 1000L;
var clone = new RoomEntity
{
Id = nextId + 1,
Name = newName,
Description = source.Description,
CreatorPlayerId = playerId,
ImageName = "",
State = 0,
Accessibility = source.Accessibility,
SupportsLevelVoting = source.SupportsLevelVoting,
// User-cloned rooms are NOT AG-managed: the AG flag means
// "owned by the Against Gravity seed account, baked into
// resources.assets". A user's clone is their own
// community-content room — tag it accordingly so it
// shows up as Custom in admin and isn't shielded from
// hard-delete. (Previously this was =true; that misled
// both the admin badge AND the purge-refuses-canonical
// gate, which is why "RR ORIGINAL"-labelled junk rooms
// showed up that the admin couldn't purge.)
IsAGRoom = false,
IsDormRoom = false,
CloningAllowed = source.CloningAllowed,
SupportsVRLow = source.SupportsVRLow,
SupportsMobile = source.SupportsMobile,
SupportsScreens = source.SupportsScreens,
SupportsWalkVR = source.SupportsWalkVR,
SupportsTeleportVR = source.SupportsTeleportVR,
AllowsJuniors = source.AllowsJuniors,
RoomWarningMask = source.RoomWarningMask,
CustomRoomWarning = source.CustomRoomWarning,
DisableMicAutoMute = source.DisableMicAutoMute,
LocationReplicationId = source.LocationReplicationId,
TagsCsv = "community", // tagged community since user-built
CheerCount = 0,
FavoriteCount = 0,
VisitCount = 0,
HotScore = 0, // brand new — sorts to bottom of hot
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
};
db.Rooms.Add(clone);
await db.SaveChangesAsync();
return new CloneResult(0 /* Success */, clone);
}
/// <summary>
/// Project a RoomEntity to the wire shape Room.Deserialize at RVA
/// 0x114E430 expects. Required keys (PascalCase):
/// RoomId, Name, Description, CreatorPlayerId, ImageName, State,
/// Accessibility, SupportsLevelVoting, IsAGRoom, CloningAllowed,
/// SupportsScreens, SupportsWalkVR, SupportsTeleportVR,
/// AllowsJuniors, RoomWarningMask, CustomRoomWarning
/// Optional: IsDormRoom, SupportsVRLow, SupportsMobile, DisableMicAutoMute.
/// </summary>
public static object ToWireRoom(RoomEntity r) => new
{
RoomId = r.Id,
Name = r.Name,
Description = r.Description,
CreatorPlayerId = r.CreatorPlayerId,
CreatorAccountId = r.CreatorPlayerId,
ImageName = r.ImageName,
State = r.State,
Accessibility = r.Accessibility,
SupportsLevelVoting = r.SupportsLevelVoting,
IsAGRoom = r.IsAGRoom,
IsRRO = r.IsAGRoom,
// 2020.12 Room.Deserialize (KLCOGEIGEBJ.PPGFHEDFBEA, the Room base)
// reads required keys: RoomId, IsDorm, CloningAllowed, DisableMicAutoMute,
// DisableRoomComments, EncryptVoiceChat. The 2020.03 key was IsDormRoom;
// 2020.12 dropped "Room" from the name. Send both — extra keys are
// ignored by both clients.
IsDorm = r.IsDormRoom,
IsDormRoom = r.IsDormRoom,
CloningAllowed = r.CloningAllowed,
SupportsVRLow = r.SupportsVRLow,
SupportsMobile = r.SupportsMobile,
SupportsScreens = r.SupportsScreens,
SupportsWalkVR = r.SupportsWalkVR,
SupportsTeleportVR = r.SupportsTeleportVR,
AllowsJuniors = r.AllowsJuniors,
SupportsJuniors = r.AllowsJuniors,
SupportsQuest2 = true,
RoomWarningMask = r.RoomWarningMask,
WarningMask = r.RoomWarningMask,
CustomRoomWarning = r.CustomRoomWarning,
CustomWarning = r.CustomRoomWarning,
DisableMicAutoMute = r.DisableMicAutoMute,
// 2020.12 additional required keys in the base Room reader.
DisableRoomComments = false,
EncryptVoiceChat = false,
CreatedAt = (r.CreatedAt == default ? DateTime.UtcNow : r.CreatedAt)
.ToString("yyyy-MM-ddTHH:mm:ssZ"),
Stats = new
{
CheerCount = r.CheerCount,
FavoriteCount = r.FavoriteCount,
VisitorCount = r.VisitorCount,
VisitCount = r.VisitCount,
},
};
/// <summary>
/// Idempotent admin overrides applied AFTER SeedAsync. Keeps the seed
/// table itself untouched (so a clean DB build still seeds the
/// canonical RR-Originals shape) while letting us:
/// • Fold the standalone Paintball map rooms (River, Homestead,
/// Quarry, Clearcut, Spillway, Drive-In) into Paintball as
/// RoomSceneEntity rows + hide their old standalone rows.
/// • Fold LaserTagHangar into LaserTag as a second scene and hide
/// the standalone Hangar row.
/// • Hide MakerRoom + EventRoom from browse (admin tools / "create
/// room" template flow still resolves them by name).
/// Re-running on a DB that's already been overridden no-ops.
/// </summary>
public async Task ApplyCanonicalOverridesAsync(HttpClient http, string imagesDir)
{
// Hide admin-utility and folded-in rooms from browse.
// CyberJunkCity + LaserTagHangar are now subrooms under LaserTag;
// PerformanceHall + MakerRoom + EventRoom are admin-only.
var namesToHide = new[]
{
"MakerRoom", "EventRoom", "PerformanceHall",
"River", "Homestead", "Quarry", "Clearcut", "Spillway", "Drive-In",
"LaserTagHangar", "CyberJunkCity"
};
await db.Rooms
.Where(r => namesToHide.Contains(r.Name) && !r.HiddenFromBrowse)
.ExecuteUpdateAsync(s => s.SetProperty(r => r.HiddenFromBrowse, true));
// Download external thumbnails. Saved under stable file names so
// a second startup re-uses the local copy. ImageName is just the
// file name; ImgController serves it from data/images/. Bowling
// Alley pulls from the rec.net CDN id baked into room_images.json
// (no UI for that yet) since the user didn't pass a custom URL.
Directory.CreateDirectory(imagesDir);
await TryDownloadAsync(http, "https://img.rec.net/by3mjs9jbozpdvu6g9aje7jgz.png",
Path.Combine(imagesDir, "image_Crescendo.png"));
await TryDownloadAsync(http,
"https://static.wikia.nocookie.net/rec-room/images/4/48/Paintball_-_Key_Art.png/revision/latest/scale-to-width-down/1000?cb=20200513122350",
Path.Combine(imagesDir, "image_PaintballKeyArt.png"));
await TryDownloadAsync(http, "https://img.rec.net/8gwqibu0anm4j0rw54xo2y0yd.png",
Path.Combine(imagesDir, "image_BowlingAlley.png"));
await TryDownloadAsync(http, "https://img.rec.net/4o5lschc01nani8xeywao622n.png",
Path.Combine(imagesDir, "image_Park.png"));
var paintball = await db.Rooms.FirstOrDefaultAsync(r => r.Name == "Paintball");
if (paintball is not null && paintball.ImageName != "image_PaintballKeyArt.png")
paintball.ImageName = "image_PaintballKeyArt.png";
var bowling = await db.Rooms.FirstOrDefaultAsync(r => r.Name == "BowlingAlley");
if (bowling is not null && bowling.ImageName != "image_BowlingAlley.png")
bowling.ImageName = "image_BowlingAlley.png";
var park = await db.Rooms.FirstOrDefaultAsync(r => r.Name == "Park");
if (park is not null && park.ImageName != "image_Park.png")
park.ImageName = "image_Park.png";
// Paintball sub-room scenes — one RoomSceneEntity row per map.
// Each row carries the map's old standalone-room
// LocationReplicationId so /goto/room/Paintball/{map} resolves
// to the same Unity scene the standalone room used. OrderIndex
// is stable so wire SubRoomId values don't shuffle between
// server restarts.
if (paintball is not null)
{
var paintballScenes = new (string Name, string Location)[]
{
("River", "e122fe98-e7db-49e8-a1b1-105424b6e1f0"),
("Homestead", "a785267d-c579-42ea-be43-fec1992d1ca7"),
("Quarry", "ff4c6427-7079-4f59-b22a-69b089420827"),
("Clearcut", "380d18b5-de9c-49f3-80f7-f4a95c1de161"),
("Spillway", "58763055-2dfb-4814-80b8-16fac5c85709"),
("Drive-In", "65ddbb48-5a01-4e3e-972d-e5c7419e2bc3"),
};
await EnsureScenesAsync(paintball.Id, paintballScenes);
}
// LaserTag sub-rooms: the lobby (current LaserTag scene =
// CyberJunkCity arena) and the Hangar (was a standalone room).
var lasertag = await db.Rooms.FirstOrDefaultAsync(r => r.Name == "LaserTag");
if (lasertag is not null)
{
var lasertagScenes = new (string Name, string Location)[]
{
("CyberJunkCity", "9d6456ce-6264-48b4-808d-2d96b3d91038"),
("Hangar", "239e676c-f12f-489f-bf3a-d4c383d692c3"),
};
await EnsureScenesAsync(lasertag.Id, lasertagScenes);
}
await db.SaveChangesAsync();
}
/// <summary>Adds any missing scene rows for a parent room without
/// disturbing existing ones. Matches by (RoomId, Name) so a
/// re-run that finds the scene already there is a no-op.</summary>
private async Task EnsureScenesAsync(long roomId, (string Name, string Location)[] scenes)
{
var existing = await db.RoomScenes
.Where(s => s.RoomId == roomId)
.Select(s => new { s.Name, s.OrderIndex })
.ToListAsync();
var existingNames = existing.Select(e => e.Name).ToHashSet(StringComparer.OrdinalIgnoreCase);
var nextOrder = existing.Count == 0 ? 0 : existing.Max(e => e.OrderIndex) + 1;
foreach (var (name, location) in scenes)
{
if (existingNames.Contains(name)) continue;
db.RoomScenes.Add(new RoomSceneEntity
{
RoomId = roomId,
OrderIndex = nextOrder++,
Name = name,
RoomSceneLocationId = location,
DataBlobName = "",
MaxPlayers = 8,
IsSandbox = false,
CanMatchmakeInto = true,
DataModifiedAt = DateTime.UtcNow,
});
}
}
private static async Task TryDownloadAsync(HttpClient http, string url, string targetPath)
{
// Skip if already on disk — admins can delete the file to force
// a re-fetch on next startup.
if (File.Exists(targetPath) && new FileInfo(targetPath).Length > 0) return;
try
{
using var resp = await http.GetAsync(url);
if (!resp.IsSuccessStatusCode) return;
var bytes = await resp.Content.ReadAsByteArrayAsync();
if (bytes.Length == 0) return;
await File.WriteAllBytesAsync(targetPath, bytes);
}
catch
{
// Network blip on a fresh install just leaves the room with
// its existing placeholder image — admin can re-trigger by
// restarting once the URL's reachable.
}
}
}
@@ -0,0 +1,217 @@
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using Microsoft.EntityFrameworkCore;
using System.Text.Json;
namespace DorkNet.Server.Services;
/// <summary>
/// Single-row settings store. Same Postgres-backed pattern as
/// <see cref="CommunityBoardService"/>: every read is one SELECT,
/// every write is one UPDATE/INSERT, and the row is visible to every
/// replica immediately so admin toggles propagate without a cache
/// invalidation dance.
///
/// Scoped (not singleton) because it holds a DbContext reference. The
/// toggles it backs (signups, etc.) are checked on rare paths —
/// account creation hits this once per signup attempt, which is
/// dwarfed by everything else the request does.
/// </summary>
public class ServerSettingsService(DorkNetDbContext db)
{
private const int RowId = 1;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
};
public async Task<ServerSettingsEntity> GetAsync()
{
var row = await db.ServerSettings.AsNoTracking()
.FirstOrDefaultAsync(s => s.Id == RowId);
return row ?? new ServerSettingsEntity { Id = RowId };
}
public async Task<bool> AreSignupsDisabledAsync()
{
var row = await db.ServerSettings.AsNoTracking()
.FirstOrDefaultAsync(s => s.Id == RowId);
return row?.SignupsDisabled ?? false;
}
public async Task<ServerSettingsEntity> SetSignupsDisabledAsync(bool disabled)
{
var existing = await GetTrackedRowAsync();
existing.SignupsDisabled = disabled;
existing.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return existing;
}
public async Task<WeeklyChallengeSettings> GetWeeklyChallengesAsync()
{
var row = await GetAsync();
return ToWeeklySettings(row);
}
public async Task<WeeklyChallengeSettings> SetWeeklyChallengesAsync(
bool completedRequired,
IReadOnlyList<WeeklyChallengeTemplate> challenges,
WeeklyChallengeReward? reward)
{
var normalized = NormalizeWeeklyChallenges(challenges);
var normalizedReward = NormalizeWeeklyReward(reward);
var existing = await GetTrackedRowAsync();
existing.WeeklyChallengesCompletedRequired = completedRequired;
existing.WeeklyChallengesJson = JsonSerializer.Serialize(normalized, JsonOptions);
existing.WeeklyChallengeRewardJson = JsonSerializer.Serialize(normalizedReward, JsonOptions);
existing.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return ToWeeklySettings(existing);
}
private async Task<ServerSettingsEntity> GetTrackedRowAsync()
{
var existing = await db.ServerSettings.FirstOrDefaultAsync(s => s.Id == RowId);
if (existing is not null) return existing;
existing = new ServerSettingsEntity { Id = RowId, UpdatedAt = DateTime.UtcNow };
db.ServerSettings.Add(existing);
return existing;
}
private static WeeklyChallengeSettings ToWeeklySettings(ServerSettingsEntity row)
{
var challenges = DefaultWeeklyChallenges();
var reward = DefaultWeeklyReward();
if (!string.IsNullOrWhiteSpace(row.WeeklyChallengesJson))
{
try
{
var parsed = JsonSerializer.Deserialize<List<WeeklyChallengeTemplate>>(
row.WeeklyChallengesJson,
JsonOptions);
if (parsed?.Count > 0)
challenges = NormalizeWeeklyChallenges(parsed);
}
catch
{
challenges = DefaultWeeklyChallenges();
}
}
if (!string.IsNullOrWhiteSpace(row.WeeklyChallengeRewardJson))
{
try
{
var parsed = JsonSerializer.Deserialize<WeeklyChallengeReward>(
row.WeeklyChallengeRewardJson,
JsonOptions);
reward = NormalizeWeeklyReward(parsed);
}
catch
{
reward = DefaultWeeklyReward();
}
}
return new WeeklyChallengeSettings(
row.WeeklyChallengesCompletedRequired,
challenges,
reward,
row.UpdatedAt);
}
private static List<WeeklyChallengeTemplate> NormalizeWeeklyChallenges(
IReadOnlyList<WeeklyChallengeTemplate> challenges)
{
var normalized = challenges
.Where(c => !string.IsNullOrWhiteSpace(c.Name))
.Take(10)
.Select((c, i) => new WeeklyChallengeTemplate(
i,
c.Name.Trim(),
string.IsNullOrWhiteSpace(c.Config) ? "{}" : c.Config.Trim(),
c.Description?.Trim() ?? string.Empty,
c.Tooltip?.Trim() ?? string.Empty))
.ToList();
return normalized.Count > 0 ? normalized : DefaultWeeklyChallenges();
}
private static WeeklyChallengeReward NormalizeWeeklyReward(WeeklyChallengeReward? reward)
{
reward ??= DefaultWeeklyReward();
return new WeeklyChallengeReward(
GiftDropId: Math.Max(0, reward.GiftDropId),
Xp: Math.Max(0, reward.Xp),
Tokens: Math.Max(0, reward.Tokens),
Level: Math.Max(0, reward.Level),
GiftContext: Math.Max(0, reward.GiftContext),
GiftRarity: Math.Max(0, reward.GiftRarity),
AvatarItemDesc: reward.AvatarItemDesc?.Trim() ?? string.Empty,
ConsumableItemDesc: reward.ConsumableItemDesc?.Trim() ?? string.Empty,
EquipmentPrefabName: reward.EquipmentPrefabName?.Trim() ?? string.Empty,
EquipmentModificationGuid: reward.EquipmentModificationGuid?.Trim() ?? string.Empty);
}
public static List<WeeklyChallengeTemplate> DefaultWeeklyChallenges() =>
[
new(
Index: 0,
Name: "Play 3 Rec Room Originals",
Config: "{\"Type\":\"CompleteActivity\",\"Goal\":3}",
Description: "Complete three Rec Room Original activities.",
Tooltip: "Play any RRO activity to make progress."),
new(
Index: 1,
Name: "Cheer 5 Players",
Config: "{\"Type\":\"CheerPlayers\",\"Goal\":5}",
Description: "Send cheers to five players.",
Tooltip: "Use the watch to cheer players you meet."),
new(
Index: 2,
Name: "Visit 5 Rooms",
Config: "{\"Type\":\"VisitRooms\",\"Goal\":5}",
Description: "Visit five rooms this week.",
Tooltip: "Any room visit counts toward this challenge."),
];
public static WeeklyChallengeReward DefaultWeeklyReward() =>
new(
GiftDropId: 0,
Xp: 250,
Tokens: 250,
Level: 1,
GiftContext: 0,
GiftRarity: 0,
AvatarItemDesc: string.Empty,
ConsumableItemDesc: string.Empty,
EquipmentPrefabName: string.Empty,
EquipmentModificationGuid: string.Empty);
}
public sealed record WeeklyChallengeSettings(
bool CompletedRequired,
IReadOnlyList<WeeklyChallengeTemplate> Challenges,
WeeklyChallengeReward Reward,
DateTime UpdatedAt);
public sealed record WeeklyChallengeTemplate(
int Index,
string Name,
string Config,
string Description,
string Tooltip);
public sealed record WeeklyChallengeReward(
int GiftDropId,
int Xp,
int Tokens,
int Level,
int GiftContext,
int GiftRarity,
string AvatarItemDesc,
string ConsumableItemDesc,
string EquipmentPrefabName,
string EquipmentModificationGuid);
+1108
View File
@@ -0,0 +1,1108 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using DorkNet.Server.Controllers.API.Avatar.V4;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Services;
/// <summary>
/// Owns the in-game store catalog, the storefronts API shaping, and
/// the purchase flow (verify balance → deduct currency → grant item to
/// inventory). The watch's "Shop" tab fetches this via
/// <c>GET api/storefronts/v3/all</c> and friends; the purchase flow
/// runs through <c>POST api/purchase/v1/{initiate|process|complete}</c>.
///
/// Currency: items are priced in tokens (CurrencyType=2), the same
/// currency the level-up + cheer-receive flow grants. Real-money
/// purchases (CurrencyType=1) are intentionally not wired up — this is
/// a private server.
/// </summary>
public class StoreService(DorkNetDbContext db, LevelService level, IConfiguration config)
{
public const long StoreItemPrice = 1;
public const string AllStorefrontKey = "all";
public const string RroStorefrontKey = "rro";
private const int StoreAvatarItemTypeOutfit = 0;
private const int StoreAvatarItemTypeHairDye = 1;
private const int GiftBoxContentHairDye = 6;
private const string WardrobePrefix = "wardrobe-";
public const string EquipmentSkinPrefix = "equipment-skin:";
public sealed record StorefrontDefinition(
string Key,
int? StorefrontType,
string DisplayName,
string Scope);
private static readonly StorefrontDefinition[] StorefrontDefinitions =
{
new("main", null, "Main watch catalog", "watch"),
new("watch", 3, "Watch gift-drop shelf", "watch"),
new(AllStorefrontKey, null, "All storefront shelves", "shared"),
new(RroStorefrontKey, null, "All RRO and Rec Center shelves", "shared"),
new("season:1", null, "Season 1", "season"),
new("giftdrop:1", 1, "Laser Tag", "room"),
new("giftdrop:2", 2, "Rec Center", "room"),
new("giftdrop:100", 100, "Quest - Lost Skulls", "room"),
new("giftdrop:101", 101, "Quest - Dracula", "room"),
new("giftdrop:102", 102, "Quest - Golden Trophy", "room"),
new("giftdrop:103", 103, "Quest - Crimson Cauldron", "room"),
new("giftdrop:200", 200, "Rec Royale", "room"),
new("giftdrop:300", 300, "Cafe", "room"),
new("giftdrop:400", 400, "Paintball", "room"),
new("giftdrop:401", 401, "Paintball - River", "room"),
new("giftdrop:402", 402, "Paintball - Homestead", "room"),
new("giftdrop:403", 403, "Paintball - Quarry", "room"),
new("giftdrop:404", 404, "Paintball - Clear Cut", "room"),
new("giftdrop:405", 405, "Paintball - Spillway", "room"),
new("giftdrop:406", 406, "Paintball - Sunset Drive-In", "room"),
new("giftdrop:500", 500, "Bowling", "room"),
new("giftdrop:600", 600, "Stunt Runner", "room"),
new("giftdrop:700", 700, "Dorm Mirror", "room"),
};
public static IReadOnlyList<StorefrontDefinition> GetStorefrontDefinitions() =>
StorefrontDefinitions;
public static string StorefrontKeyForType(int storefrontType) =>
storefrontType == 3 ? "watch" : $"giftdrop:{storefrontType}";
private static readonly (string Slug, string Name, string ConsumableDesc, string HairColorGuid)[] PermanentHairDyes =
{
("hairdye-red", "Permanent KREQ Red Hair Dye", "[hairdyepotionconsumable_red]", "xyHGGFKNWEOTnXloDFahPQ"),
("hairdye-dark-red", "Permanent Cauldron Crimson Hair Dye", "[hairdyepotionconsumable_darkred]", "XCwNFMT1i0i9Q9gJYTLPUQ"),
("hairdye-orange", "Permanent Bucket Orange Hair Dye", "[hairdyepotionconsumable_orange]", "1I8AD333gku-iOQlS6WWlw"),
("hairdye-yellow", "Permanent Pirate Gold Hair Dye", "[hairdyepotionconsumable_yellow]", "pQNfh-3DsEGWfiIls6Qf6g"),
("hairdye-green", "Permanent Goblin Green Hair Dye", "[hairdyepotionconsumable_green]", "TylV8M6zjUaadhxHPWAS-w"),
("hairdye-teal", "Permanent EDM Emerald Hair Dye", "[hairdyepotionconsumable_teal]", "wXJZbYCkZE-NMbPVf2YuFA"),
("hairdye-cyan", "Permanent H2Oasis Blue Hair Dye", "[hairdyepotionconsumable_cyan]", "Bjsgo9ddeUCk3zW1-DdJbw"),
("hairdye-blue", "Permanent Butterfly Blue Hair Dye", "[hairdyepotionconsumable_blue]", "yTdx1nVVtECU8Vh7EC8dGA"),
("hairdye-dark-blue", "Permanent Ranger Roy-Al Blue Hair Dye", "[hairdyepotionconsumable_darkblue]", "3ksu_FLaukeJ-YE90jb4Ow"),
("hairdye-purple", "Permanent Propulsion Purple Hair Dye", "[hairdyepotionconsumable_purple]", "53y1prYATU2drZDS-jpaqg"),
("hairdye-dark-purple", "Permanent Gizmo Grape Hair Dye", "[hairdyepotionconsumable_darkpurple]", "QmgjYhH_L0q58eF5SWeDhQ"),
("hairdye-pink", "Permanent Mousebot Magenta Hair Dye", "[hairdyepotionconsumable_pink]", "8k_gZA_lrEyJxI19GrcAZg"),
("hairdye-dark-pink", "Permanent Recreational Raspberry Hair Dye", "[hairdyepotionconsumable_darkpink]", "yejur5BbR0mevbH-rzgXYA"),
("hairdye-pastel-red", "Permanent Rebel Rose Hair Dye", "[hairdyepotionconsumable_pastelred]", "aziIarpSREmcT6vihduPwA"),
("hairdye-pastel-orange", "Permanent PVPeach Hair Dye", "[hairdyepotionconsumable_pastelorange]", "0RUmjv2bL0ydbKU61dlqlg"),
("hairdye-pastel-yellow", "Permanent Standee Sandy Hair Dye", "[hairdyepotionconsumable_pastelyellow]", "H65ddsxAY0m2sccMrF48Aw"),
("hairdye-pastel-green", "Permanent Frontier Forest Hair Dye", "[hairdyepotionconsumable_pastelgreen]", "LBz3a9520kiOOMMOwsTExg"),
("hairdye-pastel-teal", "Permanent Jungle Jade Hair Dye", "[hairdyepotionconsumable_pastelteal]", "Sc8S3QiTIUuXNJZD6geY1Q"),
("hairdye-pastel-cyan", "Permanent Circuit Cerulean Hair Dye", "[hairdyepotionconsumable_pastelcyan]", "W5E2gDCKY0SXaKytP89-OA"),
("hairdye-pastel-blue", "Permanent Paula's Periwinkle Hair Dye", "[hairdyepotionconsumable_pastelblue]", "_2QGtfHzuUSq0eyI84rImw"),
("hairdye-pastel-purple", "Permanent Lounge Lavender Hair Dye", "[hairdyepotionconsumable_pastelpurple]", "qp_vRXZQPU2rlx_IU1Vr8Q"),
("hairdye-pastel-pink", "Permanent Maker Mauve Hair Dye", "[hairdyepotionconsumable_pastelpink]", "G129V_kVXkSLN2OG8EjSqQ"),
};
private bool EnableWatchGiftDrops =>
config.GetValue("Store:EnableWatchGiftDrops", true);
private int MaxWatchGiftDrops =>
// 0 means no cap. Keep the switch configurable in case a
// deployment needs to temporarily reduce the shelf size while
// debugging a bad catalog row.
config.GetValue("Store:MaxWatchGiftDrops", 0);
/// <summary>Idempotent seed of a starter catalog so the store is
/// non-empty on first boot. Only inserts items whose Slug isn't
/// already in the DB; safe to call repeatedly.</summary>
public async Task SeedAsync()
{
var defaults = new (string Slug, string Name, string Desc, string Category, long Price, string Image, string Storefront)[]
{
// Hats / heads
("dorknet-cap-classic", "Classic Cap", "A simple cap. Comfortable, unremarkable.", "head", 150, "store_classic_cap.png", "main"),
("dorknet-bowler-hat", "Bowler Hat", "Tipped at a jaunty angle. Goes with everything.", "head", 400, "store_bowler_hat.png", "main"),
("dorknet-witch-hat", "Witch Hat", "Pointy. Definitely magical.", "head", 650, "store_witch_hat.png", "main"),
// Torso
("dorknet-tee-server", "Server Crew Tee", "Sworn to defend the JWT secret.", "torso", 300, "store_tee_server.png", "main"),
("dorknet-hoodie-cyber", "Cyber Hoodie", "Glowing accents. Optimised for late-night coding.", "torso", 900, "store_hoodie_cyber.png","main"),
("dorknet-jacket-bomber", "Bomber Jacket", "Sleek, bomber-style — for laser tag and lurking.", "torso", 850, "store_jacket_bomber.png","main"),
// Legs
("dorknet-jeans-classic", "Blue Jeans", "Standard issue. Goes with anything.", "legs", 200, "store_jeans.png", "main"),
("dorknet-cargo-pants", "Cargo Pants", "Lots of pockets — for inventions, snacks, secrets.", "legs", 450, "store_cargo_pants.png", "main"),
// Feet
("dorknet-sneakers", "Sneakers", "Soft soles. Quiet on the dorm floor.", "feet", 250, "store_sneakers.png", "main"),
("dorknet-boots-combat", "Combat Boots", "Polished. Heavy. Great for stomping.", "feet", 550, "store_boots_combat.png","main"),
// Accessories
("dorknet-shades", "Sunglasses", "Maximum chill. Slightly inappropriate indoors.", "accessory", 300, "store_shades.png", "main"),
("dorknet-watch-rgb", "RGB Smartwatch", "Glowing wrist accessory. Cycles through every colour.", "accessory", 700, "store_watch_rgb.png", "main"),
("dorknet-cape-velvet", "Velvet Cape", "Floor-length. Suitable for entering a room dramatically.", "accessory", 1200, "store_cape_velvet.png", "main"),
// Hair
("dorknet-hair-mohawk", "Mohawk", "Sharp. Loud. Possibly aerodynamic.", "hair", 800, "store_hair_mohawk.png", "main"),
("dorknet-hair-flowing", "Flowing Locks", "Long. Glossy. Definitely waving in some imaginary breeze.", "hair", 650, "store_hair_flowing.png","main"),
// Face
("dorknet-face-grin", "Permanent Grin", "A confident, slightly unsettling smile.", "face", 350, "store_face_grin.png", "main"),
("dorknet-face-monocle", "Monocle & Mustache", "Distinguished. Possibly inherited.", "face", 550, "store_face_monocle.png","main"),
// Consumables
("dorknet-firework", "Firework", "One-shot launch. Spectacular. Not edible.", "consumable", 50, "store_firework.png", "main"),
("dorknet-pizza-slice", "Pizza Slice", "Looks great on the watch. Also restores 5 imaginary HP.", "consumable", 25, "store_pizza.png", "main"),
// Room templates
("dorknet-template-cozy", "Cozy Cabin Template", "A wood-cabin starter for new rooms — fireplace included.", "roomtemplate", 1500, "store_template_cozy.png", "main"),
// Limited-time items in the gift drop storefront
("dorknet-gift-mystery1", "Mystery Box (Tier 1)","Contains a randomised cosmetic. Open with caution.", "consumable", 100, "store_mystery_t1.png", "giftdrop:1"),
("dorknet-gift-mystery2", "Mystery Box (Tier 2)","A heftier mystery box. More chance of rare drops.", "consumable", 500, "store_mystery_t2.png", "giftdrop:1"),
("dorknet-gift-aura", "Golden Aura", "Limited-time aura cosmetic — radiates while you're nearby.","accessory", 2500, "store_gift_aura.png", "giftdrop:2"),
};
var existing = await db.StoreItems
.Select(i => i.Slug)
.ToListAsync();
var existingSet = new HashSet<string>(existing, StringComparer.OrdinalIgnoreCase);
// Drop stale wardrobe-* rows whose embedded GUID isn't in the
// current avatar_item_lookup.json safelist. The previous seed
// pulled GUIDs from outfits_assets_*.bundle MonoBehaviours
// (mask/swatch ASSET GUIDs, not AvatarItem GUIDs); those tiles
// can't render their 3D preview because the watch's
// avatarItemPrefabLookup doesn't have those keys. Removing
// them prevents broken tiles from cluttering the store.
var safe = DorkNet.Server.Controllers.API.Avatar.V4.AvatarItemsController.SafeGuids;
if (safe.Count > 0)
{
// Skip wardrobe-colored-* — those resolve through the color
// variant catalog, not the safe-GUID set, and the substring
// after "wardrobe-" is never a bare GUID anyway.
var stale = await db.StoreItems
.Where(i => i.Slug.StartsWith("wardrobe-")
&& !i.Slug.StartsWith(WardrobeColoredPrefix))
.Select(i => new { i.Id, i.Slug })
.ToListAsync();
var toDelete = new List<long>();
foreach (var row in stale)
{
var guid = row.Slug.Substring("wardrobe-".Length);
if (!safe.Contains(guid)) toDelete.Add(row.Id);
}
if (toDelete.Count > 0)
{
await db.StoreItems
.Where(i => toDelete.Contains(i.Id))
.ExecuteDeleteAsync();
// Re-pull the existing-slug set so the new seed below
// re-inserts with the correct GUIDs.
existing = await db.StoreItems.Select(i => i.Slug).ToListAsync();
existingSet = new HashSet<string>(existing, StringComparer.OrdinalIgnoreCase);
}
}
// Drop orphan wardrobe-colored-* rows whose slug isn't in the
// current color-variant catalog. When we remove an entry from
// data/color_variants.json (e.g. because the asset's swatch
// colors look wrong in-game), the corresponding StoreItems row
// would otherwise linger as a broken tile.
if (_variantCatalog.Value.Count > 0)
{
var validColored = new HashSet<string>(_variantCatalog.Value.Keys,
StringComparer.OrdinalIgnoreCase);
var orphanColored = await db.StoreItems
.Where(i => i.Slug.StartsWith(WardrobeColoredPrefix))
.Select(i => new { i.Id, i.Slug })
.ToListAsync();
var coloredToDelete = orphanColored
.Where(r => !validColored.Contains(r.Slug))
.Select(r => r.Id)
.ToList();
if (coloredToDelete.Count > 0)
{
await db.StoreItems
.Where(i => coloredToDelete.Contains(i.Id))
.ExecuteDeleteAsync();
existing = await db.StoreItems.Select(i => i.Slug).ToListAsync();
existingSet = new HashSet<string>(existing, StringComparer.OrdinalIgnoreCase);
}
}
var added = false;
foreach (var entry in defaults)
{
if (existingSet.Contains(entry.Slug)) continue;
db.StoreItems.Add(new StoreItemEntity
{
Slug = entry.Slug,
DisplayName = entry.Name,
Description = entry.Desc,
Category = entry.Category,
Price = StoreItemPrice,
CurrencyType = 2,
ImageName = entry.Image,
Storefront = entry.Storefront,
IsActive = true,
IsLimitedTime = entry.Storefront.StartsWith("giftdrop:"),
});
added = true;
}
if (added) await db.SaveChangesAsync();
await SeedGameCatalogAdditionsAsync(existingSet);
await SeedEquipmentSkinsAsync(existingSet);
await SeedClothingFromGameAsync(existingSet);
await SeedColorVariantsAsync(existingSet);
await NormalizeStorePricesAsync();
}
private async Task SeedGameCatalogAdditionsAsync(HashSet<string> existingSlugs)
{
var added = false;
foreach (var dye in PermanentHairDyes)
{
if (existingSlugs.Contains(dye.Slug)) continue;
db.StoreItems.Add(new StoreItemEntity
{
Slug = dye.Slug,
DisplayName = dye.Name,
Description = "Permanent hair dye.",
Category = "consumable",
Price = StoreItemPrice,
CurrencyType = 2,
ImageName = string.Empty,
Storefront = "main",
IsActive = true,
IsLimitedTime = false,
});
existingSlugs.Add(dye.Slug);
added = true;
}
if (added) await db.SaveChangesAsync();
}
// ── Equipment skins ────────────────────────────────────────────
// Source: data/equipment_skins.json. The old client does not infer
// equipment reward payloads from a store slug; it expects the exact
// PrefabName + ModificationGuid pair from the equipment skin runtime
// config. Keep those values data-driven so new verified skin rows can
// be added without changing controller code.
public sealed class EquipmentSkinEntry
{
public string prefab_name { get; set; } = string.Empty;
public string modification_guid { get; set; } = string.Empty;
public string display_name { get; set; } = string.Empty;
public string tooltip { get; set; } = string.Empty;
public int rarity { get; set; }
public string storefront { get; set; } = "main";
public long price { get; set; } = StoreItemPrice;
public string image_name { get; set; } = string.Empty;
}
private sealed class EquipmentSkinFile
{
public List<EquipmentSkinEntry> items { get; set; } = new();
}
private static readonly Lazy<Dictionary<string, EquipmentSkinEntry>> _equipmentSkinCatalog =
new(LoadEquipmentSkinCatalog);
public static IReadOnlyDictionary<string, EquipmentSkinEntry> EquipmentSkinCatalog =>
_equipmentSkinCatalog.Value;
private static Dictionary<string, EquipmentSkinEntry> LoadEquipmentSkinCatalog()
{
var candidates = new[]
{
Path.Combine(AppContext.BaseDirectory, "data", "equipment_skins.json"),
Path.Combine(AppContext.BaseDirectory, "Data", "equipment_skins.json"),
Path.Combine(Directory.GetCurrentDirectory(), "data", "equipment_skins.json"),
Path.Combine(Directory.GetCurrentDirectory(), "DorkNet.Server", "Data", "equipment_skins.json"),
};
var path = candidates.FirstOrDefault(File.Exists);
if (path is null) return new();
try
{
using var fs = File.OpenRead(path);
var data = System.Text.Json.JsonSerializer.Deserialize<EquipmentSkinFile>(fs,
new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true })
?? new EquipmentSkinFile();
var bySlug = new Dictionary<string, EquipmentSkinEntry>(StringComparer.OrdinalIgnoreCase);
foreach (var item in data.items)
{
if (string.IsNullOrWhiteSpace(item.prefab_name) ||
string.IsNullOrWhiteSpace(item.modification_guid))
continue;
bySlug[EquipmentSkinSlug(item.prefab_name, item.modification_guid)] = item;
}
return bySlug;
}
catch
{
return new();
}
}
public static string EquipmentSkinSlug(string prefabName, string modificationGuid)
{
prefabName = (prefabName ?? string.Empty).Trim();
modificationGuid = (modificationGuid ?? string.Empty).Trim();
return $"{EquipmentSkinPrefix}{prefabName}:{modificationGuid}";
}
public static bool TryGetEquipmentPayload(
string? slug,
out string prefabName,
out string modificationGuid,
out EquipmentSkinEntry? entry)
{
prefabName = string.Empty;
modificationGuid = string.Empty;
entry = null;
if (string.IsNullOrWhiteSpace(slug)) return false;
if (_equipmentSkinCatalog.Value.TryGetValue(slug, out entry))
{
prefabName = entry.prefab_name;
modificationGuid = entry.modification_guid;
return true;
}
if (!slug.StartsWith(EquipmentSkinPrefix, StringComparison.OrdinalIgnoreCase))
return false;
var payload = slug[EquipmentSkinPrefix.Length..];
var split = payload.IndexOf(':');
if (split <= 0 || split >= payload.Length - 1) return false;
prefabName = payload[..split];
modificationGuid = payload[(split + 1)..];
return !string.IsNullOrWhiteSpace(prefabName)
&& !string.IsNullOrWhiteSpace(modificationGuid);
}
public static bool TryGetEquipmentPayload(
string? slug,
out string prefabName,
out string modificationGuid) =>
TryGetEquipmentPayload(slug, out prefabName, out modificationGuid, out _);
private async Task SeedEquipmentSkinsAsync(HashSet<string> existingSlugs)
{
var catalog = _equipmentSkinCatalog.Value;
if (catalog.Count == 0) return;
var added = false;
foreach (var (slug, entry) in catalog)
{
if (existingSlugs.Contains(slug)) continue;
var storefront = string.IsNullOrWhiteSpace(entry.storefront) ? "main" : entry.storefront;
db.StoreItems.Add(new StoreItemEntity
{
Slug = slug,
DisplayName = string.IsNullOrWhiteSpace(entry.display_name)
? $"{entry.prefab_name} skin"
: entry.display_name,
Description = string.IsNullOrWhiteSpace(entry.tooltip)
? "Equipment skin."
: entry.tooltip,
Category = "weapon",
Price = entry.price > 0 ? entry.price : StoreItemPrice,
CurrencyType = 2,
ImageName = entry.image_name,
Storefront = storefront,
IsActive = true,
IsLimitedTime = storefront.StartsWith("giftdrop:", StringComparison.OrdinalIgnoreCase),
});
existingSlugs.Add(slug);
added = true;
}
if (added) await db.SaveChangesAsync();
}
// ── Color variants ──────────────────────────────────────────────
// Source: data/color_variants.json — generated from a static walk
// of the 2020 game's outfits_assets_*.bundle files (Python script
// at tools/build-color-variants.py). Each entry maps a swatch
// (with item GUID + mask GUID) to a color name. We seed one
// StoreItem per (item, swatch) with slug
// wardrobe-colored-{itemGuid}-{colorLower}
// so TryGetAvatarItemPayload can recover the desc on demand.
//
// The pure wardrobe-{itemGuid} slugs (no color) keep working for
// items without color variants, and for legacy single-color SKUs
// — those still emit "<itemGuid>,,," and the watch picks a default.
public const string WardrobeColoredPrefix = "wardrobe-colored-";
public sealed class ColorVariantEntry
{
public string swatch_name { get; set; } = string.Empty;
public string swatch_guid { get; set; } = string.Empty;
public string color { get; set; } = string.Empty;
public string item_name { get; set; } = string.Empty;
public string item_guid { get; set; } = string.Empty;
public int? item_outfit_type { get; set; }
public string mask_guid { get; set; } = string.Empty;
}
private sealed class ColorVariantFile
{
public List<ColorVariantEntry> matches { get; set; } = new();
}
private static readonly Lazy<Dictionary<string, ColorVariantEntry>> _variantCatalog =
new(LoadColorVariantCatalog);
private static Dictionary<string, ColorVariantEntry> LoadColorVariantCatalog()
{
var candidates = new[]
{
Path.Combine(AppContext.BaseDirectory, "data", "color_variants.json"),
Path.Combine(AppContext.BaseDirectory, "Data", "color_variants.json"),
Path.Combine(Directory.GetCurrentDirectory(), "data", "color_variants.json"),
};
var path = candidates.FirstOrDefault(File.Exists);
if (path is null) return new();
try
{
using var fs = File.OpenRead(path);
var data = System.Text.Json.JsonSerializer.Deserialize<ColorVariantFile>(fs,
new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true })
?? new ColorVariantFile();
var byKey = new Dictionary<string, ColorVariantEntry>(StringComparer.OrdinalIgnoreCase);
foreach (var m in data.matches)
{
if (string.IsNullOrEmpty(m.item_guid) || string.IsNullOrEmpty(m.swatch_guid)) continue;
var key = $"{WardrobeColoredPrefix}{m.item_guid}-{m.color}".ToLowerInvariant();
byKey[key] = m;
}
return byKey;
}
catch { return new(); }
}
public static IReadOnlyDictionary<string, ColorVariantEntry> ColorVariantCatalog => _variantCatalog.Value;
/// <summary>Item GUIDs that have at least one color-variant SKU. The
/// generic <c>wardrobe-{guid}</c> tile is suppressed for these
/// because the colored tiles render the correct swatch; the bare
/// tile would emit <c>{guid},,,</c> and fall back to the default
/// swatch (often a placeholder colour the player didn't pick).
/// </summary>
private static readonly Lazy<HashSet<string>> _itemsWithColorVariants =
new(() => new HashSet<string>(
_variantCatalog.Value.Values.Select(e => e.item_guid),
StringComparer.OrdinalIgnoreCase));
private async Task SeedColorVariantsAsync(HashSet<string> existingSlugs)
{
var catalog = _variantCatalog.Value;
if (catalog.Count == 0) return;
var added = false;
foreach (var (slug, entry) in catalog)
{
if (existingSlugs.Contains(slug)) continue;
// Friendlier display name: strip parens + slot prefix from
// the item name, then suffix the color. So
// "(Shirt_Lumberjack)" + "Blue" → "Lumberjack — Blue".
var inner = (entry.item_name ?? string.Empty).Trim('(', ')');
foreach (var s in new[] { "Hat_", "Shirt_", "Head_", "Hair_", "Eye_", "Wrist_",
"Neck_", "Beard_", "Pants_", "Feet_", "Accessory_",
"Belt_", "Hand_" })
{
if (inner.StartsWith(s)) { inner = inner[s.Length..]; break; }
}
inner = inner.Replace('_', ' ').Trim();
var displayName = $"{inner} — {entry.color}";
db.StoreItems.Add(new StoreItemEntity
{
Slug = slug,
DisplayName = displayName,
Description = $"{entry.color} variant of {inner}.",
Category = ColorVariantCategory(entry.item_outfit_type),
Price = StoreItemPrice,
CurrencyType = 2,
ImageName = string.Empty,
Storefront = "main",
IsActive = true,
IsLimitedTime = false,
});
existingSlugs.Add(slug);
added = true;
}
if (added) await db.SaveChangesAsync();
}
/// <summary>Map outfit_type (per <c>avatar_item_lookup.json</c>) to the
/// store Category column. Outfit types: 0=Hat, 2=Hair, 10=Eye,
/// 20=Beard, 100=Neck, 101=Torso, 200=Wrist (others fall through).</summary>
private static string ColorVariantCategory(int? outfitType) => outfitType switch
{
0 => "head",
2 => "hair",
10 or 20 => "head",
100 or 101 => "torso",
200 => "accessory",
_ => "accessory",
};
private async Task NormalizeStorePricesAsync()
{
await db.StoreItems
.Where(i => i.Price != StoreItemPrice)
.ExecuteUpdateAsync(s => s.SetProperty(i => i.Price, StoreItemPrice));
}
/// <summary>
/// Seed the storefront from
/// <see cref="DorkNet.Server.Controllers.API.Avatar.V4.AvatarItemsController.Catalog"/>
/// — every AvatarItem GUID in the watch's bundled
/// <c>AvatarItemWardrobeRuntimeConfig.avatarItemDataLookup</c>
/// becomes one tile. Slug format <c>wardrobe-{avatarItemGuid}</c>
/// so the watch's `PurchasableGiftDrop` flow can extract the GUID
/// from the slug at <see cref="BuildPurchasableGiftDrop"/> time
/// and put it in <c>AvatarItemDesc</c> for the watch to render
/// the 3D preview prefab in the tile.
///
/// The previous implementation pulled from
/// <c>data/wardrobe_items.json</c> (Swatch/Mask MonoBehaviour
/// GUIDs extracted from <c>outfits_assets_*.bundle</c>). Those
/// were ASSET GUIDs, not AvatarItem GUIDs, so the watch couldn't
/// resolve them in <c>avatarItemPrefabLookup</c> → tiles came
/// out blank. The new source uses the same dump
/// <c>AvatarItemsController</c> validates against, so every
/// store tile is guaranteed to render.
/// </summary>
private async Task SeedClothingFromGameAsync(HashSet<string> existingSlugs)
{
var catalog = DorkNet.Server.Controllers.API.Avatar.V4.AvatarItemsController.Catalog;
if (catalog.Count == 0) return;
// Backfill prices on existing wardrobe rows that were seeded
// with the previous Price=0 default — store UI shows "0
// tokens" on every tile otherwise. Idempotent: only updates
// rows whose price is still 0.
var stalePriceRows = await db.StoreItems
.Where(i => i.Slug.StartsWith("wardrobe-") && i.Price == 0)
.Select(i => new { i.Id, i.Slug })
.ToListAsync();
if (stalePriceRows.Count > 0)
{
foreach (var r in stalePriceRows)
{
var guid = r.Slug.Substring("wardrobe-".Length);
await db.StoreItems
.Where(s => s.Id == r.Id)
.ExecuteUpdateAsync(s => s.SetProperty(p => p.Price, StoreItemPrice));
}
}
var added = false;
foreach (var (guid, dto) in catalog)
{
var slug = $"wardrobe-{guid}";
if (existingSlugs.Contains(slug)) continue;
existingSlugs.Add(slug);
db.StoreItems.Add(new StoreItemEntity
{
Slug = slug,
DisplayName = string.IsNullOrEmpty(dto.FriendlyName) ? guid : dto.FriendlyName,
Description = "Wardrobe item.",
// Category just routes the tile to a tab in the Shop
// UI; we map AvatarItemsController.Slot codes to
// human-readable category labels.
Category = CategoryFromFriendlyName(dto.FriendlyName),
Price = StoreItemPrice,
CurrencyType = 2,
ImageName = string.Empty,
Storefront = "main",
IsActive = true,
IsLimitedTime = false,
});
added = true;
}
if (added) await db.SaveChangesAsync();
}
private static int SumCharCodes(string s)
{
int sum = 0;
for (int i = 0; i < s.Length; i++) sum = unchecked(sum * 31 + s[i]);
return sum;
}
private static string CategoryFromFriendlyName(string name)
{
// Names from the bundled AvatarItemData arrive as e.g. "Hat
// Wizard", "Hair AngledBob", "Beard Close", "Eye 2020Glasses",
// "Belt PaintballBelt" — the FIRST word is the slot label.
// Map to the same bucket strings the previous catch-all used
// so any UI code still grouping by category keeps working.
var space = name.IndexOf(' ');
var head = (space < 0 ? name : name[..space]).ToLowerInvariant();
return head switch
{
"hat" or "helmet" or "cap" or "crown" or "beanie" or "mask" => "head",
"hair" => "hair",
"eye" or "glass" or "glasses" or "goggle" or "goggles" => "face",
"mouth" or "beard" or "stache" or "moustache" or "mustache"
or "copstache" or "seventiesstache" => "face",
"shirt" or "jacket" or "hoodie" or "jumpsuit" or "tshirt" or "blouse"
or "dress" or "vest" or "torso" or "babydolldress" => "torso",
"pant" or "short" or "skirt" or "trouser" => "legs",
"shoe" or "boot" or "sock" or "sneaker" => "feet",
"neck" or "tie" or "bowtie" or "businesstie" or "cowboyscarf"
or "archerquiver" or "quiver" => "neck",
"belt" => "belt",
"wrist" or "glove" or "watch" or "partyband" => "wrist",
_ => "accessory",
};
}
public async Task<List<StoreItemEntity>> GetActiveByStorefrontAsync(string storefront) =>
await db.StoreItems
.Where(i => i.IsActive && i.Storefront == storefront &&
(i.AvailableUntil == null || i.AvailableUntil > DateTime.UtcNow))
.OrderBy(i => i.Category).ThenBy(i => i.Price)
.ToListAsync();
public async Task<List<StoreItemEntity>> GetRoomStorefrontItemsAsync(int storefrontType)
{
var key = StorefrontKeyForType(storefrontType);
var keys = new[] { key, RroStorefrontKey, AllStorefrontKey };
var rows = await db.StoreItems
.Where(i => i.IsActive && keys.Contains(i.Storefront) &&
(i.AvailableUntil == null || i.AvailableUntil > DateTime.UtcNow))
.OrderBy(i => i.Storefront == key ? 0 :
i.Storefront == RroStorefrontKey ? 1 : 2)
.ThenBy(i => i.Category)
.ThenBy(i => i.Price)
.ToListAsync();
if (rows.Any(IsRenderableGiftDrop) ||
!config.GetValue("Store:FallbackEmptyRoomStorefrontsToWatch", true))
{
return rows;
}
return await GetRenderableGiftDropItemsAsync();
}
public async Task<List<StoreItemEntity>> GetAllActiveAsync() =>
await db.StoreItems
.Where(i => i.IsActive &&
(i.AvailableUntil == null || i.AvailableUntil > DateTime.UtcNow))
.OrderBy(i => i.Storefront).ThenBy(i => i.Category).ThenBy(i => i.Price)
.ToListAsync();
public async Task<List<StoreItemEntity>> GetRenderableGiftDropItemsAsync()
{
// The March 2020 client imports Watch storefront gift drops into
// RRUI.Cache.AvatarItemCache and binds them through
// DynamicAvatarItemImposter. Only publish slots that the decompiled
// watch/store filters handle; unsupported OutfitTypes can enter the
// avatar cache and crash during tile preview binding.
if (!EnableWatchGiftDrops) return new List<StoreItemEntity>();
var rows = await db.StoreItems
.Where(i => i.IsActive &&
(i.AvailableUntil == null || i.AvailableUntil > DateTime.UtcNow))
.OrderBy(i => i.Category).ThenBy(i => i.DisplayName)
.ToListAsync();
var filtered = rows
.Where(IsRenderableGiftDrop)
.ToList();
var max = MaxWatchGiftDrops;
return max > 0 ? filtered.Take(max).ToList() : filtered;
}
public Task<StoreItemEntity?> GetBySlugAsync(string slug) =>
db.StoreItems.FirstOrDefaultAsync(i => i.Slug == slug);
public Task<StoreItemEntity?> GetByIdAsync(long id) =>
db.StoreItems.FirstOrDefaultAsync(i => i.Id == id);
public sealed record PurchaseResult(bool Success, string? Error, long? Balance, string? Slug);
/// <summary>Atomic purchase: re-checks balance, deducts currency,
/// appends the item to the player's inventory. Idempotent on
/// already-owned items (returns Success=true without re-charging).
/// Returns the resulting balance and the slug granted.</summary>
public async Task<PurchaseResult> PurchaseAsync(long playerId, long itemId)
{
var item = await GetByIdAsync(itemId);
if (item is null || !item.IsActive)
return new(false, "item_not_available", null, null);
if (item.AvailableUntil is { } until && until <= DateTime.UtcNow)
return new(false, "item_expired", null, null);
var balance = await level.GetBalanceAsync(playerId, item.CurrencyType);
if (balance < item.Price)
return new(false, "insufficient_funds", balance, null);
var avatar = await db.Avatars.FirstOrDefaultAsync(a => a.PlayerId == playerId);
if (avatar is null)
{
avatar = new AvatarEntity { PlayerId = playerId };
db.Avatars.Add(avatar);
}
if (TryGetAvatarItemPayload(item.Slug, out _, out var avatarItemDesc))
{
var guid = InventoryAvatarItemDesc(avatarItemDesc);
var ownedGuids = ParseWardrobeInventory(avatar.InventoryJson);
if (ownedGuids.Contains(guid, StringComparer.OrdinalIgnoreCase))
return new(true, "already_owned", balance, item.Slug);
var newWardrobeBalance = await level.GrantCurrencyAsync(
playerId, item.CurrencyType, -item.Price, $"purchase:{item.Slug}");
ownedGuids.Add(guid);
avatar.InventoryJson = System.Text.Json.JsonSerializer.Serialize(ownedGuids);
avatar.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return new(true, null, newWardrobeBalance, item.Slug);
}
// Idempotency: own-already → return success without re-charging.
var inventory = ParseInventory(avatar.InventoryJson);
var existing = inventory.FirstOrDefault(e => e.ItemId == item.Slug);
if (existing is not null)
{
if (string.Equals(item.Category, "consumable", StringComparison.OrdinalIgnoreCase))
{
var newBalanceConsumable = await level.GrantCurrencyAsync(
playerId, item.CurrencyType, -item.Price, $"purchase:{item.Slug}");
existing.Quantity += 1;
avatar.InventoryJson = System.Text.Json.JsonSerializer.Serialize(inventory);
avatar.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return new(true, null, newBalanceConsumable, item.Slug);
}
return new(true, "already_owned", balance, item.Slug);
}
var newBalance = await level.GrantCurrencyAsync(
playerId, item.CurrencyType, -item.Price, $"purchase:{item.Slug}");
inventory.Add(new InventoryEntry { ItemId = item.Slug, Quantity = 1 });
avatar.InventoryJson = System.Text.Json.JsonSerializer.Serialize(inventory);
avatar.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return new(true, null, newBalance, item.Slug);
}
private static List<string> ParseWardrobeInventory(string? json)
{
if (string.IsNullOrWhiteSpace(json) || json == "[]") return new();
try
{
var strings = System.Text.Json.JsonSerializer.Deserialize<List<string>>(json);
if (strings is not null) return strings;
}
catch { }
try
{
var entries = ParseInventory(json);
return entries
.Select(e => e.ItemId.StartsWith("wardrobe-", StringComparison.Ordinal)
? e.ItemId["wardrobe-".Length..]
: e.ItemId)
.Select(InventoryAvatarItemDesc)
.Where(s => !string.IsNullOrWhiteSpace(s))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
}
catch { return new(); }
}
public sealed class InventoryEntry
{
[System.Text.Json.Serialization.JsonPropertyName("itemId")]
public string ItemId { get; set; } = string.Empty;
[System.Text.Json.Serialization.JsonPropertyName("quantity")]
public int Quantity { get; set; }
}
public static List<InventoryEntry> ParseInventory(string? json)
{
if (string.IsNullOrWhiteSpace(json) || json == "[]") return new();
try
{
return System.Text.Json.JsonSerializer
.Deserialize<List<InventoryEntry>>(json) ?? new();
}
catch { return new(); }
}
/// <summary>
/// GiftDropStorefront wire shape, verified against ISIL:
/// GiftDropStorefront.Deserialize → reads "StoreItems"
/// (Cpp2IL_ISIL/.../GiftDropStorefront.txt:98)
/// StoreItems is List&lt;PurchasableGiftDrop&gt;
/// PurchasableGiftDrop.Deserialize → calls PurchasableItem.Deserialize
/// then reads "GiftDrops" as List&lt;StorefrontGiftDrop&gt;
/// (PurchasableGiftDrop.txt:118)
/// PurchasableItem.Deserialize → REQUIRED keys
/// PurchasableItemId / Type / IsFeatured / Prices / SubscriberPrices
/// (PurchasableItem.txt:592-654)
/// PurchasablePrice.Deserialize → CurrencyType / Price
/// (PurchasablePrice.txt:87-94)
/// StorefrontGiftDrop.Deserialize → REQUIRED GiftDropId / Rarity
/// plus optional FriendlyName / Tooltip / AvatarItemDesc /
/// AvatarItemType / ConsumableItemDesc / EquipmentPrefabName /
/// EquipmentModificationGuid / IsQuery / Unique / SubscribersOnly /
/// Level / Context (StorefrontGiftDrop.txt:829-924).
///
/// Each StoreItemEntity becomes one PurchasableGiftDrop holding a
/// single StorefrontGiftDrop pointing at the associated wardrobe
/// AvatarItem GUID. Only <c>wardrobe-{guid}</c>-prefixed slugs are
/// emitted because the 2020 watch storefront binds every gift-drop
/// tile through <c>BrowsableAvatarItem</c>. Sending a gift drop with
/// AvatarItemType=Outfit and an empty AvatarItemDesc leaves that
/// binding half-populated and can crash the client while rendering
/// the shop grid.
/// </summary>
public static object ToStorefrontDto(string name, IEnumerable<StoreItemEntity> items, string apex = "rec.net", int storefrontType = 3)
{
var storeItems = items
.Where(IsRenderableGiftDrop)
.Select((i, idx) => BuildPurchasableGiftDrop(i, idx))
.ToArray();
return new
{
StorefrontType = storefrontType,
StorefrontName = name,
NextUpdate = "9999-12-31T00:00:00Z",
NewUntil = "9999-12-31T00:00:00Z",
StoreItems = storeItems,
};
}
private static bool IsRenderableGiftDrop(StoreItemEntity item)
{
if (TryGetHairDyePayload(item.Slug, out _)) return true;
if (TryGetConsumableItemDesc(item.Slug, out _)) return true;
if (TryGetEquipmentPayload(item.Slug, out _, out _)) return true;
if (!TryGetAvatarItemPayload(item.Slug, out var avatarItemType, out var avatarItemDesc))
return false;
if (avatarItemType != StoreAvatarItemTypeOutfit) return false;
var guid = avatarItemDesc.Split(',', 2)[0];
return AvatarItemsController.TryGetOutfitType(guid, out var outfitType)
&& IsWatchStorefrontOutfitType(outfitType);
}
private static bool IsWatchStorefrontOutfitType(int outfitType) => outfitType is
AvatarItemsController.Slot.Hat or
AvatarItemsController.Slot.BackHead or
AvatarItemsController.Slot.Hair or
AvatarItemsController.Slot.Eye or
AvatarItemsController.Slot.Mouth or
AvatarItemsController.Slot.Neck or
AvatarItemsController.Slot.Shirt or
AvatarItemsController.Slot.Belt or
AvatarItemsController.Slot.Pocket or
AvatarItemsController.Slot.Wrist or
AvatarItemsController.Slot.Glove or
AvatarItemsController.Slot.Watch or
AvatarItemsController.Slot.TeamShirt or
AvatarItemsController.Slot.TeamWrist;
/// <summary>One PurchasableGiftDrop carrying one StorefrontGiftDrop.
/// PurchasableGiftDrop inherits PurchasableItem so we ship the
/// PurchasableItem keys plus the GiftDrops list in the same
/// object.</summary>
private static object BuildPurchasableGiftDrop(StoreItemEntity i, int positionalIndex)
{
// PurchasableItemId is an int and must be unique within the
// storefront — we use the StoreItem row id, capped to int range.
var itemId = (int)(i.Id & 0x7fffffff);
var price = new
{
CurrencyType = i.CurrencyType,
Price = (int)i.Price,
};
// Pull the AvatarItem GUID out of the slug for wardrobe-imported
// entries. Hand-curated <c>dorknet-*</c> SKUs leave AvatarItemDesc
// empty — they're cosmetic store tiles only.
//
// CRITICAL: the watch's AvatarItem.FromRecNetString does
// String.Split(',') and indexes customization[0..2] via
// ArraySegmentEnumerator.get_Current — anything shorter than
// four comma-separated parts throws ArgumentException ("Offset
// and length were out of bounds…") AND, because that exception
// bubbles up through OutfitManager.IsAvatarItemAlreadyPurchased
// → WatchStoreItemButton.UpdateButtonInteractability → the
// store's UI grid render path, the watch crashes mid-tile. Bare
// <c>{guid}</c> = 1 part → instant crash on first store-tile
// hover; <c>{guid},,,</c> = 4 parts (guid + 3 empty
// customizations) → safe. AvatarItemsController.Get() applies
// the same suffix to /api/avatar/v4/items entries; we apply it
// here too.
TryGetAvatarItemPayload(i.Slug, out var avatarItemType, out var avatarItemDesc);
TryGetConsumableItemDesc(i.Slug, out var consumableItemDesc);
TryGetEquipmentPayload(i.Slug, out var equipmentPrefabName, out var equipmentModificationGuid, out var equipmentEntry);
var isHairDye = TryGetHairDyePayload(i.Slug, out var consumableHairDyeDesc, out var hairDyeColorGuid);
if (isHairDye)
{
avatarItemType = StoreAvatarItemTypeHairDye;
avatarItemDesc = hairDyeColorGuid;
consumableItemDesc = consumableHairDyeDesc;
}
var giftDrop = new
{
// REQUIRED.
GiftDropId = itemId,
Rarity = MapRarity(i.Category),
// Optional / nullable string fields the watch reads.
FriendlyName = i.DisplayName,
Tooltip = i.Description,
AvatarItemDesc = avatarItemDesc,
AvatarItemDescOrHairDyeDesc = avatarItemDesc,
AvatarItemType = string.IsNullOrEmpty(avatarItemDesc) ? (int?)null : avatarItemType,
ConsumableItemDesc = consumableItemDesc,
EquipmentPrefabName = equipmentPrefabName,
EquipmentModificationGuid = equipmentModificationGuid,
IsQuery = false,
Unique = true,
SubscribersOnly = false,
Level = 1,
Context = 0,
Content = isHairDye ? GiftBoxContentHairDye : 0,
EquipmentRarity = equipmentEntry?.rarity ?? 0,
};
return new
{
// Base PurchasableItem keys.
PurchasableItemId = itemId,
Type = 0, // PurchasableItemType.GiftDrop
IsFeatured = positionalIndex < 12, // first dozen show in Featured tab
Prices = new[] { price },
SubscriberPrices = Array.Empty<object>(),
// PurchasableGiftDrop adds GiftDrops on top.
GiftDrops = new[] { giftDrop },
};
}
/// <summary>GiftManager.GiftRarity wire values:
/// None=-1, Common=0, Uncommon=10, Rare=20, Epic=30, Legendary=50.</summary>
private static int MapRarity(string? category) => category?.ToLowerInvariant() switch
{
"consumable" => 0,
"head" or "hair" or "face" => 10,
"torso" or "neck" or "belt" or "wrist" => 20,
"accessory" => 30,
"roomtemplate" => 50,
_ => 0,
};
public static bool TryGetAvatarItemPayload(string? slug, out int avatarItemType, out string avatarItemDesc)
{
avatarItemType = StoreAvatarItemTypeOutfit;
avatarItemDesc = string.Empty;
if (string.IsNullOrWhiteSpace(slug)) return false;
// Colored variant slug (wardrobe-colored-<itemGuid>-<color>) —
// resolve via the static catalog to get the swatch + mask
// GUIDs. AvatarItem RecNet desc format verified against the
// watch's AvatarItemVisualData.ToRecNetString (Cpp2IL_ISIL/.../
// RecRoom/Avatar/Data/Shared/AvatarItemVisualData.txt:474-585):
// <prefabGuid>,<swatchGuid>,<maskGuid>,<decalGuid>
// — swatch BEFORE mask, not the other way around. Earlier code
// had them flipped, which made the watch read the mask GUID as
// a swatch GUID, fail to resolve it, and fall back to the
// default swatch.
if (slug.StartsWith(WardrobeColoredPrefix, StringComparison.OrdinalIgnoreCase))
{
if (_variantCatalog.Value.TryGetValue(slug.ToLowerInvariant(), out var entry))
{
avatarItemDesc = $"{entry.item_guid},{entry.swatch_guid},{entry.mask_guid},";
return true;
}
// Fall through to wardrobe- handling if not in catalog.
}
if (slug.StartsWith(WardrobePrefix, StringComparison.Ordinal))
{
avatarItemDesc = slug[WardrobePrefix.Length..] + ",,,";
return true;
}
return false;
}
public static string InventoryAvatarItemDesc(string avatarItemDesc)
{
if (string.IsNullOrWhiteSpace(avatarItemDesc)) return string.Empty;
return avatarItemDesc.EndsWith(",,,", StringComparison.Ordinal)
? avatarItemDesc[..^3]
: avatarItemDesc;
}
public static bool TryGetConsumableItemDesc(string? slug, out string consumableItemDesc)
{
consumableItemDesc = string.Empty;
if (string.IsNullOrWhiteSpace(slug)) return false;
if (TryGetHairDyePayload(slug, out consumableItemDesc, out _)) return true;
return false;
}
public static bool TryGetHairDyePayload(string? slug, out string hairDyeDesc)
{
return TryGetHairDyePayload(slug, out _, out hairDyeDesc);
}
public static bool TryGetHairDyeName(string? slug, out string name)
{
name = string.Empty;
if (string.IsNullOrWhiteSpace(slug)) return false;
foreach (var dye in PermanentHairDyes)
{
if (!string.Equals(slug, dye.Slug, StringComparison.OrdinalIgnoreCase)) continue;
name = dye.Name;
return true;
}
return false;
}
public static bool TryGetHairDyePayload(string? slug, out string consumableDesc, out string hairColorGuid)
{
consumableDesc = string.Empty;
hairColorGuid = string.Empty;
if (string.IsNullOrWhiteSpace(slug)) return false;
foreach (var dye in PermanentHairDyes)
{
if (!string.Equals(slug, dye.Slug, StringComparison.OrdinalIgnoreCase)) continue;
consumableDesc = dye.ConsumableDesc;
hairColorGuid = dye.HairColorGuid;
return true;
}
return false;
}
public static object ToItemDto(StoreItemEntity i, string apex = "rec.net") => new
{
ItemId = i.Id,
Slug = i.Slug,
Name = i.DisplayName,
DisplayName = i.DisplayName,
Description = i.Description,
Category = i.Category,
ImageName = i.ImageName,
ImageUrl = string.IsNullOrEmpty(i.ImageName) ? "" : $"https://cdn.{apex}/{i.ImageName}",
// Price block — both flat and nested forms so whichever shape
// the watch's deserialiser reads, it sees a price.
Price = i.Price,
CurrencyType = i.CurrencyType,
Cost = new { Amount = i.Price, CurrencyType = i.CurrencyType },
IsActive = i.IsActive,
IsLimitedTime = i.IsLimitedTime,
AvailableUntil = i.AvailableUntil,
};
}