Files
Mocha-2023-RecRoom-Server/Classes/DBs/CreatorFeatureDB.cs
T
mochaisvibecoded 42f7f57b72 :3
2026-08-24 17:00:26 -04:00

1982 lines
79 KiB
C#

using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using LiteDB;
namespace Mocha2023.Classes.DBs
{
public static class CreatorFeatureDB
{
private static readonly object Sync = new();
private static readonly LiteDatabase Database = OpenDatabase();
private static readonly ILiteCollection<InventionRecord> Inventions =
Database.GetCollection<InventionRecord>("Inventions");
private const long MaxClientInventionId = 1_000_000_000;
private static readonly ILiteCollection<InventionCheerRecord> InventionCheers =
Database.GetCollection<InventionCheerRecord>("InventionCheers");
private static readonly ILiteCollection<InventionAliasRecord> InventionAliases =
Database.GetCollection<InventionAliasRecord>("InventionAliases");
private static readonly ILiteCollection<InventionPurchaseRecord> InventionPurchases =
Database.GetCollection<InventionPurchaseRecord>("InventionPurchases");
private static readonly ILiteCollection<MeetupCodeRecord> MeetupCodes =
Database.GetCollection<MeetupCodeRecord>("MeetupCodes");
private static readonly ILiteCollection<CloudVariableRecord> CloudVariables =
Database.GetCollection<CloudVariableRecord>("CloudVariables");
private static readonly ILiteCollection<RoomPlayerDataRecord> RoomPlayerData =
Database.GetCollection<RoomPlayerDataRecord>("RoomPlayerData");
private static LiteDatabase OpenDatabase()
{
string directory = Path.Combine(Program.dataDir, "DBs");
Directory.CreateDirectory(directory);
return new LiteDatabase(Path.Combine(directory, "CreatorFeatures.db"));
}
static CreatorFeatureDB()
{
Directory.CreateDirectory(Path.Combine(Program.dataDir, "DBs"));
LiteDbMaintenance.StartPeriodicCheckpoint("CreatorFeatures.db", Database);
Inventions.EnsureIndex(value => value.CreatorAccountId);
Inventions.EnsureIndex(value => value.RoomId);
Inventions.EnsureIndex(value => value.UpdatedAtUtc);
InventionCheers.EnsureIndex(value => value.InventionId);
InventionCheers.EnsureIndex(value => value.AccountId);
InventionAliases.EnsureIndex(value => value.CurrentInventionId);
InventionPurchases.EnsureIndex(value => value.InventionId);
InventionPurchases.EnsureIndex(value => value.AccountId);
MeetupCodes.EnsureIndex(value => value.CreatorAccountId);
MeetupCodes.EnsureIndex(value => value.RoomInstanceId);
MeetupCodes.EnsureIndex(value => value.ExpiresAtUtc);
CloudVariables.EnsureIndex(value => value.RoomId);
CloudVariables.EnsureIndex(value => value.AccountId);
CloudVariables.EnsureIndex(value => value.UpdatedAtUtc);
RoomPlayerData.EnsureIndex(value => value.RoomId);
RoomPlayerData.EnsureIndex(value => value.AccountId);
RoomPlayerData.EnsureIndex(value => value.UpdatedAtUtc);
MigrateLegacyInventionFiles();
RepairOversizedInventionIds();
BackfillInventionSaveMetadata();
RepairStoredInventionBlobs();
}
public sealed class InventionRecord
{
[BsonId]
public long InventionId { get; set; }
public long CreatorAccountId { get; set; }
public long RoomId { get; set; }
public string Name { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public string ImageName { get; set; } = string.Empty;
public string DataBlob { get; set; } = string.Empty;
public string DataBlobHash { get; set; } = string.Empty;
public long DataBlobSize { get; set; }
public int InstantiationCost { get; set; }
public int LightsCost { get; set; }
public int ChipsCost { get; set; }
public int CloudVariablesCost { get; set; }
public int AiCost { get; set; }
public List<long> ReferencedInventionIds { get; set; } = new();
public int CreatorAccountRole { get; set; }
public List<string> Tags { get; set; } = new();
public int Version { get; set; } = 1;
public bool IsPublished { get; set; } = true;
public int Uses { get; set; }
public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAtUtc { get; set; } = DateTime.UtcNow;
public string RawPayloadJson { get; set; } = "{}";
}
public sealed class InventionAliasRecord
{
[BsonId]
public long LegacyInventionId { get; set; }
public long CurrentInventionId { get; set; }
public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow;
}
public sealed class InventionPurchaseRecord
{
[BsonId]
public string Id { get; set; } = string.Empty;
public long InventionId { get; set; }
public long AccountId { get; set; }
public int PricePaid { get; set; }
public DateTime PurchasedAtUtc { get; set; } = DateTime.UtcNow;
}
public sealed class InventionCheerRecord
{
[BsonId]
public string Id { get; set; } = string.Empty;
public long InventionId { get; set; }
public long AccountId { get; set; }
public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow;
}
public sealed class MeetupCodeRecord
{
[BsonId]
public string Code { get; set; } = string.Empty;
public long CreatorAccountId { get; set; }
public long RoomId { get; set; }
public long SubRoomId { get; set; }
public long RoomInstanceId { get; set; }
public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow;
public DateTime ExpiresAtUtc { get; set; } = DateTime.UtcNow.AddHours(6);
public int UseCount { get; set; }
public int MaxUses { get; set; } = 100;
public bool IsActive { get; set; } = true;
}
public sealed class CloudVariableRecord
{
[BsonId]
public string Id { get; set; } = string.Empty;
public long RoomId { get; set; }
public long AccountId { get; set; }
public string Key { get; set; } = string.Empty;
public string ValueJson { get; set; } = "null";
public int Version { get; set; } = 1;
public DateTime UpdatedAtUtc { get; set; } = DateTime.UtcNow;
public long UpdatedByAccountId { get; set; }
}
public sealed class RoomPlayerDataRecord
{
[BsonId]
public string Id { get; set; } = string.Empty;
public long RoomId { get; set; }
public long AccountId { get; set; }
public string DataJson { get; set; } = "{}";
public int Version { get; set; } = 1;
public DateTime UpdatedAtUtc { get; set; } = DateTime.UtcNow;
public long UpdatedByAccountId { get; set; }
}
public static InventionRecord SaveInvention(
long accountId,
JsonElement payload,
long? forcedInventionId = null)
{
if (accountId <= 0)
throw new ArgumentOutOfRangeException(nameof(accountId));
lock (Sync)
{
bool isForcedImport = forcedInventionId.GetValueOrDefault() > 0;
long requestedId = isForcedImport
? forcedInventionId!.Value
: FindLong(payload,
"InventionId", "inventionId", "Id", "id") ?? 0;
InventionRecord? existing = requestedId > 0
? FindInventionNoLock(requestedId)
: null;
if (existing != null && existing.CreatorAccountId != accountId)
throw new UnauthorizedAccessException(
"An invention can only be edited by its creator.");
bool canPreserveImportedId = isForcedImport &&
requestedId > 0 &&
requestedId <= MaxClientInventionId &&
Inventions.FindById(requestedId) == null;
long inventionId = existing?.InventionId ??
(canPreserveImportedId ? requestedId : NextInventionIdNoLock());
DateTime now = DateTime.UtcNow;
List<string> tags = ReadStringList(payload,
"Tags", "tags", "TagNames", "tagNames");
var record = existing ?? new InventionRecord
{
InventionId = inventionId,
CreatorAccountId = accountId,
CreatedAtUtc = now
};
record.RoomId = FindLong(payload,
"CreationRoomId", "creationRoomId",
"RoomId", "roomId", "SourceRoomId", "sourceRoomId")
?? record.RoomId;
record.InstantiationCost = ReadNonNegativeInt(
payload,
record.InstantiationCost,
"InstantiationCost", "instantiationCost", "InstantiateCost", "instantiateCost");
record.LightsCost = ReadNonNegativeInt(
payload,
record.LightsCost,
"LightsCost", "lightsCost");
record.ChipsCost = ReadNonNegativeInt(
payload,
record.ChipsCost,
"ChipsCost", "chipsCost");
record.CloudVariablesCost = ReadNonNegativeInt(
payload,
record.CloudVariablesCost,
"CloudVariablesCost", "cloudVariablesCost", "CloudVariableCost", "cloudVariableCost");
record.AiCost = ReadNonNegativeInt(
payload,
record.AiCost,
"AiCost", "aiCost", "AICost");
record.CreatorAccountRole = ReadNonNegativeInt(
payload,
record.CreatorAccountRole,
"CreatorAccountRole", "creatorAccountRole");
List<long> referencedInventions = ReadLongList(
payload,
"ReferencedInventions", "referencedInventions",
"ReferencedInventionIds", "referencedInventionIds");
if (referencedInventions.Count > 0)
record.ReferencedInventionIds = referencedInventions.Take(512).ToList();
record.Name = Limit(
FindString(payload,
"Name", "name", "Title", "title",
"InventionName", "inventionName")
?? record.Name,
128);
if (string.IsNullOrWhiteSpace(record.Name))
record.Name = $"Invention {inventionId}";
record.Description = Limit(
FindString(payload, "Description", "description")
?? record.Description,
1_000);
record.ImageName = Limit(
FindString(payload,
"ImageName", "imageName", "Image", "image")
?? record.ImageName,
512);
record.DataBlob = Limit(
FindString(payload,
"ObjectDataFilename", "objectDataFilename",
"ObjectDataBlob", "objectDataBlob",
"SubRoomDataFilename", "subRoomDataFilename",
"DataBlob", "dataBlob", "DataBlobPath", "dataBlobPath",
"Filename", "filename", "FileName", "fileName",
"DataBlobName", "dataBlobName", "BlobName", "blobName")
?? record.DataBlob,
512);
string? requestedBlobHash = FindString(payload,
"DataBlobHash", "dataBlobHash",
"ObjectDataBlobHash", "objectDataBlobHash",
"ObjectDataHash", "objectDataHash", "Hash", "hash");
if (!string.IsNullOrWhiteSpace(requestedBlobHash))
record.DataBlobHash = Limit(requestedBlobHash, 256);
long? requestedBlobSize = FindLong(payload,
"DataBlobSize", "dataBlobSize", "ObjectDataSize", "objectDataSize",
"ContentLength", "contentLength", "Length", "length");
if (requestedBlobSize is >= 0)
record.DataBlobSize = requestedBlobSize.Value;
if (tags.Count > 0)
record.Tags = tags.Take(30).ToList();
bool? requestedPublished = FindBool(payload,
"IsPublished", "isPublished", "Published", "published");
if (requestedPublished.HasValue)
record.IsPublished = requestedPublished.Value;
int requestedVersion = (int)Math.Clamp(
FindLong(payload, "Version", "version") ?? 0,
0,
int.MaxValue);
record.Version = Math.Max(
Math.Max(1, record.Version + (existing == null ? 0 : 1)),
requestedVersion);
record.UpdatedAtUtc = now;
record.RawPayloadJson = payload.GetRawText();
RepairInventionBlobMetadataNoLock(record);
Inventions.Upsert(record);
if (existing == null && requestedId > 0 && requestedId != inventionId)
{
InventionAliases.Upsert(new InventionAliasRecord
{
LegacyInventionId = requestedId,
CurrentInventionId = inventionId,
CreatedAtUtc = now
});
}
return record;
}
}
public static InventionRecord? GetInvention(long inventionId)
{
if (inventionId <= 0)
return null;
lock (Sync)
return FindInventionNoLock(inventionId);
}
public static long ResolveInventionId(long inventionId)
{
InventionRecord? record = GetInvention(inventionId);
return record?.InventionId ?? inventionId;
}
public static bool RecordInventionPurchase(
long accountId,
long inventionId,
int pricePaid = 0)
{
if (accountId <= 0 || inventionId <= 0 || pricePaid < 0)
return false;
lock (Sync)
{
InventionRecord? invention = FindInventionNoLock(inventionId);
if (invention == null ||
(!invention.IsPublished && invention.CreatorAccountId != accountId) ||
!HasValidInventionBlob(invention))
{
return false;
}
InventionPurchases.Upsert(new InventionPurchaseRecord
{
Id = InventionPurchaseId(invention.InventionId, accountId),
InventionId = invention.InventionId,
AccountId = accountId,
PricePaid = pricePaid,
PurchasedAtUtc = DateTime.UtcNow
});
return true;
}
}
public static bool HasInventionAccess(long accountId, long inventionId)
{
if (accountId <= 0 || inventionId <= 0)
return false;
lock (Sync)
{
InventionRecord? invention = FindInventionNoLock(inventionId);
if (invention == null)
return false;
return invention.CreatorAccountId == accountId ||
InventionPurchases.Exists(value =>
value.Id == InventionPurchaseId(invention.InventionId, accountId));
}
}
public static InventionRecord? SetInventionTags(
long inventionId,
long accountId,
IEnumerable<string> tags)
{
if (inventionId <= 0)
return null;
lock (Sync)
{
InventionRecord? record = Inventions.FindById(inventionId);
if (record == null)
return null;
if (record.CreatorAccountId != accountId)
{
throw new UnauthorizedAccessException(
"An invention can only be edited by its creator.");
}
record.Tags = tags
.Select(value => Limit(value.Trim(), 32))
.Where(value => !string.IsNullOrWhiteSpace(value))
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(30)
.ToList();
record.Version = Math.Max(1, record.Version + 1);
record.UpdatedAtUtc = DateTime.UtcNow;
Inventions.Update(record);
return record;
}
}
public static InventionRecord? IncrementInventionUse(long inventionId)
{
if (inventionId <= 0)
return null;
lock (Sync)
{
InventionRecord? record = Inventions.FindById(inventionId);
if (record == null)
return null;
record.Uses = Math.Max(0, record.Uses) + 1;
record.UpdatedAtUtc = DateTime.UtcNow;
Inventions.Update(record);
return record;
}
}
public static bool DeleteInvention(long inventionId, long accountId)
{
lock (Sync)
{
InventionRecord? record = Inventions.FindById(inventionId);
if (record == null || record.CreatorAccountId != accountId ||
!Inventions.Delete(inventionId))
{
return false;
}
InventionCheers.DeleteMany(value => value.InventionId == inventionId);
return true;
}
}
public static bool SetInventionCheer(
long inventionId,
long accountId,
bool cheered)
{
if (inventionId <= 0 || accountId <= 0)
return false;
lock (Sync)
{
if (Inventions.FindById(inventionId) == null)
return false;
string id = InventionCheerId(inventionId, accountId);
if (!cheered)
{
InventionCheers.Delete(id);
return true;
}
InventionCheers.Upsert(new InventionCheerRecord
{
Id = id,
InventionId = inventionId,
AccountId = accountId,
CreatedAtUtc = DateTime.UtcNow
});
return true;
}
}
public static bool IsInventionCheered(long inventionId, long accountId)
{
if (inventionId <= 0 || accountId <= 0)
return false;
return InventionCheers.Exists(value =>
value.Id == InventionCheerId(inventionId, accountId));
}
public static int GetInventionCheerCount(long inventionId) =>
inventionId <= 0
? 0
: InventionCheers.Count(value => value.InventionId == inventionId);
private static string InventionCheerId(long inventionId, long accountId) =>
$"{accountId}:{inventionId}";
public static List<InventionRecord> SearchInventions(
string? query = null,
long? creatorAccountId = null,
long? roomId = null,
string? tag = null,
bool includeUnpublished = false,
int skip = 0,
int take = 100)
{
skip = Math.Max(0, skip);
take = Math.Clamp(take, 1, 100);
string q = query?.Trim() ?? string.Empty;
string wantedTag = tag?.Trim() ?? string.Empty;
IEnumerable<InventionRecord> rows = Inventions.FindAll();
if (!includeUnpublished)
rows = rows.Where(HasValidInventionBlob);
if (!includeUnpublished)
rows = rows.Where(value => value.IsPublished);
if (creatorAccountId.HasValue && creatorAccountId.Value > 0)
rows = rows.Where(value => value.CreatorAccountId == creatorAccountId.Value);
if (roomId.HasValue && roomId.Value > 0)
rows = rows.Where(value => value.RoomId == roomId.Value);
if (!string.IsNullOrWhiteSpace(wantedTag))
{
rows = rows.Where(value => value.Tags?.Any(item =>
string.Equals(item, wantedTag, StringComparison.OrdinalIgnoreCase)) == true);
}
if (!string.IsNullOrWhiteSpace(q))
{
rows = rows.Where(value =>
value.Name.Contains(q, StringComparison.OrdinalIgnoreCase) ||
value.Description.Contains(q, StringComparison.OrdinalIgnoreCase) ||
value.Tags.Any(item =>
item.Contains(q, StringComparison.OrdinalIgnoreCase)));
}
return rows
.OrderByDescending(value => value.Uses)
.ThenByDescending(value => value.UpdatedAtUtc)
.Skip(skip)
.Take(take)
.ToList();
}
public static int CountInventions(
string? query = null,
long? creatorAccountId = null,
long? roomId = null,
string? tag = null,
bool includeUnpublished = false)
{
string q = query?.Trim() ?? string.Empty;
string wantedTag = tag?.Trim() ?? string.Empty;
IEnumerable<InventionRecord> rows = Inventions.FindAll();
if (!includeUnpublished)
rows = rows.Where(HasValidInventionBlob);
if (!includeUnpublished)
rows = rows.Where(value => value.IsPublished);
if (creatorAccountId.HasValue && creatorAccountId.Value > 0)
rows = rows.Where(value => value.CreatorAccountId == creatorAccountId.Value);
if (roomId.HasValue && roomId.Value > 0)
rows = rows.Where(value => value.RoomId == roomId.Value);
if (!string.IsNullOrWhiteSpace(wantedTag))
{
rows = rows.Where(value => value.Tags?.Any(item =>
string.Equals(item, wantedTag, StringComparison.OrdinalIgnoreCase)) == true);
}
if (!string.IsNullOrWhiteSpace(q))
{
rows = rows.Where(value =>
value.Name.Contains(q, StringComparison.OrdinalIgnoreCase) ||
value.Description.Contains(q, StringComparison.OrdinalIgnoreCase) ||
value.Tags.Any(item => item.Contains(q, StringComparison.OrdinalIgnoreCase)));
}
return rows.Count();
}
public static object? ToClientInventionVersion(InventionRecord value)
{
if (!TryGetInventionBlob(
value,
out _,
out string dataBlobPath,
out string dataBlobName,
out string dataBlobHash,
out long dataBlobSize))
{
return null;
}
int versionNumber = Math.Max(1, value.Version);
return new
{
InventionId = value.InventionId,
Id = value.InventionId,
ReplicationId = StableReplicationId(
"invention-version",
value.InventionId,
versionNumber),
VersionNumber = versionNumber,
Version = versionNumber,
InventionVersionId = value.InventionId,
VersionId = value.InventionId,
BlobName = dataBlobName,
DataBlob = dataBlobName,
DataBlobName = dataBlobName,
DataBlobPath = dataBlobPath,
ObjectDataFilename = dataBlobName,
BlobHash = dataBlobHash,
DataBlobHash = dataBlobHash,
BlobSize = dataBlobSize,
DataBlobSize = dataBlobSize,
BlobUrl = "/cdn/" + dataBlobPath.TrimStart('/'),
DownloadUrl = "/cdn/" + dataBlobPath.TrimStart('/'),
InstantiationCost = Math.Max(0, value.InstantiationCost),
LightsCost = Math.Max(0, value.LightsCost),
ChipsCost = Math.Max(0, value.ChipsCost),
CloudVariablesCost = Math.Max(0, value.CloudVariablesCost),
AiCost = Math.Max(0, value.AiCost),
CanSpawn = true
};
}
public static object ToClientInvention(InventionRecord value)
{
bool hasBlob = TryGetInventionBlob(
value,
out _,
out string dataBlobPath,
out string dataBlobName,
out string dataBlobHash,
out long dataBlobSize);
if (hasBlob &&
(!string.Equals(value.DataBlob, dataBlobPath, StringComparison.Ordinal) ||
!string.Equals(value.DataBlobHash, dataBlobHash, StringComparison.Ordinal) ||
value.DataBlobSize != dataBlobSize))
{
lock (Sync)
{
value.DataBlob = dataBlobPath;
value.DataBlobHash = dataBlobHash;
value.DataBlobSize = dataBlobSize;
Inventions.Upsert(value);
}
}
int currentVersionNumber = Math.Max(1, value.Version);
string inventionReplicationId = StableReplicationId(
"invention",
value.InventionId,
0);
object? currentVersion = hasBlob
? ToClientInventionVersion(value)
: null;
return new
{
InventionId = value.InventionId,
Id = value.InventionId,
ReplicationId = inventionReplicationId,
Name = value.Name,
InventionName = value.Name,
Description = value.Description,
CreatorAccountId = value.CreatorAccountId,
CreatorPlayerId = value.CreatorAccountId,
CreatorId = value.CreatorAccountId,
RoomId = value.RoomId,
ImageName = value.ImageName,
Image = value.ImageName,
PreviewImageName = value.ImageName,
DataBlob = hasBlob ? dataBlobName : string.Empty,
DataBlobPath = hasBlob ? dataBlobPath : string.Empty,
DataBlobName = hasBlob ? dataBlobName : string.Empty,
Filename = hasBlob ? dataBlobName : string.Empty,
ObjectDataFilename = hasBlob ? dataBlobName : string.Empty,
ObjectDataBlob = hasBlob ? dataBlobName : string.Empty,
DataBlobHash = hasBlob ? dataBlobHash : string.Empty,
ObjectDataBlobHash = hasBlob ? dataBlobHash : string.Empty,
DataBlobSize = hasBlob ? dataBlobSize : 0,
InstantiationCost = Math.Max(0, value.InstantiationCost),
LightsCost = Math.Max(0, value.LightsCost),
ChipsCost = Math.Max(0, value.ChipsCost),
CloudVariablesCost = Math.Max(0, value.CloudVariablesCost),
AiCost = Math.Max(0, value.AiCost),
ReferencedInventions = value.ReferencedInventionIds ?? new List<long>(),
ReferencedInventionIds = value.ReferencedInventionIds ?? new List<long>(),
DataBlobUrl = hasBlob
? "/cdn/" + dataBlobPath.TrimStart('/')
: string.Empty,
HasValidObjects = hasBlob,
CanSpawn = hasBlob,
Tags = value.Tags ?? new List<string>(),
TagNames = value.Tags ?? new List<string>(),
CurrentVersionNumber = currentVersionNumber,
CurrentVersion = currentVersion,
InventionVersion = currentVersion,
LatestVersion = currentVersion,
Version = currentVersionNumber,
Accessibility = 1,
CreationRoomId = value.RoomId,
CreatorPermission = 100,
GeneralPermission = 20,
IsFeatured = false,
IsAGInvention = false,
IsCertifiedInvention = false,
Price = 0,
AllowTrial = true,
HideFromPlayer = false,
IsPublished = value.IsPublished,
Published = value.IsPublished,
Uses = value.Uses,
NumDownloads = value.Uses,
NumPlayersHaveUsedInRoom = value.Uses,
CheerCount = GetInventionCheerCount(value.InventionId),
CreatedAt = value.CreatedAtUtc,
FirstPublishedAt = value.CreatedAtUtc,
CreationDate = value.CreatedAtUtc,
ModifiedAt = value.UpdatedAtUtc,
UpdatedAt = value.UpdatedAtUtc,
LastUpdated = value.UpdatedAtUtc
};
}
public static bool HasValidInventionBlob(InventionRecord value) =>
TryGetInventionBlob(
value,
out _,
out _,
out _,
out _,
out _);
public static bool TryGetInventionBlob(
InventionRecord value,
out string fullPath,
out string relativePath,
out string filename,
out string hashBase64,
out long size)
{
fullPath = string.Empty;
relativePath = string.Empty;
filename = string.Empty;
hashBase64 = string.Empty;
size = 0;
var candidates = new List<(string Source, string Value)>();
if (!string.IsNullOrWhiteSpace(value.DataBlob))
candidates.Add(("storedDataBlob", value.DataBlob));
if (!string.IsNullOrWhiteSpace(value.RawPayloadJson))
{
try
{
using JsonDocument document = JsonDocument.Parse(value.RawPayloadJson);
CollectInventionBlobCandidates(document.RootElement, candidates);
}
catch (JsonException)
{
}
}
string cdnRoot = Path.GetFullPath(Path.Combine(Program.dataDir, "CDN"));
string inventionDirectory = Path.Combine(cdnRoot, "invention");
string roomDirectory = Path.Combine(cdnRoot, "room");
Directory.CreateDirectory(inventionDirectory);
(string FullPath, string Name, long Size, int Score)? best = null;
foreach ((string source, string rawValue) in candidates)
{
string normalized = NormalizeCandidateBlobValue(rawValue);
if (string.IsNullOrWhiteSpace(normalized))
continue;
string safeName = Path.GetFileName(normalized);
if (safeName.Length == 0 || safeName.Length > 180 ||
safeName.Any(character =>
!char.IsLetterOrDigit(character) &&
character is not '-' and not '_' and not '.'))
{
continue;
}
var blobNames = new List<string> { safeName };
if (safeName.EndsWith(".inv", StringComparison.OrdinalIgnoreCase))
{
string extensionless = safeName[..^4];
if (!string.IsNullOrWhiteSpace(extensionless))
blobNames.Add(extensionless);
}
else
{
string baseName = Path.GetFileNameWithoutExtension(safeName);
if (string.IsNullOrWhiteSpace(baseName))
baseName = safeName;
blobNames.Add(baseName + ".inv");
}
var paths = new List<string>();
if (normalized.Contains('/'))
paths.Add(Path.Combine(cdnRoot, normalized.Replace('/', Path.DirectorySeparatorChar)));
foreach (string blobName in blobNames.Distinct(StringComparer.OrdinalIgnoreCase))
{
paths.Add(Path.Combine(inventionDirectory, blobName));
paths.Add(Path.Combine(roomDirectory, blobName));
paths.Add(Path.Combine(cdnRoot, blobName));
}
foreach (string candidatePath in paths.Distinct(StringComparer.OrdinalIgnoreCase))
{
string resolved;
try
{
resolved = Path.GetFullPath(candidatePath);
}
catch (Exception exception) when (
exception is ArgumentException or NotSupportedException or PathTooLongException)
{
continue;
}
if (!resolved.StartsWith(cdnRoot + Path.DirectorySeparatorChar,
StringComparison.OrdinalIgnoreCase) ||
!File.Exists(resolved))
{
continue;
}
var info = new FileInfo(resolved);
if (info.Length <= 0 || LooksLikeImageFile(resolved))
continue;
int score = ScoreInventionBlobCandidate(source, resolved, info.Length);
if (best == null || score > best.Value.Score)
best = (resolved, info.Name, info.Length, score);
}
}
if (best == null)
return false;
string canonicalBaseName = Path.GetFileNameWithoutExtension(best.Value.Name);
if (string.IsNullOrWhiteSpace(canonicalBaseName))
canonicalBaseName = best.Value.Name;
string canonicalName = best.Value.Name.EndsWith(
".inv",
StringComparison.OrdinalIgnoreCase)
? best.Value.Name
: canonicalBaseName + ".inv";
string canonicalPath = Path.Combine(inventionDirectory, canonicalName);
if (!string.Equals(best.Value.FullPath, canonicalPath, StringComparison.OrdinalIgnoreCase))
{
try
{
if (!File.Exists(canonicalPath))
File.Copy(best.Value.FullPath, canonicalPath, overwrite: false);
}
catch (IOException)
{
}
catch (UnauthorizedAccessException)
{
}
}
fullPath = File.Exists(canonicalPath) ? canonicalPath : best.Value.FullPath;
var finalInfo = new FileInfo(fullPath);
if (!finalInfo.Exists || finalInfo.Length <= 0 || LooksLikeImageFile(fullPath))
return false;
filename = finalInfo.Name;
relativePath = string.Equals(
Path.GetDirectoryName(fullPath),
inventionDirectory,
StringComparison.OrdinalIgnoreCase)
? "invention/" + filename
: "room/" + filename;
size = finalInfo.Length;
if (!string.IsNullOrWhiteSpace(value.DataBlobHash) &&
value.DataBlobSize == size)
{
hashBase64 = value.DataBlobHash;
}
else
{
using FileStream stream = File.OpenRead(fullPath);
hashBase64 = Convert.ToBase64String(SHA256.HashData(stream));
}
return true;
}
private static void RepairInventionBlobMetadataNoLock(InventionRecord value)
{
if (!TryGetInventionBlob(
value,
out _,
out string relativePath,
out _,
out string hashBase64,
out long size))
{
return;
}
value.DataBlob = relativePath;
value.DataBlobHash = hashBase64;
value.DataBlobSize = size;
}
private static void BackfillInventionSaveMetadata()
{
lock (Sync)
{
int repaired = 0;
foreach (InventionRecord record in Inventions.FindAll().ToList())
{
if (string.IsNullOrWhiteSpace(record.RawPayloadJson))
continue;
try
{
using JsonDocument document = JsonDocument.Parse(record.RawPayloadJson);
JsonElement payload = document.RootElement;
bool changed = false;
long roomId = FindLong(payload,
"CreationRoomId", "creationRoomId",
"RoomId", "roomId", "SourceRoomId", "sourceRoomId") ?? 0;
if (record.RoomId <= 0 && roomId > 0)
{
record.RoomId = roomId;
changed = true;
}
changed |= TryBackfillNonNegativeInt(
payload,
record.InstantiationCost,
value => record.InstantiationCost = value,
"InstantiationCost", "instantiationCost", "InstantiateCost", "instantiateCost");
changed |= TryBackfillNonNegativeInt(
payload,
record.LightsCost,
value => record.LightsCost = value,
"LightsCost", "lightsCost");
changed |= TryBackfillNonNegativeInt(
payload,
record.ChipsCost,
value => record.ChipsCost = value,
"ChipsCost", "chipsCost");
changed |= TryBackfillNonNegativeInt(
payload,
record.CloudVariablesCost,
value => record.CloudVariablesCost = value,
"CloudVariablesCost", "cloudVariablesCost", "CloudVariableCost", "cloudVariableCost");
changed |= TryBackfillNonNegativeInt(
payload,
record.AiCost,
value => record.AiCost = value,
"AiCost", "aiCost", "AICost");
changed |= TryBackfillNonNegativeInt(
payload,
record.CreatorAccountRole,
value => record.CreatorAccountRole = value,
"CreatorAccountRole", "creatorAccountRole");
List<long> references = ReadLongList(payload,
"ReferencedInventions", "referencedInventions",
"ReferencedInventionIds", "referencedInventionIds");
if ((record.ReferencedInventionIds == null || record.ReferencedInventionIds.Count == 0) &&
references.Count > 0)
{
record.ReferencedInventionIds = references.Take(512).ToList();
changed = true;
}
if (!changed)
continue;
Inventions.Update(record);
repaired++;
}
catch (JsonException)
{
}
}
if (repaired > 0)
Console.WriteLine($"[INVENTION METADATA REPAIR] repaired={repaired}");
}
}
private static bool TryBackfillNonNegativeInt(
JsonElement payload,
int currentValue,
Action<int> setter,
params string[] names)
{
long? parsed = FindLong(payload, names);
if (!parsed.HasValue)
return false;
int value = (int)Math.Clamp(parsed.Value, 0, int.MaxValue);
if (currentValue == value)
return false;
setter(value);
return true;
}
private static int ReadNonNegativeInt(
JsonElement payload,
int fallback,
params string[] names)
{
long? parsed = FindLong(payload, names);
return parsed.HasValue
? (int)Math.Clamp(parsed.Value, 0, int.MaxValue)
: Math.Max(0, fallback);
}
private static void RepairStoredInventionBlobs()
{
lock (Sync)
{
int repaired = 0;
foreach (InventionRecord record in Inventions.FindAll().ToList())
{
string oldBlob = record.DataBlob;
string oldHash = record.DataBlobHash;
long oldSize = record.DataBlobSize;
RepairInventionBlobMetadataNoLock(record);
if (!string.Equals(oldBlob, record.DataBlob, StringComparison.Ordinal) ||
!string.Equals(oldHash, record.DataBlobHash, StringComparison.Ordinal) ||
oldSize != record.DataBlobSize)
{
Inventions.Update(record);
repaired++;
}
}
if (repaired > 0)
Console.WriteLine($"[INVENTION BLOB REPAIR] repaired={repaired}");
}
}
private static void CollectInventionBlobCandidates(
JsonElement element,
ICollection<(string Source, string Value)> output)
{
if (element.ValueKind == JsonValueKind.Object)
{
foreach (JsonProperty property in element.EnumerateObject())
{
string lowerName = property.Name.ToLowerInvariant();
bool looksLikeBlobField =
lowerName.Contains("blob", StringComparison.Ordinal) ||
lowerName.Contains("filename", StringComparison.Ordinal) ||
lowerName is "file" or "datafile" or "objectdata";
bool looksLikePreview =
lowerName.Contains("image", StringComparison.Ordinal) ||
lowerName.Contains("preview", StringComparison.Ordinal) ||
lowerName.Contains("thumbnail", StringComparison.Ordinal) ||
lowerName.Contains("icon", StringComparison.Ordinal);
if (looksLikeBlobField && !looksLikePreview)
{
if (property.Value.ValueKind == JsonValueKind.String)
{
string? text = property.Value.GetString();
if (!string.IsNullOrWhiteSpace(text))
output.Add((property.Name, text));
}
else if (property.Value.ValueKind is JsonValueKind.Object or JsonValueKind.Array)
{
CollectInventionBlobCandidates(property.Value, output);
}
}
else
{
CollectInventionBlobCandidates(property.Value, output);
}
if (TryParseEmbeddedJson(property.Value, out JsonElement embedded))
CollectInventionBlobCandidates(embedded, output);
}
}
else if (element.ValueKind == JsonValueKind.Array)
{
foreach (JsonElement item in element.EnumerateArray())
CollectInventionBlobCandidates(item, output);
}
else if (TryParseEmbeddedJson(element, out JsonElement embedded))
{
CollectInventionBlobCandidates(embedded, output);
}
}
private static string NormalizeCandidateBlobValue(string? value)
{
string normalized = (value ?? string.Empty).Replace('\\', '/').Trim();
if (normalized.StartsWith("/cdn/", StringComparison.OrdinalIgnoreCase))
normalized = normalized[5..];
normalized = normalized.TrimStart('/');
if (normalized.Contains("..", StringComparison.Ordinal))
return string.Empty;
if (normalized.StartsWith("room/invention/", StringComparison.OrdinalIgnoreCase))
normalized = normalized[5..];
if (normalized.StartsWith("invention/room/", StringComparison.OrdinalIgnoreCase))
normalized = normalized[10..];
return normalized;
}
private static int ScoreInventionBlobCandidate(
string source,
string fullPath,
long length)
{
string lower = source.ToLowerInvariant();
int score = 0;
if (lower.Contains("object", StringComparison.Ordinal)) score += 500;
if (lower.Contains("data", StringComparison.Ordinal)) score += 300;
if (lower.Contains("blob", StringComparison.Ordinal)) score += 200;
if (lower.Contains("filename", StringComparison.Ordinal)) score += 100;
if (fullPath.Contains(
Path.DirectorySeparatorChar + "invention" + Path.DirectorySeparatorChar,
StringComparison.OrdinalIgnoreCase)) score += 50;
score += (int)Math.Min(length / 1024, 100);
return score;
}
private static bool LooksLikeImageFile(string path)
{
try
{
Span<byte> header = stackalloc byte[12];
using FileStream stream = File.OpenRead(path);
int read = stream.Read(header);
if (read >= 8 &&
header[0] == 0x89 && header[1] == 0x50 &&
header[2] == 0x4E && header[3] == 0x47)
return true;
if (read >= 3 && header[0] == 0xFF && header[1] == 0xD8 && header[2] == 0xFF)
return true;
if (read >= 6 &&
header[0] == (byte)'G' && header[1] == (byte)'I' &&
header[2] == (byte)'F')
return true;
if (read >= 12 &&
header[0] == (byte)'R' && header[1] == (byte)'I' &&
header[2] == (byte)'F' && header[3] == (byte)'F' &&
header[8] == (byte)'W' && header[9] == (byte)'E' &&
header[10] == (byte)'B' && header[11] == (byte)'P')
return true;
return read >= 2 && header[0] == (byte)'B' && header[1] == (byte)'M';
}
catch (IOException)
{
return false;
}
catch (UnauthorizedAccessException)
{
return false;
}
}
public static MeetupCodeRecord CreateMeetupCode(
long creatorAccountId,
long roomId,
long subRoomId,
long roomInstanceId,
int maxUses = 100,
int lifetimeMinutes = 360)
{
if (creatorAccountId <= 0 || roomId <= 0 || roomInstanceId <= 0)
throw new ArgumentOutOfRangeException(nameof(roomInstanceId));
lock (Sync)
{
DeleteExpiredMeetupCodesNoLock();
MeetupCodeRecord? existing = MeetupCodes.Find(value =>
value.CreatorAccountId == creatorAccountId &&
value.RoomInstanceId == roomInstanceId &&
value.IsActive)
.OrderByDescending(value => value.CreatedAtUtc)
.FirstOrDefault(value => value.ExpiresAtUtc > DateTime.UtcNow);
if (existing != null)
return existing;
string code;
do
{
code = GenerateMeetupCode();
}
while (MeetupCodes.Exists(value => value.Code == code));
DateTime now = DateTime.UtcNow;
var record = new MeetupCodeRecord
{
Code = code,
CreatorAccountId = creatorAccountId,
RoomId = roomId,
SubRoomId = subRoomId,
RoomInstanceId = roomInstanceId,
CreatedAtUtc = now,
ExpiresAtUtc = now.AddMinutes(Math.Clamp(lifetimeMinutes, 5, 1_440)),
MaxUses = Math.Clamp(maxUses, 1, 1_000),
IsActive = true
};
MeetupCodes.Insert(record);
return record;
}
}
public static MeetupCodeRecord? GetMeetupCode(string? code)
{
string normalized = NormalizeMeetupCode(code);
if (normalized.Length == 0)
return null;
lock (Sync)
{
DeleteExpiredMeetupCodesNoLock();
MeetupCodeRecord? record = MeetupCodes.FindById(normalized);
return record != null && record.IsActive &&
record.ExpiresAtUtc > DateTime.UtcNow &&
record.UseCount < record.MaxUses
? record
: null;
}
}
public static void RecordMeetupCodeUse(string code)
{
lock (Sync)
{
MeetupCodeRecord? record = MeetupCodes.FindById(NormalizeMeetupCode(code));
if (record == null)
return;
record.UseCount++;
if (record.UseCount >= record.MaxUses)
record.IsActive = false;
MeetupCodes.Update(record);
}
}
public static bool RevokeMeetupCode(string code, long callerAccountId)
{
lock (Sync)
{
MeetupCodeRecord? record = MeetupCodes.FindById(NormalizeMeetupCode(code));
if (record == null || record.CreatorAccountId != callerAccountId)
return false;
record.IsActive = false;
return MeetupCodes.Update(record);
}
}
public static object ToClientMeetupCode(MeetupCodeRecord value) => new
{
Code = value.Code,
MeetupCode = value.Code,
CreatorAccountId = value.CreatorAccountId,
RoomId = value.RoomId,
SubRoomId = value.SubRoomId,
RoomInstanceId = value.RoomInstanceId,
CreatedAt = value.CreatedAtUtc,
ExpiresAt = value.ExpiresAtUtc,
UseCount = value.UseCount,
MaxUses = value.MaxUses,
IsActive = value.IsActive
};
public static CloudVariableRecord SetCloudVariable(
long roomId,
long accountId,
string key,
string valueJson,
long updatedByAccountId)
{
if (roomId <= 0 || accountId < 0 || updatedByAccountId <= 0)
throw new ArgumentOutOfRangeException(nameof(roomId));
string normalizedKey = NormalizeCloudKey(key);
if (normalizedKey.Length == 0)
throw new ArgumentException("A cloud variable key is required.", nameof(key));
if (Encoding.UTF8.GetByteCount(valueJson) > 16 * 1024)
throw new ArgumentException("Cloud variable values are limited to 16 KiB.", nameof(valueJson));
lock (Sync)
{
string id = BuildCloudVariableId(roomId, accountId, normalizedKey);
CloudVariableRecord? existing = CloudVariables.FindById(id);
if (existing == null)
{
int count = CloudVariables.Count(value =>
value.RoomId == roomId && value.AccountId == accountId);
if (count >= 512)
throw new InvalidOperationException(
"This room/player scope already has 512 cloud variables.");
}
var record = existing ?? new CloudVariableRecord
{
Id = id,
RoomId = roomId,
AccountId = accountId,
Key = normalizedKey,
Version = 0
};
record.ValueJson = string.IsNullOrWhiteSpace(valueJson) ? "null" : valueJson;
record.Version = Math.Max(1, record.Version + 1);
record.UpdatedAtUtc = DateTime.UtcNow;
record.UpdatedByAccountId = updatedByAccountId;
CloudVariables.Upsert(record);
return record;
}
}
public static CloudVariableRecord? GetCloudVariable(
long roomId,
long accountId,
string key)
{
if (roomId <= 0 || accountId < 0)
return null;
return CloudVariables.FindById(
BuildCloudVariableId(roomId, accountId, NormalizeCloudKey(key)));
}
public static List<CloudVariableRecord> GetCloudVariables(
long roomId,
long accountId,
bool includeShared = true)
{
if (roomId <= 0 || accountId < 0)
return new List<CloudVariableRecord>();
return CloudVariables.Find(value =>
value.RoomId == roomId &&
(value.AccountId == accountId ||
(includeShared && value.AccountId == 0)))
.OrderBy(value => value.Key)
.ThenBy(value => value.AccountId)
.ToList();
}
public static bool DeleteCloudVariable(
long roomId,
long accountId,
string key) =>
CloudVariables.Delete(
BuildCloudVariableId(roomId, accountId, NormalizeCloudKey(key)));
public static RoomPlayerDataRecord SaveRoomPlayerData(
long roomId,
long accountId,
string dataJson,
long updatedByAccountId)
{
if (roomId <= 0 || accountId <= 0 || updatedByAccountId <= 0)
throw new ArgumentOutOfRangeException(nameof(roomId));
string normalizedJson = string.IsNullOrWhiteSpace(dataJson)
? "{}"
: dataJson;
if (Encoding.UTF8.GetByteCount(normalizedJson) > 256 * 1024)
{
throw new ArgumentException(
"Room player data is limited to 256 KiB.",
nameof(dataJson));
}
using (JsonDocument.Parse(normalizedJson))
{
}
lock (Sync)
{
string id = BuildRoomPlayerDataId(roomId, accountId);
RoomPlayerDataRecord? existing = RoomPlayerData.FindById(id);
var record = existing ?? new RoomPlayerDataRecord
{
Id = id,
RoomId = roomId,
AccountId = accountId,
Version = 0
};
record.DataJson = normalizedJson;
record.Version = Math.Max(1, record.Version + 1);
record.UpdatedAtUtc = DateTime.UtcNow;
record.UpdatedByAccountId = updatedByAccountId;
RoomPlayerData.Upsert(record);
return record;
}
}
public static RoomPlayerDataRecord? GetRoomPlayerData(
long roomId,
long accountId)
{
if (roomId <= 0 || accountId <= 0)
return null;
return RoomPlayerData.FindById(BuildRoomPlayerDataId(roomId, accountId));
}
public static bool DeleteRoomPlayerData(long roomId, long accountId)
{
if (roomId <= 0 || accountId <= 0)
return false;
return RoomPlayerData.Delete(BuildRoomPlayerDataId(roomId, accountId));
}
public static object ToClientCloudVariable(CloudVariableRecord value)
{
object? parsedValue;
try
{
using JsonDocument document = JsonDocument.Parse(value.ValueJson);
parsedValue = document.RootElement.Clone();
}
catch (JsonException)
{
parsedValue = value.ValueJson;
}
return new
{
Key = value.Key,
Name = value.Key,
VariableName = value.Key,
CloudVariableName = value.Key,
Value = parsedValue,
VariableValue = parsedValue,
CloudVariableValue = parsedValue,
ValueJson = value.ValueJson,
RoomId = value.RoomId,
AccountId = value.AccountId,
PlayerId = value.AccountId,
IsShared = value.AccountId == 0,
IsPlayerVariable = value.AccountId != 0,
Scope = value.AccountId == 0 ? "room" : "player",
Version = value.Version,
UpdatedAt = value.UpdatedAtUtc,
UpdatedByAccountId = value.UpdatedByAccountId
};
}
private static void MigrateLegacyInventionFiles()
{
string root = Path.Combine(Program.dataDir, "Inventions");
if (!Directory.Exists(root))
return;
int imported = 0;
foreach (string accountDirectory in Directory.EnumerateDirectories(root))
{
if (!long.TryParse(Path.GetFileName(accountDirectory), out long accountId) ||
accountId <= 0)
continue;
foreach (string file in Directory.EnumerateFiles(
accountDirectory,
"*.json",
SearchOption.TopDirectoryOnly))
{
try
{
using JsonDocument document = JsonDocument.Parse(
File.ReadAllText(file));
long fileId = long.TryParse(
Path.GetFileNameWithoutExtension(file),
out long parsedId)
? parsedId
: 0;
if (fileId > 0 && Inventions.FindById(fileId) != null)
continue;
JsonElement payload = document.RootElement.Clone();
SaveInvention(
accountId,
payload,
fileId > 0 ? fileId : null);
imported++;
}
catch (Exception exception)
{
Console.WriteLine(
$"[INVENTION MIGRATION] skipped={file} error={exception.Message}");
}
}
}
if (imported > 0)
Console.WriteLine($"[INVENTION MIGRATION] imported={imported}");
}
private static InventionRecord? FindInventionNoLock(long inventionId)
{
InventionRecord? direct = Inventions.FindById(inventionId);
if (direct != null)
return direct;
InventionAliasRecord? alias = InventionAliases.FindById(inventionId);
return alias == null
? null
: Inventions.FindById(alias.CurrentInventionId);
}
private static string InventionPurchaseId(long inventionId, long accountId) =>
$"{accountId}:{inventionId}";
private static long NextInventionIdNoLock()
{
long max = Inventions.FindAll()
.Where(value => value.InventionId > 0 &&
value.InventionId <= MaxClientInventionId)
.Select(value => value.InventionId)
.DefaultIfEmpty(0)
.Max();
long candidate = Math.Max(1, max + 1);
while (candidate <= MaxClientInventionId &&
Inventions.FindById(candidate) != null)
{
candidate++;
}
if (candidate > MaxClientInventionId)
throw new InvalidOperationException("No client-compatible invention IDs remain.");
return candidate;
}
private static void RepairOversizedInventionIds()
{
lock (Sync)
{
List<InventionRecord> oversized = Inventions.FindAll()
.Where(value => value.InventionId > MaxClientInventionId)
.OrderBy(value => value.CreatedAtUtc)
.ThenBy(value => value.InventionId)
.ToList();
if (oversized.Count == 0)
return;
int repaired = 0;
foreach (InventionRecord record in oversized)
{
long oldId = record.InventionId;
long newId = NextInventionIdNoLock();
record.InventionId = newId;
Inventions.Insert(record);
Inventions.Delete(oldId);
foreach (InventionCheerRecord cheer in InventionCheers
.Find(value => value.InventionId == oldId)
.ToList())
{
string oldCheerId = cheer.Id;
cheer.InventionId = newId;
cheer.Id = InventionCheerId(newId, cheer.AccountId);
InventionCheers.Upsert(cheer);
if (!string.Equals(oldCheerId, cheer.Id, StringComparison.Ordinal))
InventionCheers.Delete(oldCheerId);
}
foreach (InventionPurchaseRecord purchase in InventionPurchases
.Find(value => value.InventionId == oldId)
.ToList())
{
string oldPurchaseId = purchase.Id;
purchase.InventionId = newId;
purchase.Id = InventionPurchaseId(newId, purchase.AccountId);
InventionPurchases.Upsert(purchase);
if (!string.Equals(oldPurchaseId, purchase.Id, StringComparison.Ordinal))
InventionPurchases.Delete(oldPurchaseId);
}
foreach (InventionAliasRecord existingAlias in InventionAliases
.Find(value => value.CurrentInventionId == oldId)
.ToList())
{
existingAlias.CurrentInventionId = newId;
InventionAliases.Update(existingAlias);
}
InventionAliases.Upsert(new InventionAliasRecord
{
LegacyInventionId = oldId,
CurrentInventionId = newId,
CreatedAtUtc = DateTime.UtcNow
});
repaired++;
Console.WriteLine(
$"[INVENTION ID REPAIR] old={oldId} new={newId} name={record.Name}");
}
Console.WriteLine($"[INVENTION ID REPAIR] repaired={repaired}");
}
}
private static string GenerateMeetupCode()
{
const string alphabet = "23456789abcdefghjkmnpqrstuvwxyz";
byte[] bytes = RandomNumberGenerator.GetBytes(8);
var chars = new char[8];
for (int i = 0; i < chars.Length; i++)
chars[i] = alphabet[bytes[i] % alphabet.Length];
return new string(chars);
}
private static string NormalizeMeetupCode(string? code) =>
new((code ?? string.Empty)
.Where(char.IsLetterOrDigit)
.Select(char.ToLowerInvariant)
.Take(16)
.ToArray());
private static void DeleteExpiredMeetupCodesNoLock()
{
DateTime now = DateTime.UtcNow;
MeetupCodes.DeleteMany(value =>
!value.IsActive ||
value.ExpiresAtUtc <= now ||
value.UseCount >= value.MaxUses);
}
private static string NormalizeCloudKey(string? key)
{
string value = (key ?? string.Empty).Trim();
if (value.Length > 128)
value = value[..128];
return value.Replace('\0', '_');
}
public static void MigrateRoomId(long oldRoomId, long newRoomId)
{
lock (Sync)
{
foreach (InventionRecord invention in Inventions.Find(value => value.RoomId == oldRoomId).ToList())
{
invention.RoomId = newRoomId;
Inventions.Update(invention);
}
foreach (CloudVariableRecord variable in CloudVariables.Find(value => value.RoomId == oldRoomId).ToList())
{
CloudVariables.Delete(variable.Id);
variable.Id = BuildCloudVariableId(newRoomId, variable.AccountId, variable.Key);
variable.RoomId = newRoomId;
CloudVariables.Upsert(variable);
}
foreach (RoomPlayerDataRecord data in RoomPlayerData.Find(value => value.RoomId == oldRoomId).ToList())
{
RoomPlayerData.Delete(data.Id);
data.Id = BuildRoomPlayerDataId(newRoomId, data.AccountId);
data.RoomId = newRoomId;
RoomPlayerData.Upsert(data);
}
}
}
private static string BuildCloudVariableId(
long roomId,
long accountId,
string key)
{
byte[] digest = SHA256.HashData(
Encoding.UTF8.GetBytes(key.ToLowerInvariant()));
return $"{roomId}:{accountId}:{Convert.ToHexString(digest)}";
}
private static string BuildRoomPlayerDataId(long roomId, long accountId) =>
$"{roomId}:{accountId}";
private static string Limit(string? value, int maxLength)
{
string result = value?.Trim() ?? string.Empty;
return result.Length <= maxLength ? result : result[..maxLength];
}
private static string StableReplicationId(
string scope,
long inventionId,
int version)
{
byte[] source = Encoding.UTF8.GetBytes(
$"{scope}:{inventionId}:{version}");
byte[] hash = SHA256.HashData(source);
var guidBytes = new byte[16];
Array.Copy(hash, guidBytes, guidBytes.Length);
return new Guid(guidBytes).ToString();
}
private static long? FindLong(JsonElement element, params string[] names)
{
if (element.ValueKind == JsonValueKind.Object)
{
foreach (JsonProperty property in element.EnumerateObject())
{
if (names.Any(name => string.Equals(
name,
property.Name,
StringComparison.OrdinalIgnoreCase)))
{
if (property.Value.ValueKind == JsonValueKind.Number &&
property.Value.TryGetInt64(out long number))
return number;
if (long.TryParse(property.Value.ToString(), out number))
return number;
}
long? nested = FindLong(property.Value, names);
if (nested.HasValue)
return nested;
}
}
else if (element.ValueKind == JsonValueKind.Array)
{
foreach (JsonElement item in element.EnumerateArray())
{
long? nested = FindLong(item, names);
if (nested.HasValue)
return nested;
}
}
else if (TryParseEmbeddedJson(element, out JsonElement embedded))
{
return FindLong(embedded, names);
}
return null;
}
private static string? FindString(JsonElement element, params string[] names)
{
if (element.ValueKind == JsonValueKind.Object)
{
foreach (JsonProperty property in element.EnumerateObject())
{
if (names.Any(name => string.Equals(
name,
property.Name,
StringComparison.OrdinalIgnoreCase)))
{
if (property.Value.ValueKind == JsonValueKind.String)
return property.Value.GetString();
if (property.Value.ValueKind is JsonValueKind.Number or
JsonValueKind.True or JsonValueKind.False)
return property.Value.ToString();
}
string? nested = FindString(property.Value, names);
if (nested != null)
return nested;
}
}
else if (element.ValueKind == JsonValueKind.Array)
{
foreach (JsonElement item in element.EnumerateArray())
{
string? nested = FindString(item, names);
if (nested != null)
return nested;
}
}
else if (TryParseEmbeddedJson(element, out JsonElement embedded))
{
return FindString(embedded, names);
}
return null;
}
private static bool? FindBool(JsonElement element, params string[] names)
{
string? raw = FindString(element, names);
if (bool.TryParse(raw, out bool boolean))
return boolean;
if (int.TryParse(raw, out int number))
return number != 0;
if (element.ValueKind == JsonValueKind.Object)
{
foreach (JsonProperty property in element.EnumerateObject())
{
if (!names.Any(name => string.Equals(
name,
property.Name,
StringComparison.OrdinalIgnoreCase)))
continue;
if (property.Value.ValueKind == JsonValueKind.True)
return true;
if (property.Value.ValueKind == JsonValueKind.False)
return false;
}
}
return null;
}
private static bool TryParseEmbeddedJson(
JsonElement element,
out JsonElement parsed)
{
parsed = default;
if (element.ValueKind != JsonValueKind.String)
return false;
string? raw = element.GetString()?.Trim();
if (string.IsNullOrWhiteSpace(raw) ||
(raw[0] != '{' && raw[0] != '['))
{
return false;
}
try
{
using JsonDocument document = JsonDocument.Parse(raw);
parsed = document.RootElement.Clone();
return true;
}
catch (JsonException)
{
return false;
}
}
private static List<long> ReadLongList(
JsonElement element,
params string[] names)
{
var output = new List<long>();
if (TryParseEmbeddedJson(element, out JsonElement embeddedRoot))
return ReadLongList(embeddedRoot, names);
if (element.ValueKind == JsonValueKind.Array)
{
foreach (JsonElement item in element.EnumerateArray())
{
if (item.ValueKind == JsonValueKind.Number && item.TryGetInt64(out long number))
{
if (number > 0)
output.Add(number);
}
else if (long.TryParse(item.ToString(), out number) && number > 0)
{
output.Add(number);
}
else if (item.ValueKind is JsonValueKind.Object or JsonValueKind.Array)
{
output.AddRange(ReadLongList(item, names));
}
}
return output.Distinct().ToList();
}
if (element.ValueKind != JsonValueKind.Object)
return output;
foreach (JsonProperty property in element.EnumerateObject())
{
bool requested = names.Any(name => string.Equals(
name,
property.Name,
StringComparison.OrdinalIgnoreCase));
if (requested)
{
JsonElement value = property.Value;
if (TryParseEmbeddedJson(value, out JsonElement embedded))
value = embedded;
if (value.ValueKind == JsonValueKind.Array)
{
output.AddRange(ReadLongList(value, names));
}
else if (value.ValueKind == JsonValueKind.Number &&
value.TryGetInt64(out long number) && number > 0)
{
output.Add(number);
}
else
{
foreach (string part in value.ToString().Split(
new[] { ',', ';' },
StringSplitOptions.RemoveEmptyEntries |
StringSplitOptions.TrimEntries))
{
if (long.TryParse(part, out number) && number > 0)
output.Add(number);
}
}
}
else if (property.Value.ValueKind is JsonValueKind.Object or JsonValueKind.Array)
{
output.AddRange(ReadLongList(property.Value, names));
}
else if (TryParseEmbeddedJson(property.Value, out JsonElement embedded))
{
output.AddRange(ReadLongList(embedded, names));
}
}
return output.Distinct().ToList();
}
private static List<string> ReadStringList(
JsonElement element,
params string[] names)
{
var output = new List<string>();
if (element.ValueKind == JsonValueKind.Array)
{
foreach (JsonElement item in element.EnumerateArray())
output.AddRange(ReadStringList(item, names));
return output
.Where(value => !string.IsNullOrWhiteSpace(value))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
}
if (TryParseEmbeddedJson(element, out JsonElement embeddedRoot))
return ReadStringList(embeddedRoot, names);
if (element.ValueKind != JsonValueKind.Object)
return output;
foreach (JsonProperty property in element.EnumerateObject())
{
bool isRequestedProperty = names.Any(name => string.Equals(
name,
property.Name,
StringComparison.OrdinalIgnoreCase));
if (!isRequestedProperty)
{
if (property.Value.ValueKind is JsonValueKind.Object or
JsonValueKind.Array)
{
output.AddRange(ReadStringList(property.Value, names));
}
else if (TryParseEmbeddedJson(
property.Value,
out JsonElement embedded))
{
output.AddRange(ReadStringList(embedded, names));
}
continue;
}
JsonElement tagValue = property.Value;
if (TryParseEmbeddedJson(tagValue, out JsonElement parsedTags))
tagValue = parsedTags;
if (tagValue.ValueKind == JsonValueKind.Array)
{
foreach (JsonElement item in tagValue.EnumerateArray())
{
string? tag = item.ValueKind == JsonValueKind.String
? item.GetString()
: item.ValueKind == JsonValueKind.Object
? FindString(item, "Tag", "tag", "Name", "name")
: item.ToString();
if (!string.IsNullOrWhiteSpace(tag))
output.Add(Limit(tag, 32));
}
}
else
{
foreach (string item in tagValue.ToString().Split(
new[] { ',', ';' },
StringSplitOptions.RemoveEmptyEntries |
StringSplitOptions.TrimEntries))
{
output.Add(Limit(item, 32));
}
}
}
return output
.Where(value => !string.IsNullOrWhiteSpace(value))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
}
}
}