using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using Microsoft.EntityFrameworkCore;
using System.Globalization;
using System.Text.Json;
namespace DorkNet.Server.Services;
///
/// Single-row settings store. Same Postgres-backed pattern as
/// : 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.
///
public class ServerSettingsService(DorkNetDbContext db)
{
private const int RowId = 1;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
};
public async Task GetAsync()
{
var row = await db.ServerSettings.AsNoTracking()
.FirstOrDefaultAsync(s => s.Id == RowId);
return row ?? new ServerSettingsEntity { Id = RowId };
}
public async Task AreSignupsDisabledAsync()
{
var row = await db.ServerSettings.AsNoTracking()
.FirstOrDefaultAsync(s => s.Id == RowId);
return row?.SignupsDisabled ?? false;
}
public async Task SetSignupsDisabledAsync(bool disabled)
{
var existing = await GetTrackedRowAsync();
existing.SignupsDisabled = disabled;
existing.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return existing;
}
/// When true, every account is treated as a friend of every
/// other account (synthesized at read time — no relationship rows are
/// written). See
/// and .
public async Task IsGlobalFriendsEnabledAsync()
{
var row = await db.ServerSettings.AsNoTracking()
.FirstOrDefaultAsync(s => s.Id == RowId);
return row?.GlobalFriendsEnabled ?? false;
}
public async Task SetGlobalFriendsEnabledAsync(bool enabled)
{
var existing = await GetTrackedRowAsync();
existing.GlobalFriendsEnabled = enabled;
existing.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return existing;
}
/// When true, every account is treated as owning every avatar
/// item in the master catalog (synthesized at read time — no inventory
/// rows are written). See
/// and .
public async Task IsAllAvatarItemsOwnedEnabledAsync()
{
var row = await db.ServerSettings.AsNoTracking()
.FirstOrDefaultAsync(s => s.Id == RowId);
return row?.AllAvatarItemsOwned ?? false;
}
public async Task SetAllAvatarItemsOwnedEnabledAsync(bool enabled)
{
var existing = await GetTrackedRowAsync();
existing.AllAvatarItemsOwned = enabled;
existing.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return existing;
}
public async Task GetWeeklyChallengesAsync()
{
var row = await GetAsync();
return ToWeeklySettings(row);
}
public async Task GetPlayMenuTagsAsync()
{
var row = await GetAsync();
return ToPlayMenuTags(row);
}
public async Task GetRecCenterDoorsAsync()
{
var row = await GetAsync();
return ToRecCenterDoors(row);
}
/// Built-in default base-room picker (used when an admin hasn't
/// customised the list). RRO originals minus #quest rooms, MakerRoom
/// first as the canonical blank canvas.
public static readonly IReadOnlyList DefaultBaseRoomNames = new[]
{
"MakerRoom", "RecCenter", "3DCharades", "Dodgeball",
"DiscGolfLake", "DiscGolfPropulsion", "Paddleball",
"Paintball", "Soccer", "LaserTag", "RecRoyaleSquads",
"RecRoyaleSolos", "Park", "BowlingAlley", "RecRally",
};
public async Task> GetBaseRoomNamesAsync()
{
var row = await GetAsync();
return ToBaseRoomNames(row);
}
public async Task> SetBaseRoomNamesAsync(IReadOnlyList names)
{
var normalized = NormalizeBaseRoomNames(names);
var existing = await GetTrackedRowAsync();
existing.BaseRoomNamesJson = JsonSerializer.Serialize(normalized, JsonOptions);
existing.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return normalized;
}
private static IReadOnlyList ToBaseRoomNames(ServerSettingsEntity row)
{
if (!string.IsNullOrWhiteSpace(row.BaseRoomNamesJson))
{
try
{
var parsed = JsonSerializer.Deserialize>(row.BaseRoomNamesJson, JsonOptions);
if (parsed is { Count: > 0 })
return NormalizeBaseRoomNames(parsed);
}
catch
{
// Bad settings must not break room creation; admins can
// overwrite from the SPA.
}
}
return DefaultBaseRoomNames.ToList();
}
private static IReadOnlyList NormalizeBaseRoomNames(IReadOnlyList names)
{
var seen = new HashSet(StringComparer.OrdinalIgnoreCase);
var result = new List();
foreach (var n in names)
{
var t = (n ?? string.Empty).Trim();
if (t.Length > 0 && seen.Add(t)) result.Add(t);
}
return result.Count > 0 ? result : DefaultBaseRoomNames.ToList();
}
public async Task GetDiscoveredGameConfigsAsync()
{
var row = await GetAsync();
return ToDiscoveredGameConfigs(row);
}
public async Task> GetGameConfigurationsAsync()
{
var row = await GetAsync();
return ToGameConfigurations(
ToRecCenterDoors(row),
ToDiscoveredGameConfigs(row),
ToCircuitsV2Limits(row));
}
public async Task GetCircuitsV2LimitsAsync()
{
var row = await GetAsync();
return ToCircuitsV2Limits(row);
}
public async Task SetCircuitsV2LimitsAsync(
CircuitsV2LimitSettings settings)
{
var normalized = NormalizeCircuitsV2Limits(settings);
var existing = await GetTrackedRowAsync();
existing.CircuitsV2LimitsJson = JsonSerializer.Serialize(normalized, JsonOptions);
existing.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return normalized with { UpdatedAt = existing.UpdatedAt };
}
public async Task SetPlayMenuTagsAsync(
IReadOnlyList pinned,
IReadOnlyList popular,
IReadOnlyList? trending = null)
{
var normalized = NormalizePlayMenuTags(pinned, popular, trending);
var existing = await GetTrackedRowAsync();
existing.PlayMenuTagsJson = JsonSerializer.Serialize(normalized, JsonOptions);
existing.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return normalized with { UpdatedAt = existing.UpdatedAt };
}
public async Task SetRecCenterDoorsAsync(
IReadOnlyList doors)
{
var normalized = NormalizeRecCenterDoors(doors);
var existing = await GetTrackedRowAsync();
existing.RecCenterDoorsJson = JsonSerializer.Serialize(normalized, JsonOptions);
existing.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return normalized with { UpdatedAt = existing.UpdatedAt };
}
public async Task SetDiscoveredGameConfigsAsync(
DiscoveredGameConfigSettings settings)
{
var normalized = NormalizeDiscoveredGameConfigs(settings);
var existing = await GetTrackedRowAsync();
existing.DiscoveredGameConfigsJson = JsonSerializer.Serialize(normalized, JsonOptions);
existing.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return normalized with { UpdatedAt = existing.UpdatedAt };
}
public async Task SetWeeklyChallengesAsync(
bool completedRequired,
IReadOnlyList 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);
}
/// Whether the server-side profanity filter behind
/// api/sanitize/* is bypassed. Off by default (filter active).
public async Task IsProfanityFilterDisabledAsync()
{
var row = await db.ServerSettings.AsNoTracking()
.FirstOrDefaultAsync(s => s.Id == RowId);
return row?.ProfanityFilterDisabled ?? false;
}
public async Task SetProfanityFilterDisabledAsync(bool disabled)
{
var existing = await GetTrackedRowAsync();
existing.ProfanityFilterDisabled = disabled;
existing.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return existing;
}
/// Whether the CDN's PersistedRoomData version clamp for the
/// March-2023 client is bypassed. Off by default (clamp active) — see
/// and
/// .
public async Task IsRoomBlobVersionClampDisabledAsync()
{
var row = await db.ServerSettings.AsNoTracking()
.FirstOrDefaultAsync(s => s.Id == RowId);
return row?.RoomBlobVersionClampDisabled ?? false;
}
public async Task SetRoomBlobVersionClampDisabledAsync(bool disabled)
{
var existing = await GetTrackedRowAsync();
existing.RoomBlobVersionClampDisabled = disabled;
existing.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return existing;
}
/// Live charades slot→list bindings. Empty/unparseable JSON
/// resolves to (every slot
/// falls back to its built-in seed list).
public async Task GetCharadesSlotBindingsAsync()
{
var row = await GetAsync();
if (string.IsNullOrWhiteSpace(row.CharadesSlotBindingsJson))
return CharadesSlotBindings.Empty;
try
{
return JsonSerializer.Deserialize(
row.CharadesSlotBindingsJson, JsonOptions) ?? CharadesSlotBindings.Empty;
}
catch
{
return CharadesSlotBindings.Empty;
}
}
public async Task SetCharadesSlotBindingsAsync(CharadesSlotBindings bindings)
{
var existing = await GetTrackedRowAsync();
existing.CharadesSlotBindingsJson = JsonSerializer.Serialize(bindings, JsonOptions);
existing.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return bindings;
}
private async Task 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>(
row.WeeklyChallengesJson,
JsonOptions);
if (parsed?.Count > 0)
challenges = NormalizeWeeklyChallenges(parsed);
}
catch
{
challenges = DefaultWeeklyChallenges();
}
}
if (!string.IsNullOrWhiteSpace(row.WeeklyChallengeRewardJson))
{
try
{
var parsed = JsonSerializer.Deserialize(
row.WeeklyChallengeRewardJson,
JsonOptions);
reward = NormalizeWeeklyReward(parsed);
}
catch
{
reward = DefaultWeeklyReward();
}
}
return new WeeklyChallengeSettings(
row.WeeklyChallengesCompletedRequired,
challenges,
reward,
row.UpdatedAt);
}
private static PlayMenuTagSettings ToPlayMenuTags(ServerSettingsEntity row)
{
if (!string.IsNullOrWhiteSpace(row.PlayMenuTagsJson))
{
try
{
var parsed = JsonSerializer.Deserialize(
row.PlayMenuTagsJson,
JsonOptions);
if (parsed is not null)
return NormalizePlayMenuTags(parsed.PinnedTags, parsed.PopularTags, parsed.TrendingTags)
with { UpdatedAt = row.UpdatedAt };
}
catch
{
// Bad settings should not break the Play menu; admins can
// overwrite them from the SPA.
}
}
return DefaultPlayMenuTags() with { UpdatedAt = row.UpdatedAt };
}
private static RecCenterDoorSettings ToRecCenterDoors(ServerSettingsEntity row)
{
if (!string.IsNullOrWhiteSpace(row.RecCenterDoorsJson))
{
try
{
var parsed = JsonSerializer.Deserialize(
row.RecCenterDoorsJson,
JsonOptions);
if (parsed is not null)
return NormalizeRecCenterDoors(parsed.Doors)
with { UpdatedAt = row.UpdatedAt };
}
catch
{
// Bad settings should not break Rec Center startup; admins
// can overwrite them from the SPA.
}
}
return DefaultRecCenterDoors() with { UpdatedAt = row.UpdatedAt };
}
private static DiscoveredGameConfigSettings ToDiscoveredGameConfigs(ServerSettingsEntity row)
{
if (!string.IsNullOrWhiteSpace(row.DiscoveredGameConfigsJson))
{
try
{
var parsed = JsonSerializer.Deserialize(
row.DiscoveredGameConfigsJson,
JsonOptions);
if (parsed is not null)
return NormalizeDiscoveredGameConfigs(parsed)
with { UpdatedAt = row.UpdatedAt };
}
catch
{
// Bad settings should not break gameconfig bootstrap.
}
}
return DefaultDiscoveredGameConfigs() with { UpdatedAt = row.UpdatedAt };
}
private static List NormalizeWeeklyChallenges(
IReadOnlyList challenges)
{
var normalized = challenges
.Where(c => !string.IsNullOrWhiteSpace(c.Name))
.Take(10)
.Select((c, i) => new WeeklyChallengeTemplate(
i,
c.Name.Trim(),
NormalizeWeeklyChallengeConfig(c.Config),
c.Description?.Trim() ?? string.Empty,
c.Tooltip?.Trim() ?? string.Empty))
.ToList();
return normalized.Count > 0 ? normalized : DefaultWeeklyChallenges();
}
private static string NormalizeWeeklyChallengeConfig(string? config)
{
if (TryGetLegacyWeeklyGoal(config, out var legacyGoal))
return CountedAnyChallengeConfig(legacyGoal);
if (IsClientWeeklyChallengeConfig(config))
return config!.Trim();
return CountedAnyChallengeConfig(1);
}
private static bool TryGetLegacyWeeklyGoal(string? config, out int goal)
{
goal = 1;
if (string.IsNullOrWhiteSpace(config)) return false;
try
{
using var doc = JsonDocument.Parse(config);
if (doc.RootElement.ValueKind != JsonValueKind.Object)
return false;
if (!doc.RootElement.TryGetProperty("Type", out _))
return false;
if (doc.RootElement.TryGetProperty("Goal", out var goalElement)
&& goalElement.TryGetInt32(out var parsedGoal))
{
goal = Math.Clamp(parsedGoal, 1, 1000);
}
return true;
}
catch
{
return false;
}
}
private static bool IsClientWeeklyChallengeConfig(string? config)
{
if (string.IsNullOrWhiteSpace(config)) return false;
try
{
using var doc = JsonDocument.Parse(config);
return IsClientWeeklyChallengeConfigElement(doc.RootElement);
}
catch
{
return false;
}
}
private static bool IsClientWeeklyChallengeConfigElement(JsonElement element)
{
if (element.ValueKind != JsonValueKind.Object)
return false;
if (!element.TryGetProperty("ct", out var typeElement) || !typeElement.TryGetInt32(out var type))
return false;
if (type == 0 || IsKnownClientChallengeType(type))
return true;
if (type != 1)
return false;
if (!element.TryGetProperty("t", out var targetElement)
|| !targetElement.TryGetInt32(out var target)
|| target <= 0)
{
return false;
}
if (!element.TryGetProperty("ctc", out var children)
|| children.ValueKind != JsonValueKind.Array
|| children.GetArrayLength() == 0)
{
return false;
}
return children.EnumerateArray().All(IsClientWeeklyChallengeConfigElement);
}
private static bool IsKnownClientChallengeType(int type) => type is
2 // TimedBufferChallenge
or 3 // DynamicFloatArithmeticChallenge
or 4 // DynamicIntArithmeticChallenge
or 6 // RequiredEventTypeChallenge
or 7 // RequiredRoomSceneLocationChallenge
or 8 // RequiredEnemyTypeChallenge
or 9 // BoolVarEqualsChallenge
or 11 // DiscGolfFinishUnderParChallenge
or 12 // RequiredGameModeActivityChallenge
or 13 // CompleteGameWithoutChallenge
or 14 // RequiredGestureChallenge
or 15 // HitstreakChallenge
or 16; // HitstreakCountChallenge
private static string CountedAnyChallengeConfig(int target) =>
"{\"ct\":1,\"ctc\":[{\"ct\":0}],\"t\":" + Math.Clamp(target, 1, 1000) + ",\"cc\":0}";
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 DefaultWeeklyChallenges() =>
[
new(
Index: 0,
Name: "Play 3 Rec Room Originals",
Config: CountedAnyChallengeConfig(3),
Description: "Complete three Rec Room Original activities.",
Tooltip: "Play any RRO activity to make progress."),
new(
Index: 1,
Name: "Cheer 5 Players",
Config: CountedAnyChallengeConfig(5),
Description: "Send cheers to five players.",
Tooltip: "Use the watch to cheer players you meet."),
new(
Index: 2,
Name: "Visit 5 Rooms",
Config: CountedAnyChallengeConfig(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 static PlayMenuTagSettings DefaultPlayMenuTags() =>
new(
PinnedTags:
[
"community",
"recroomoriginal",
"featured",
"quest",
"sport",
"template",
"hangout",
"creative",
],
PopularTags:
[
"paintball",
"dodgeball",
"soccer",
"lasertag",
"recroyale",
"discgolf",
"charades",
"bowling",
"paddleball",
"stuntrunner",
"makerpen",
"pvp",
"co-op",
"chill",
"music",
"parkour",
],
TrendingTags:
[
"featured",
"community",
"makerpen",
"pvp",
"quest",
"music",
"parkour",
"hangout",
"creative",
"recroomoriginal",
],
UpdatedAt: DateTime.UtcNow);
public static RecCenterDoorSettings DefaultRecCenterDoors() =>
new(
Doors:
[
new("Shooters", "Shooters", "#paintball|#lasertag|#recroyale"),
new("Creative", "Creative", "#creative|#makerpen|#template"),
new("Quests", "Quests", "#quest"),
new("Sports", "Sports", "#sport"),
new("Featured", "Featured", "#featured|#recroomoriginal"),
],
UpdatedAt: DateTime.UtcNow);
public static DiscoveredGameConfigSettings DefaultDiscoveredGameConfigs() =>
new(
FriendsPostGamePromptUnderFriendCount: 3,
FriendsSuggestFriendCodeOnFriendsScreenCount: 5,
ScreensForceVerification: false,
VrForceVerification: false,
RewardsUseRewardSelection: false,
RewardsSelectionTimeout: 30,
RoomDetailsPhotoRollEnabled: false,
LoadingNetworkTimeout: 30,
RunningNetworkTimeout: 30,
SynchronizedFieldRemoveDefaultEntries: false,
RenderingDisableSrpBatcher: false,
SplitTestSoftOverrides: "{}",
SplitTestHardOverrides: "{}",
SplitTestSegmentProbabilities: "{}",
UpdatedAt: DateTime.UtcNow);
///
/// Retail shipped one network entry — 5 RPCs per frame, 1024 bytes per
/// 60 frames — which is what makes busy CircuitsV2 rooms drop chip
/// sends. DorkNet ships the budgets effectively unlimited instead, and
/// raises the room-size limits to values the client survives with
/// headroom (each is justified in docs/recroom-2023-circuitsv2-limits.md):
///
/// - Compute 50000 → 500000: the per-frame CPU budget shared by
/// every circuit in the room. It is the only thing that stops a runaway
/// loop, so it stays finite.
/// - Chips 3000 → 30000: the room's chip cap ("Too many
/// chips.").
/// - CloudValues 100 → 1000: the room's cloud-variable cap.
/// - UnculledChips / VisibleChips 500 → 3000: chips past these
/// counts get culled, so every chip of a retail-sized room stays
/// visible.
/// - InkPerChip 15 → 0: this is an ink COST, charged against the
/// client's hard-coded 30000-ink room cap, so lowering it is the
/// increase. 0 means chips stop eating room ink.
///
/// CloudValuesAutosaveSeconds stays at retail's 300: it is a save
/// cadence, not a capacity limit. Admins can dial any of it back from
/// the SPA.
///
public static CircuitsV2LimitSettings DefaultCircuitsV2Limits() =>
new(
Network:
[
new(
NumPlayersForActivation: 0,
Bytes: UnlimitedBudget,
BytesTimeWindowFrame60s: 60,
Rpcs: UnlimitedBudget,
RpcTimeWindowFrame60s: 1),
],
Compute: 500_000,
Chips: 30_000,
CloudValues: 1_000,
CloudValuesAutosaveSeconds: 300f,
UnculledChips: 3_000,
VisibleChips: 3_000,
InkPerChip: 0,
UpdatedAt: DateTime.UtcNow);
/// The budget value that stands in for "unlimited". The client
/// only ever compares a running counter against it, so any large int
/// works; this leaves 2x headroom under int.MaxValue.
private const int UnlimitedBudget = 1_000_000_000;
/// Time windows size a new int[N] ring buffer — one
/// bucket per frame — inside the client's rate limiter, so a huge
/// window is an allocation, not a bigger budget. 3600 frames (a minute
/// at 60fps) is 14KB and far past anything useful.
private const int MaxTimeWindowFrames = 3600;
/// Play-menu carousel order. PlayMenuCarouselModel.TryGetDisplayPriorityMap
/// looks this exact key up (literal at Assembly-CSharp/RRUI/Data/PlayMenuCarouselModel.txt:547)
/// and, when it is absent, logs "Could not find GameConfig for PlayMenuCarouselOrdering_1"
/// (:758) and leaves the carousels in prefab order. The value is split on ';' then '='
/// into id → Int32 priority (:603, :632, :655); PlayMenuCarouselController.DisplayOrderPriorityImpl
/// then reorders the carousel children with SetSiblingIndex.
///
/// The ids are the carousel ids serialized on that component in the 2023-03-21 build's
/// Addressables bundle (3900770abc33861326d1865e454c0901.bundle, MonoBehaviour
/// PlayMenuCarouselController/DisplayOrderPriorityImpl): continueplaying, friendsplaying,
/// myclubhouses, myevents, mysubscriptions, recommended, hotrooms, topcreators, featured,
/// myfavorites, recroomoriginals, new, limitedtimeevent. Priorities follow the live
/// service's 2023 "PlayMenuCarouselOrdering_3" segment (higher first), with the
/// time-limited event carousel put on top.
public const string PlayMenuCarouselOrderingKey = "PlayMenuCarouselOrdering_1";
public const string PlayMenuCarouselOrdering =
"limitedtimeevent=15;hotrooms=14;new=13;recroomoriginals=12;continueplaying=11;" +
"myfavorites=10;friendsplaying=9;recommended=8;mysubscriptions=7;featured=5;" +
"myclubhouses=4;myevents=3;topcreators=2";
public static IReadOnlyList ToGameConfigurations(
RecCenterDoorSettings doors,
DiscoveredGameConfigSettings discovered,
CircuitsV2LimitSettings circuits)
{
var rows = new List();
foreach (var door in NormalizeRecCenterDoors(doors.Doors).Doors)
{
rows.Add(new($"Door.{door.Key}.Title", door.Title));
rows.Add(new($"Door.{door.Key}.Query", door.Query));
}
var gameConfig = NormalizeDiscoveredGameConfigs(discovered);
rows.AddRange([
new("Friends.PostGamePromptUnderFriendCount", gameConfig.FriendsPostGamePromptUnderFriendCount.ToString()),
new("Friends.SuggestFriendCodeOnFriendsScreenCount", gameConfig.FriendsSuggestFriendCodeOnFriendsScreenCount.ToString()),
new("Screens.ForceVerification", gameConfig.ScreensForceVerification ? "1" : "0"),
new("VR.ForceVerification", gameConfig.VrForceVerification ? "1" : "0"),
new("Rewards.UseRewardSelection", gameConfig.RewardsUseRewardSelection.ToString().ToLowerInvariant()),
new("Rewards.SelectionTimeout", gameConfig.RewardsSelectionTimeout.ToString()),
new("RoomDetails.PhotoRollEnabled", gameConfig.RoomDetailsPhotoRollEnabled.ToString().ToLowerInvariant()),
new("loadingNetworkTimeout", gameConfig.LoadingNetworkTimeout.ToString(System.Globalization.CultureInfo.InvariantCulture)),
new("runningNetworkTimeout", gameConfig.RunningNetworkTimeout.ToString(System.Globalization.CultureInfo.InvariantCulture)),
new("SynchronizedField.RemoveDefaultEntries", gameConfig.SynchronizedFieldRemoveDefaultEntries.ToString().ToLowerInvariant()),
new("Rendering.DisableSrpBatcher", gameConfig.RenderingDisableSrpBatcher.ToString().ToLowerInvariant()),
new("splitTestSoftOverrides", gameConfig.SplitTestSoftOverrides),
new("splitTestHardOverrides", gameConfig.SplitTestHardOverrides),
new("splitTestSegmentProbabilities", gameConfig.SplitTestSegmentProbabilities),
new(PlayMenuCarouselOrderingKey, PlayMenuCarouselOrdering),
]);
var limits = NormalizeCircuitsV2Limits(circuits);
rows.AddRange([
new("UGC.CircuitsV2.Limit.Network", JsonSerializer.Serialize(limits.Network, JsonOptions)),
new("UGC.CircuitsV2.Limit.Compute", Int(limits.Compute)),
new("UGC.CircuitsV2.Limit.Chips", Int(limits.Chips)),
new("UGC.CircuitsV2.Limit.CloudValues", Int(limits.CloudValues)),
new("UGC.CircuitsV2.Limit.CloudValuesAutosaveSeconds",
limits.CloudValuesAutosaveSeconds.ToString("0.###", CultureInfo.InvariantCulture)),
new("UGC.CircuitsV2.Limit.UnculledChips", Int(limits.UnculledChips)),
new("UGC.CircuitsV2.Limit.VisibleChips", Int(limits.VisibleChips)),
new("UGC.CircuitsV2.Limit.InkPerChip", Int(limits.InkPerChip)),
]);
return rows;
// The client parses every scalar with int/float.TryParse under
// InvariantCulture, so no thousands separators.
static string Int(int value) => value.ToString(CultureInfo.InvariantCulture);
}
private static PlayMenuTagSettings NormalizePlayMenuTags(
IReadOnlyList? pinned,
IReadOnlyList? popular,
IReadOnlyList? trending)
{
static List Normalize(IReadOnlyList? tags, int max)
{
var seen = new HashSet(StringComparer.OrdinalIgnoreCase);
return (tags ?? [])
.Select(t => (t ?? string.Empty).Trim().TrimStart('#').ToLowerInvariant())
.Where(t => t.Length > 0)
.Where(t => t.All(c => char.IsLetterOrDigit(c) || c is '-' or '_'))
.Where(seen.Add)
.Take(max)
.ToList();
}
var defaults = DefaultPlayMenuTags();
var pinnedTags = Normalize(pinned, 16);
var popularTags = Normalize(popular, 32);
var trendingTags = Normalize(trending, 32);
return new PlayMenuTagSettings(
pinnedTags.Count > 0 ? pinnedTags : defaults.PinnedTags,
popularTags.Count > 0 ? popularTags : defaults.PopularTags,
trendingTags.Count > 0 ? trendingTags : defaults.TrendingTags,
DateTime.UtcNow);
}
private static RecCenterDoorSettings NormalizeRecCenterDoors(
IReadOnlyList doors)
{
static string NormalizeKey(string value)
{
var clean = new string((value ?? string.Empty)
.Where(char.IsLetterOrDigit)
.ToArray());
return string.IsNullOrWhiteSpace(clean) ? string.Empty : clean;
}
static string NormalizeQuery(string value)
{
var tags = (value ?? string.Empty)
.Split(['|', ',', '\n', '\r', ' ', '\t'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(t => t.Trim().TrimStart('#').ToLowerInvariant())
.Where(t => t.Length > 0)
.Where(t => t.All(c => char.IsLetterOrDigit(c) || c is '-' or '_'))
.Select(t => $"#{t}")
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(12)
.ToList();
return string.Join("|", tags);
}
var normalized = doors
.Select(d =>
{
var key = NormalizeKey(d.Key);
var title = string.IsNullOrWhiteSpace(d.Title) ? key : d.Title.Trim();
var query = NormalizeQuery(d.Query);
return new RecCenterDoorConfig(key, title, query);
})
.Where(d => d.Key.Length > 0 && d.Query.Length > 0)
.GroupBy(d => d.Key, StringComparer.OrdinalIgnoreCase)
.Select(g => g.First())
.Take(8)
.ToList();
return new RecCenterDoorSettings(
normalized.Count > 0 ? normalized : DefaultRecCenterDoors().Doors,
DateTime.UtcNow);
}
private static DiscoveredGameConfigSettings NormalizeDiscoveredGameConfigs(
DiscoveredGameConfigSettings settings)
{
static string JsonObjectOrDefault(string value)
{
value = string.IsNullOrWhiteSpace(value) ? "{}" : value.Trim();
try
{
using var doc = JsonDocument.Parse(value);
return doc.RootElement.ValueKind == JsonValueKind.Object ? value : "{}";
}
catch
{
return "{}";
}
}
return new DiscoveredGameConfigSettings(
FriendsPostGamePromptUnderFriendCount: Math.Clamp(settings.FriendsPostGamePromptUnderFriendCount, 0, 100),
FriendsSuggestFriendCodeOnFriendsScreenCount: Math.Clamp(settings.FriendsSuggestFriendCodeOnFriendsScreenCount, 0, 100),
ScreensForceVerification: settings.ScreensForceVerification,
VrForceVerification: settings.VrForceVerification,
RewardsUseRewardSelection: settings.RewardsUseRewardSelection,
RewardsSelectionTimeout: Math.Clamp(settings.RewardsSelectionTimeout, 0, 300),
RoomDetailsPhotoRollEnabled: settings.RoomDetailsPhotoRollEnabled,
LoadingNetworkTimeout: Math.Clamp(settings.LoadingNetworkTimeout, 1, 300),
RunningNetworkTimeout: Math.Clamp(settings.RunningNetworkTimeout, 1, 300),
SynchronizedFieldRemoveDefaultEntries: settings.SynchronizedFieldRemoveDefaultEntries,
RenderingDisableSrpBatcher: settings.RenderingDisableSrpBatcher,
SplitTestSoftOverrides: JsonObjectOrDefault(settings.SplitTestSoftOverrides),
SplitTestHardOverrides: JsonObjectOrDefault(settings.SplitTestHardOverrides),
SplitTestSegmentProbabilities: JsonObjectOrDefault(settings.SplitTestSegmentProbabilities),
UpdatedAt: DateTime.UtcNow);
}
///
/// Clamps the limits to what the client can actually survive. Four of
/// these are crash guards rather than taste:
///
/// - Rpcs of 0 divides by zero in the CircuitsV2 debug subscreen,
/// which renders RpcRecentMax / Rpcs.
/// - A time window sizes a per-frame ring buffer, so an
/// unbounded value is an out-of-memory kill at circuit init.
/// - Chips sizes four list preallocations in the chip-culling
/// manager, and InkPerChip × CloudValues is an unchecked int multiply,
/// so both are capped.
/// - The client rejects the whole network array — falling back to
/// the retail 5-RPC budget — unless some entry has
/// NumPlayersForActivation 0, so the lowest entry is forced to 0.
///
///
private static CircuitsV2LimitSettings NormalizeCircuitsV2Limits(
CircuitsV2LimitSettings settings)
{
var seen = new HashSet();
var network = (settings.Network ?? [])
.Where(entry => entry is not null)
.Select(entry => new CircuitsV2NetworkLimit(
NumPlayersForActivation: Math.Clamp(entry.NumPlayersForActivation, 0, 1000),
Bytes: Math.Clamp(entry.Bytes, 1, UnlimitedBudget),
BytesTimeWindowFrame60s: Math.Clamp(entry.BytesTimeWindowFrame60s, 1, MaxTimeWindowFrames),
Rpcs: Math.Clamp(entry.Rpcs, 1, UnlimitedBudget),
RpcTimeWindowFrame60s: Math.Clamp(entry.RpcTimeWindowFrame60s, 1, MaxTimeWindowFrames)))
.OrderBy(entry => entry.NumPlayersForActivation)
.Where(entry => seen.Add(entry.NumPlayersForActivation))
.Take(16)
.ToList();
if (network.Count == 0)
network = DefaultCircuitsV2Limits().Network;
else if (network[0].NumPlayersForActivation != 0)
network[0] = network[0] with { NumPlayersForActivation = 0 };
return new CircuitsV2LimitSettings(
Network: network,
// Compute is the per-frame CPU budget: CircuitsV2Manager's frame
// tick (CircuitsV2Manager.txt OCGDEIJJBAG ISIL 240, :21241)
// builds a fresh BENFNKBKHEA(Compute) every frame, which sums
// four int counters and compares them <= the budget
// (Circuits.V2/BENFNKBKHEA.txt). Nothing is allocated from it,
// but it is the only runaway-loop guard: a budget near
// int.MaxValue lets the summed counters wrap negative and never
// trip. 10M keeps 200x headroom under int.MaxValue.
Compute: Math.Clamp(settings.Compute, 1, 10_000_000),
// The chip-culling manager's ctor preallocates four
// List(Chips) (PAFDMBHNMBP.txt .ctor ISIL 053-108), so
// 0 or a negative is an ArgumentOutOfRange and 1M is 32MB of
// pointer arrays — the ceiling.
Chips: Math.Clamp(settings.Chips, 1, 1_000_000),
// CalculateActiveResourceCost computes
// inkCost += InkPerChip * Math.Min(numChips, CloudValues) in
// plain int (CreationManager.txt ISIL 389-415, :6136-6162).
// With InkPerChip capped at 30000 below, 10000 keeps that
// product at 3e8, well under int.MaxValue.
CloudValues: Math.Clamp(settings.CloudValues, 0, 10_000),
// A non-finite value would serialize as "NaN"/"Infinity", which
// the client's float.TryParse happily accepts.
CloudValuesAutosaveSeconds: float.IsFinite(settings.CloudValuesAutosaveSeconds)
? Math.Clamp(settings.CloudValuesAutosaveSeconds, 1f, 3600f)
: 300f,
UnculledChips: Math.Clamp(settings.UnculledChips, 1, 1_000_000),
VisibleChips: Math.Clamp(settings.VisibleChips, 1, 1_000_000),
// InkPerChip is an ink cost against the room's hard-coded
// 30000-ink cap (CostInfo(0x7530, 64, 101, Chips, CloudValues)
// in CreationManager.OnCircuitsV2LimitsRefreshed, ISIL 060,
// :3374). Past 30000 a single chip is unaffordable, and the
// cap also bounds the int multiply noted on CloudValues. No
// client code divides by it, so 0 is safe.
InkPerChip: Math.Clamp(settings.InkPerChip, 0, 30_000),
UpdatedAt: DateTime.UtcNow);
}
private static CircuitsV2LimitSettings ToCircuitsV2Limits(ServerSettingsEntity row)
{
if (!string.IsNullOrWhiteSpace(row.CircuitsV2LimitsJson))
{
try
{
var parsed = JsonSerializer.Deserialize(
row.CircuitsV2LimitsJson,
JsonOptions);
if (parsed is not null)
return NormalizeCircuitsV2Limits(parsed) with { UpdatedAt = row.UpdatedAt };
}
catch
{
// Bad settings should not break gameconfig bootstrap.
}
}
return DefaultCircuitsV2Limits() with { UpdatedAt = row.UpdatedAt };
}
}
public sealed record WeeklyChallengeSettings(
bool CompletedRequired,
IReadOnlyList 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);
public sealed record PlayMenuTagSettings(
List PinnedTags,
List PopularTags,
List TrendingTags,
DateTime UpdatedAt);
public sealed record RecCenterDoorSettings(
List Doors,
DateTime UpdatedAt);
public sealed record RecCenterDoorConfig(
string Key,
string Title,
string Query);
public sealed record GameConfigurationSetting(
string Key,
string Value,
DateTime? StartTime = null,
DateTime? EndTime = null);
///
/// One entry of the UGC.CircuitsV2.Limit.Network array. The client
/// sorts entries ascending by and
/// activates the highest one whose threshold is at or below the room's max
/// player capacity, so a single entry with a threshold of 0 applies
/// everywhere. Field names are the literal JSON keys the client's
/// Newtonsoft reader binds to LimitConfig.NetworkConfig.
///
public sealed record CircuitsV2NetworkLimit(
int NumPlayersForActivation,
int Bytes,
int BytesTimeWindowFrame60s,
int Rpcs,
int RpcTimeWindowFrame60s);
///
/// The UGC.CircuitsV2.Limit.* gameconfig family, read by
/// RecRoom.CircuitsV2.Config.LimitConfig. Every field maps to one
/// key; the client falls back to its baked-in defaults for any key the
/// server omits or sends unparseably.
///
public sealed record CircuitsV2LimitSettings(
List Network,
int Compute,
int Chips,
int CloudValues,
float CloudValuesAutosaveSeconds,
int UnculledChips,
int VisibleChips,
int InkPerChip,
DateTime UpdatedAt);
public sealed record DiscoveredGameConfigSettings(
int FriendsPostGamePromptUnderFriendCount,
int FriendsSuggestFriendCodeOnFriendsScreenCount,
bool ScreensForceVerification,
bool VrForceVerification,
bool RewardsUseRewardSelection,
int RewardsSelectionTimeout,
bool RoomDetailsPhotoRollEnabled,
float LoadingNetworkTimeout,
float RunningNetworkTimeout,
bool SynchronizedFieldRemoveDefaultEntries,
bool RenderingDisableSrpBatcher,
string SplitTestSoftOverrides,
string SplitTestHardOverrides,
string SplitTestSegmentProbabilities,
DateTime UpdatedAt);