mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String></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<PLILLKHMNDA>)</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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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&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;
|
||||
}
|
||||
}
|
||||
@@ -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:<id></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<string>)</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<string, object></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<string, NotificationHandler></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<player>_v*.dat</c>,
|
||||
/// <c>room_<id>_v*.dat</c>, <c>invention_p<player>_*.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<player>_*</c>, <c>holotar_p<player>_*</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 ?? ""}";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 >= 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)}";
|
||||
}
|
||||
}
|
||||
@@ -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<MKAMHOIHOJK></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();
|
||||
}
|
||||
}
|
||||
@@ -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}&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);
|
||||
@@ -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<PurchasableGiftDrop>
|
||||
/// PurchasableGiftDrop.Deserialize → calls PurchasableItem.Deserialize
|
||||
/// then reads "GiftDrops" as List<StorefrontGiftDrop>
|
||||
/// (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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user