using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using DorkNet.Models.Notification; using DorkNet.Server.Auth; using DorkNet.Server.Data; using DorkNet.Server.Data.Entities; using DorkNet.Server.Services; namespace DorkNet.Server.Controllers.API.Inventions; /// /// api.rec.net/api/inventions/* — Maker-Pen creations the player can /// re-spawn or share. URL surface + verbs verified against /// Cpp2IL_ISIL/.../RecNet/Inventions.txt: the "update*" /// routes use Core.Get (GET) despite being mutations, so we /// expose them as [HttpGet]; only cheer, report, /// settags, save, addversion, and batch /// are POST. delete, publish, unpublish, /// download are also GET. /// /// Wire DTO matches the watch's OBBBPCBIMME.PPGFHEDFBEA /// deserializer (Cpp2IL ISIL OBBBPCBIMME.txt:588-704): keys /// InventionId, ReplicationId, CreatorPlayerId, Name, Description, /// ImageName, CurrentVersionNumber, IsPublished, AllowTrial, /// ModifiedAt, CreatedAt, FirstPublishedAt, CreationRoomId, /// NumPlayersHaveUsedInRoom, NumDownloads, CheerCount, /// CreatorPermission, GeneralPermission, IsAGInvention, Price, /// HideFromPlayer. Casing is significant — note IsAGInvention /// has a capital G. /// [ApiController] public class InventionsController( DorkNetDbContext db, LevelService level, NotificationService notifications) : ControllerBase { private long? CurrentPlayerIdOrNull => this.CurrentPlayerId(); private long CurrentPlayerId => this.RequireCurrentPlayerId(); // ── Browse ─────────────────────────────────────────────────────────── [HttpGet("api/inventions/v1/featured")] [HttpGet("api/inventions/v3/popular")] public async Task Popular([FromQuery] int take = 50) { take = Math.Clamp(take, 1, 100); var rows = await db.Inventions .Where(i => !i.IsDeleted && i.IsPublished) .OrderByDescending(i => i.CheerCount) .Take(take) .ToListAsync(); return Ok(rows.Select(ToWire)); } /// GET api/inventions/v1/toptoday — the client fires /// this as its own no-arg shelf query /// (OEGFNFEAAGO_NestedType___c_NestedType___GetTopInventionsToday_b__43_0_d.txt:147, /// verb 0) and shows it NEXT TO the featured shelf, so aliasing it onto /// the all-time popular query rendered the same twelve tiles twice. /// Ranked by cheers received in the last 24h, then backfilled with /// all-time popular so a quiet day still fills the shelf. [HttpGet("api/inventions/v1/toptoday")] public async Task TopToday([FromQuery] int take = 50) { take = Math.Clamp(take, 1, 100); var since = DateTime.UtcNow.AddDays(-1); var hot = await db.Cheers .Where(c => c.TargetInventionId > 0 && c.CheeredAt >= since) .GroupBy(c => c.TargetInventionId) .Select(g => new { InventionId = g.Key, Score = g.Count() }) .OrderByDescending(x => x.Score) .Take(take) .ToListAsync(); var order = hot.Select(h => h.InventionId).ToList(); var hits = order.Count == 0 ? new List() : await db.Inventions .Where(i => !i.IsDeleted && i.IsPublished && order.Contains(i.Id)) .ToListAsync(); var ranked = order .Select(id => hits.FirstOrDefault(r => r.Id == id)) .Where(r => r is not null) .Select(r => r!) .ToList(); if (ranked.Count < take) { var have = ranked.Select(r => r.Id).ToList(); var filler = await db.Inventions .Where(i => !i.IsDeleted && i.IsPublished && !have.Contains(i.Id)) .OrderByDescending(i => i.CheerCount) .Take(take - ranked.Count) .ToListAsync(); ranked.AddRange(filler); } return Ok(ranked.Select(ToWire)); } /// GET api/inventions/v1/featureddormskins — separate /// shelf in the client /// (OEGFNFEAAGO_NestedType___c_NestedType___GetFeaturedDormSkins_b__45_0_d.txt:147, /// verb 0, no params) which previously returned generic popular /// inventions. has no dorm-skin column, so /// the marker lives in — see /// . The filter runs in memory because /// TagsCsv is a packed list no provider can split. [HttpGet("api/inventions/v1/featureddormskins")] public async Task FeaturedDormSkins([FromQuery] int take = 50) { take = Math.Clamp(take, 1, 100); var rows = await db.Inventions .Where(i => !i.IsDeleted && i.IsPublished && i.TagsCsv != "") .OrderByDescending(i => i.CheerCount) .Take(500) .ToListAsync(); return Ok(rows.Where(i => IsDormSkinTags(i.TagsCsv)).Take(take).Select(ToWire)); } /// Dorm-skin marker. There is no IsDormSkin column on /// , so a tag decides: any tag that reads /// "dormskin" / "dorm skin" (case- and space-insensitive). private static bool IsDormSkinTags(string? tagsCsv) => !string.IsNullOrEmpty(tagsCsv) && tagsCsv .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Any(t => t.Replace(" ", string.Empty) .Equals("dormskin", StringComparison.OrdinalIgnoreCase)); [HttpGet("api/inventions/v3/saved")] [Authorize] public async Task Saved([FromQuery] int take = 50) { take = Math.Clamp(take, 1, 100); var pid = CurrentPlayerId; var rows = await db.Inventions .Where(i => !i.IsDeleted && i.CreatorPlayerId == pid) .OrderByDescending(i => i.UpdatedAt) .Take(take) .ToListAsync(); return Ok(rows.Select(ToWire)); } [HttpGet("api/inventions/v3/created")] [Authorize] public Task Created([FromQuery] int take = 50) => Saved(take); /// GetLocalPlayerInventions — same as Saved but /// at v1 path. Watch hits both depending on tab. [HttpGet("api/inventions/v1/mine")] [HttpGet("api/inventions/v2/mine")] [Authorize] public Task Mine() => Saved(100); /// The 2023 client sends value + skip + take on every store /// search (OEGFNFEAAGO.txt:10805/10820/10832 — three AFGEDDANEKP pairs /// on a verb-0 request). Ignoring skip/take pinned every page to the /// first 50 rows, so scrolling the store re-showed the same tiles. [HttpGet("api/inventions/v1/search")] [HttpGet("api/inventions/v2/search")] public async Task Search( [FromQuery] string value = "", [FromQuery] int skip = 0, [FromQuery] int take = 100) { skip = Math.Max(0, skip); take = Math.Clamp(take, 1, 200); if (string.IsNullOrWhiteSpace(value)) return Ok(Array.Empty()); // The watch's @-prefix means "search by player". Two callers // use this with different payload shapes — both end up here: // * PlayerDetailsWatchUIFlow.SetAccount → @ (numeric) // loads the Inventions tab on a player profile. Verified // in Cpp2IL — see PlayerDetailsWatchUIFlow.txt:1867,1950. // * Inventions search UI → @ (text) free-text // prefix match on Username. if (value.StartsWith('@')) { var tail = value[1..]; List creators; if (long.TryParse(tail, out var accountId)) { creators = new List { accountId }; } else { creators = await db.Players .Where(p => p.Username.StartsWith(tail)) .Select(p => p.Id).Take(50).ToListAsync(); } var byCreator = await db.Inventions .Where(i => !i.IsDeleted && i.IsPublished && creators.Contains(i.CreatorPlayerId)) .OrderByDescending(i => i.CheerCount) .Skip(skip) .Take(take) .ToListAsync(); return Ok(byCreator.Select(ToWire)); } var v = value.ToLowerInvariant(); var rows = await db.Inventions .Where(i => !i.IsDeleted && i.IsPublished && (i.Name.ToLower().Contains(v) || i.TagsCsv.ToLower().Contains(v))) .OrderByDescending(i => i.CheerCount) .Skip(skip) .Take(take) .ToListAsync(); return Ok(rows.Select(ToWire)); } // ── Id-list endpoints (runtime GET/POST) ───────────────────────────── // // The 2023 client picks the verb at RUNTIME for every id-list route: // ALHIJCJOLCB.JIECAFGCODK (IsilDump/RecNet.Runtime/ALHIJCJOLCB.txt:3198) // is `count < limit ? GET : POST` with limit=100. On a non-GET the // key/value pairs move out of the query string and into an // HTTPUrlEncodedForm body — BNDIAONDFFF.txt:2971-3010 branches on the // verb (GET → "?a=b&…", otherwise → HTTPUrlEncodedForm). So the POST // actions must NOT declare a complex parameter, or [ApiController] // infers [FromBody] and answers 415 before the handler runs; we read // query + form by hand instead. // // Field names are literal: // "id" → v2/batch (OEGFNFEAAGO_NestedType_LANMKGKDNDC.txt:228) // "id" → fromcreators (…CDILMPCKMEO…GetInventionsByCreators_b__0_d.txt:330) // "ids" → dormskinsfromids (OEGFNFEAAGO.txt:5913) /// Body shape for non-2023 callers, which still send a JSON /// {"InventionIds":[…]} envelope rather than repeated fields. private sealed class InventionIdsBody { public List? InventionIds { get; set; } public List? Ids { get; set; } public List? Id { get; set; } } private static readonly System.Text.Json.JsonSerializerOptions IdsJson = new() { PropertyNameCaseInsensitive = true }; /// Gather ids from the query string, the url-encoded form and /// (as a fallback) a JSON id envelope. See the block comment above for /// why all three have to be accepted on one route. private async Task> CollectIdsAsync(params string[] keys) { var ids = new List(); void Take(string? raw) { if (string.IsNullOrEmpty(raw)) return; foreach (var part in raw.Split( ',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) { if (long.TryParse(part, out var id) && id > 0) ids.Add(id); } } var form = Request.HasFormContentType ? await Request.ReadFormAsync() : null; foreach (var key in keys) { foreach (var v in Request.Query[key]) Take(v); if (form is not null) foreach (var v in form[key]) Take(v); } if (ids.Count == 0 && form is null && (Request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) ?? false)) { try { var body = await System.Text.Json.JsonSerializer .DeserializeAsync(Request.Body, IdsJson); foreach (var list in new[] { body?.InventionIds, body?.Ids, body?.Id }) { if (list is not null) ids.AddRange(list.Where(id => id > 0)); } } catch (System.Text.Json.JsonException) { // Not an id envelope — treat as "no ids" rather than 400. } } return ids.Distinct().Take(200).ToList(); } /// Read one scalar field from the query string, falling back to /// the url-encoded form body (POST variants — see the block comment /// above). private async Task ReadFieldAsync(string key) { var fromQuery = Request.Query[key].FirstOrDefault(); if (!string.IsNullOrEmpty(fromQuery)) return fromQuery; if (Request.HasFormContentType) { var form = await Request.ReadFormAsync(); var fromForm = form[key].FirstOrDefault(); if (!string.IsNullOrEmpty(fromForm)) return fromForm; } return null; } private async Task ReadIntFieldAsync(string key) => int.TryParse(await ReadFieldAsync(key), out var v) ? v : null; private async Task ReadLongFieldAsync(string key) => long.TryParse(await ReadFieldAsync(key), out var v) ? v : null; [HttpGet("api/inventions/v1/batch")] [HttpGet("api/inventions/v2/batch")] [HttpPost("api/inventions/v1/batch")] [HttpPost("api/inventions/v2/batch")] public async Task Batch() => await BatchIdsAsync(await CollectIdsAsync("id", "ids", "InventionIds")); /// api/inventions/v1/dormskinsfromids — the client /// deserialises a BARE List<Int64> here, not a list of /// invention objects: the continuation at OEGFNFEAAGO.txt:5941 is typed /// FGLDKEJLAKB<List`1<Int64>> and is then projected by a /// Func<List<Int64>, List<Int64>> (:5966). The reply is the /// SUBSET of the posted ids that are dorm skins; aliasing this onto the /// generic batch handler fed objects to a number reader and killed /// dorm-skin filtering outright. [HttpGet("api/inventions/v1/dormskinsfromids")] [HttpPost("api/inventions/v1/dormskinsfromids")] public async Task DormSkinsFromIds() { var ids = await CollectIdsAsync("ids", "id", "InventionIds"); if (ids.Count == 0) return Ok(Array.Empty()); var rows = await db.Inventions .Where(i => !i.IsDeleted && ids.Contains(i.Id)) .Select(i => new { i.Id, i.TagsCsv }) .ToListAsync(); return Ok(rows.Where(r => IsDormSkinTags(r.TagsCsv)).Select(r => r.Id).ToList()); } private async Task BatchIdsAsync(IReadOnlyCollection requestedIds) { if (requestedIds.Count == 0) return Ok(Array.Empty()); var ids = requestedIds.Take(200).ToList(); var rows = await db.Inventions .Where(i => !i.IsDeleted && ids.Contains(i.Id)) .ToListAsync(); return Ok(rows.Select(ToWire)); } [HttpGet("api/inventions/v1/fromcreators")] [HttpPost("api/inventions/v1/fromcreators")] public async Task FromCreators() { // Only the literal "id" key carries creator ids. The previous // SelectMany-over-every-query-field also swallowed the client's // "skip"/"take" pairs, so take=100 quietly added player 100's // inventions to the shelf. var creatorIds = await CollectIdsAsync("id"); if (creatorIds.Count == 0) return Ok(Array.Empty()); var skip = Math.Max(0, await ReadIntFieldAsync("skip") ?? 0); var take = Math.Clamp(await ReadIntFieldAsync("take") ?? 100, 1, 200); var rows = await db.Inventions .Where(i => !i.IsDeleted && i.IsPublished && creatorIds.Contains(i.CreatorPlayerId)) .OrderByDescending(i => i.UpdatedAt) .Skip(skip) .Take(take) .ToListAsync(); return Ok(rows.Select(ToWire)); } /// GET api/inventions/v1/tagfilters — drives /// RecNet.Inventions.GetTags. The watch deserialises the /// response as RecNet.GetFiltersResponse (an /// IRecNetObject with PinnedFilters + /// PopularFilters string-list properties), then a /// Func<GetFiltersResponse, List<string>> /// projects whichever list the caller wanted. Verified at /// Cpp2IL_ISIL/.../RecNet/Inventions.txt:4834-4852 — the URL /// literal is followed by a typeof(Func`2<GetFiltersResponse, /// List`1<String>>). Returning a bare string array /// (the previous shape) tripped the LitJson importer's /// List → Dictionary cast inside Util.Deserialize<T> /// and surfaced as "Failed to get tags: Malformed Response" /// (output_log.txt:1602+1639) — and that bubbles up as the /// dorm-load destabilisation we've been hunting. [HttpGet("api/inventions/v1/tagfilters")] public IActionResult TagFilters() { var tags = new[] { "sport", "game", "vehicle", "weapon", "decor", "tool", "art", "music", "puzzle", "combat", "build", "race", }; return Ok(new { PinnedFilters = tags, PopularFilters = tags, }); } /// GET api/inventions/v1/creatorIds — the distinct /// creator account ids of every invention placed in a room /// (GetCreatorIdsOfInventionsInRoom). The watch uses this to /// resolve which creators it must request invention permissions for /// before spawning a room's invention-based geometry. Returns /// List<int> — empty when the room has no tracked /// inventions. [HttpGet("api/inventions/v1/creatorIds")] public async Task CreatorIdsInRoom([FromQuery] long roomId) { var inventions = await ResolveRoomInventionsAsync(roomId); var creatorIds = inventions .Select(i => (int)i.CreatorPlayerId) .Distinct() .ToList(); return Ok(creatorIds); } /// Resolve the inventions that belong to a room. Direct /// matches are inventions whose /// equals the room's /// local id (RR-Originals, dorms, anything created in-place). /// Zip-imported rooms instead carry each invention's ORIGINAL Rec /// Room CreationRoomId, which won't equal our local id and can't be /// reverse-resolved without an OriginalRoomId column; we approximate /// that cluster as the most-frequent non-local CreationRoomId among /// the room creator's inventions. The two sets are unioned so a room /// with both kinds resolves fully. private async Task> ResolveRoomInventionsAsync(long roomId) { var creatorId = await db.Rooms .Where(r => r.Id == roomId) .Select(r => (long?)r.CreatorPlayerId) .FirstOrDefaultAsync(); long? importedRoomId = null; if (creatorId is long creator) { importedRoomId = await db.Inventions .Where(i => !i.IsDeleted && i.CreatorPlayerId == creator && i.CreationRoomId != null && i.CreationRoomId != roomId) .GroupBy(i => i.CreationRoomId) .OrderByDescending(g => g.Count()) .Select(g => g.Key) .FirstOrDefaultAsync(); } return await db.Inventions .Where(i => !i.IsDeleted && (i.CreationRoomId == roomId || (importedRoomId != null && i.CreationRoomId == importedRoomId))) .ToListAsync(); } /// GET api/inventions/v1/fulllineageowner — /// raw Boolean. The client uses this to decide whether a set of /// referenced inventions can be saved together under one owner. /// DorkNet tracks current creator ownership, so the concrete rule /// here is: every requested invention id must exist and share the /// same CreatorPlayerId. [HttpGet("api/inventions/v1/fulllineageowner")] public async Task FullLineageOwner() { var ids = Request.Query .SelectMany(q => q.Value) .SelectMany(v => (v ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) .Select(v => long.TryParse(v, out var id) ? id : 0L) .Where(id => id > 0) .Distinct() .Take(200) .ToList(); if (ids.Count == 0) return Content("true", "application/json"); var owners = await db.Inventions .Where(i => !i.IsDeleted && ids.Contains(i.Id)) .Select(i => new { i.Id, i.CreatorPlayerId }) .ToListAsync(); var ok = owners.Count == ids.Count && owners.Select(o => o.CreatorPlayerId).Distinct().Count() <= 1; return Content(ok ? "true" : "false", "application/json"); } // ── Single fetch ───────────────────────────────────────────────────── /// GET /api/inventions/v1/room?id={roomId} — every /// invention used by the room. The 2020.12 watch fires this on /// EVERY room load right after fetching the room data blob; it uses /// the response to pre-resolve invention references the blob will /// spawn. Without this, imported rooms with invention-based /// geometry (floors, walls, custom shapes) render with everything /// missing — the watch silently skips invention spawns when it /// has no list, and you fall through the void. /// /// We match on which /// the zip importer populates from each Invention.json's /// CreationRoomId field. Returns an array of /// -shaped Invention objects (same wire shape /// the single-fetch endpoint returns) so the watch's existing /// deserializer works. [HttpGet("api/inventions/v1/room")] public async Task ByRoom([FromQuery] long id) { var inventions = await ResolveRoomInventionsAsync(id); return Ok(inventions.Select(ToWire).ToList()); } [HttpGet("api/inventions/v1")] public async Task SingleByQuery([FromQuery] long inventionId) { var i = await db.Inventions.FirstOrDefaultAsync(x => x.Id == inventionId && !x.IsDeleted); if (i is null) return NotFound(); if (!i.IsPublished && i.CreatorPlayerId != CurrentPlayerIdOrNull) return Forbid(); return Ok(ToWire(i)); } /// POST api/storefronts/v1/trialInvention?inventionId={id} /// — start a free trial of a store invention. Client /// (RecNet.Runtime DCFKEFHJAGC.PPCLLFMHJLD(long)) expects a SINGLE /// Invention DTO back (same shape it uses for /// search), which it spawns for the trial window (length from /// trialInvention/duration). Hosted here — not in /// StorefrontsController — so it can reuse the invention wire builder. /// We record the trial start so it's an auditable action. [HttpPost("api/storefronts/v1/trialInvention")] [Authorize] public async Task TrialInvention([FromQuery] long inventionId) { var i = await db.Inventions.FirstOrDefaultAsync(x => x.Id == inventionId && !x.IsDeleted); if (i is null) return NotFound(); if (!i.IsPublished && i.CreatorPlayerId != CurrentPlayerIdOrNull) return Forbid(); if (CurrentPlayerIdOrNull is long me) { var key = $"invention:trial:{inventionId}"; var row = await db.PlayerSettings.FirstOrDefaultAsync(s => s.PlayerId == me && s.Key == key); if (row is null) db.PlayerSettings.Add(new Data.Entities.PlayerSettingEntity { PlayerId = me, Key = key, Value = DateTime.UtcNow.ToString("O"), }); else row.Value = DateTime.UtcNow.ToString("O"); await db.SaveChangesAsync(); } return Ok(ToWire(i)); } [HttpGet("api/inventions/v1/details")] public async Task Details([FromQuery] long inventionId) { var i = await db.Inventions.FirstOrDefaultAsync(x => x.Id == inventionId && !x.IsDeleted); if (i is null) return NotFound(); if (!i.IsPublished && i.CreatorPlayerId != CurrentPlayerIdOrNull) return Forbid(); var versions = await db.InventionVersions .Where(v => v.InventionId == inventionId) .OrderByDescending(v => v.VersionNumber) .ToListAsync(); return Ok(new { Invention = ToWire(i), Versions = versions.Select(ToVersionWire), }); } [HttpGet("api/inventions/v1/personaldetails/{id:long}")] [Authorize] public async Task PersonalDetails(long id) { var i = await db.Inventions.FirstOrDefaultAsync(x => x.Id == id && !x.IsDeleted); if (i is null) return NotFound(); return Ok(new { Invention = ToWire(i), CanEdit = i.CreatorPlayerId == CurrentPlayerId, }); } [HttpGet("api/inventions/v1/versions")] public async Task Versions([FromQuery] long inventionId) { var rows = await db.InventionVersions .Where(v => v.InventionId == inventionId) .OrderByDescending(v => v.VersionNumber) .ToListAsync(); return Ok(rows.Select(ToVersionWire)); } [HttpGet("api/inventions/v1/version")] public async Task Version( [FromQuery] long inventionId, [FromQuery] int version) { var v = await db.InventionVersions .FirstOrDefaultAsync(x => x.InventionId == inventionId && x.VersionNumber == version); if (v is null) return NotFound(); return Ok(ToVersionWire(v)); } // ── Create / version ───────────────────────────────────────────────── public sealed class SaveInventionV4Request { public string? Name { get; set; } public string? Description { get; set; } public string? ImageName { get; set; } public int InstantiationCost { get; set; } public int LightsCost { get; set; } public int AiCost { get; set; } public long? CreationRoomId { get; set; } public string? InventionDataFilename { get; set; } public List? ReferencedInventions { get; set; } public int CreatorAccountRole { get; set; } } /// POST api/inventions/v4/save — 2020.12 maker-pen /// save flow. v4 wraps the new invention plus its first version in /// a Status/Invention/InventionVersion object. [HttpPost("api/inventions/v4/save")] [HttpPost("api/inventions/v6/save")] [Authorize] public async Task SaveV4([FromBody] SaveInventionV4Request req) { var pid = CurrentPlayerId; var name = (req.Name ?? string.Empty).Trim(); if (string.IsNullOrWhiteSpace(name)) return BadRequest(new { Status = 1, Error = "missing name" }); var blobName = (req.InventionDataFilename ?? string.Empty).Trim(); if (string.IsNullOrWhiteSpace(blobName)) return BadRequest(new { Status = 1, Error = "missing inventionDataFilename" }); var inv = new InventionEntity { CreatorPlayerId = pid, ReplicationId = Guid.NewGuid().ToString("D"), Name = name, Description = req.Description ?? string.Empty, ImageName = req.ImageName ?? string.Empty, Permission = 0, CurrentBlobName = blobName, TagsCsv = string.Empty, CreationRoomId = req.CreationRoomId, CreatorPermission = 100, GeneralPermission = 0, IsPublished = false, CurrentVersionNumber = 1, }; db.Inventions.Add(inv); await db.SaveChangesAsync(); var v1 = new InventionVersionEntity { InventionId = inv.Id, ReplicationId = Guid.NewGuid().ToString("D"), VersionNumber = 1, BlobName = blobName, InstantiationCost = Math.Max(0, req.InstantiationCost), LightsCost = Math.Max(0, req.LightsCost), }; db.InventionVersions.Add(v1); if (req.ReferencedInventions is { Count: > 0 }) { foreach (var referencedId in req.ReferencedInventions.Distinct().Take(200)) { db.ObjectiveProgress.Add(new ObjectiveProgressEntity { PlayerId = pid, Key = $"invention:{inv.Id}:references:{referencedId}", IsCompleted = true, ClearedAt = DateTime.UtcNow, }); } } await db.SaveChangesAsync(); await level.AwardXpAsync(pid, LevelService.InventionSavedXp, $"invention_save:{inv.Id}"); return Ok(new { Status = 0, Invention = ToWireV4(inv), InventionVersion = ToVersionWire(v1), }); } /// The 2023-03-21 client names the blob field /// inventionDataFilename and sends four extra cost fields plus the /// creation room and referenced inventions. The record only had /// BlobName, which the client never sends, so the required-parameter /// check rejected every publish with 400 and no new invention version could /// ever be saved. public sealed record AddVersionRequest( long InventionId, string? BlobName, string? InventionDataFilename, int? InstantiationCost, int? LightsCost, int? ChipsCost, int? CloudVariablesCost, int? AiCost, long? CreationRoomId, List? ReferencedInventions) { public string? Blob => !string.IsNullOrWhiteSpace(InventionDataFilename) ? InventionDataFilename : BlobName; } [HttpPost("api/inventions/v3/addversion")] [HttpPost("api/inventions/v4/addversion")] [Authorize] public async Task AddVersion([FromBody] AddVersionRequest req) { var pid = CurrentPlayerId; var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == req.InventionId && !x.IsDeleted); if (inv is null) return NotFound(); if (inv.CreatorPlayerId != pid) return Forbid(); var blobName = req.Blob; if (string.IsNullOrWhiteSpace(blobName)) return BadRequest("missing inventionDataFilename"); var nextVer = inv.CurrentVersionNumber + 1; inv.CurrentBlobName = blobName; inv.CurrentVersionNumber = nextVer; inv.UpdatedAt = DateTime.UtcNow; db.InventionVersions.Add(new InventionVersionEntity { InventionId = inv.Id, ReplicationId = Guid.NewGuid().ToString("D"), VersionNumber = nextVer, BlobName = blobName, InstantiationCost = req.InstantiationCost ?? 0, LightsCost = req.LightsCost ?? 0, }); await db.SaveChangesAsync(); return Ok(ToWire(inv)); } // ── GET-based mutations (Core.Get pattern) ─────────────────────────── /// Watch sends ONE of name/description/imgName/permission /// per request — `UpdateInvention*` ISIL builds different /// query strings. Pull whichever ones came in and apply. [HttpGet("api/inventions/v1/update")] [Authorize] public async Task Update( [FromQuery] long inventionId, [FromQuery] string? name, [FromQuery] string? description, [FromQuery] string? imgName, [FromQuery] int? permission) { var pid = CurrentPlayerId; var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == inventionId && !x.IsDeleted); if (inv is null) return NotFound(); if (inv.CreatorPlayerId != pid) return Forbid(); if (!string.IsNullOrWhiteSpace(name)) inv.Name = name.Trim(); if (description is not null) inv.Description = description; if (imgName is not null) inv.ImageName = imgName; if (permission.HasValue) { inv.GeneralPermission = ClampInventionPermission(permission.Value); if (inv.GeneralPermission > 0 && !inv.IsPublished) { inv.IsPublished = true; inv.FirstPublishedAt = DateTime.UtcNow; } // Mirror legacy 0/1/2 column for back-compat. inv.Permission = inv.GeneralPermission >= 60 ? 2 : (inv.GeneralPermission >= 20 ? 1 : 0); } inv.UpdatedAt = DateTime.UtcNow; await db.SaveChangesAsync(); return Ok(ToWire(inv)); } [HttpGet("api/inventions/v1/updateprice")] [Authorize] public async Task UpdatePrice( [FromQuery] long inventionId, [FromQuery] int price) { var pid = CurrentPlayerId; var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == inventionId && !x.IsDeleted); if (inv is null) return NotFound(); if (inv.CreatorPlayerId != pid) return Forbid(); inv.Price = Math.Clamp(price, 0, 1000000); inv.UpdatedAt = DateTime.UtcNow; await db.SaveChangesAsync(); return Ok(ToWire(inv)); } [HttpGet("api/inventions/v1/delete")] [Authorize] public async Task Delete([FromQuery] long inventionId) { var pid = CurrentPlayerId; var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == inventionId); if (inv is null) return NotFound(); if (inv.CreatorPlayerId != pid) return Forbid(); inv.IsDeleted = true; inv.GeneralPermission = 0; inv.IsPublished = false; inv.UpdatedAt = DateTime.UtcNow; await db.SaveChangesAsync(); return Ok(ToWire(inv)); } [HttpGet("api/inventions/v3/publish")] [Authorize] public async Task Publish( [FromQuery] long inventionId, [FromQuery] int permissionLevel) { var pid = CurrentPlayerId; var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == inventionId && !x.IsDeleted); if (inv is null) return NotFound(); if (inv.CreatorPlayerId != pid) return Forbid(); var perm = ClampInventionPermission(permissionLevel); if (perm == 0) return BadRequest("permissionLevel must be > 0 (Unassigned)"); inv.GeneralPermission = perm; inv.IsPublished = true; inv.FirstPublishedAt ??= DateTime.UtcNow; inv.Permission = perm >= 60 ? 2 : (perm >= 20 ? 1 : 0); inv.UpdatedAt = DateTime.UtcNow; await db.SaveChangesAsync(); return Ok(ToWire(inv)); } [HttpGet("api/inventions/v1/unpublish")] [Authorize] public async Task Unpublish([FromQuery] long inventionId) { var pid = CurrentPlayerId; var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == inventionId && !x.IsDeleted); if (inv is null) return NotFound(); if (inv.CreatorPlayerId != pid) return Forbid(); inv.GeneralPermission = 0; inv.IsPublished = false; inv.Permission = 0; inv.UpdatedAt = DateTime.UtcNow; await db.SaveChangesAsync(); return Ok(ToWire(inv)); } [HttpGet("api/inventions/v1/download")] public async Task Download([FromQuery] long inventionId) { var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == inventionId && !x.IsDeleted); if (inv is null) return NotFound(); inv.SpawnCount += 1; var pid = CurrentPlayerIdOrNull; if (pid is long me && me != inv.CreatorPlayerId) { // NumPlayersHaveUsedInRoom — increment per-distinct-player. // Approximate by distinct cheer/check; for now bump once // per download by non-creator. inv.NumPlayersHaveUsedInRoom += 1; } inv.UpdatedAt = DateTime.UtcNow; await db.SaveChangesAsync(); return Ok(ToWire(inv)); } // ── POST mutations ─────────────────────────────────────────────────── /// The client sends AutoTags and CustomTags as JSON /// ARRAYS of strings. Binding them to string made deserialization /// throw, so every tag edit 400'd. PlayerAddedTags is kept as an /// alias for older callers. public sealed record SetTagsRequest( long InventionId, List? AutoTags, List? CustomTags, List? PlayerAddedTags); [HttpPost("api/inventions/v1/settags")] [Authorize] public async Task SetTags([FromBody] SetTagsRequest req) { var pid = CurrentPlayerId; var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == req.InventionId && !x.IsDeleted); if (inv is null) return NotFound(); if (inv.CreatorPlayerId != pid) return Forbid(); var combined = string.Join(',', new[] { req.AutoTags, req.CustomTags, req.PlayerAddedTags } .Where(list => list is not null) .SelectMany(list => list!) .Select(t => t?.Trim()) .Where(t => !string.IsNullOrWhiteSpace(t)) .Distinct(StringComparer.OrdinalIgnoreCase)); inv.TagsCsv = combined; inv.UpdatedAt = DateTime.UtcNow; await db.SaveChangesAsync(); return Ok(ToWire(inv)); } public sealed record CheerRequest(long InventionId); [HttpPost("api/inventions/v1/cheer")] [Authorize] public async Task Cheer([FromBody] CheerRequest req) { var pid = CurrentPlayerId; var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == req.InventionId && !x.IsDeleted); if (inv is null) return NotFound(); // Idempotent: one cheer per (player, invention). var existing = await db.Cheers.FirstOrDefaultAsync(c => c.FromPlayerId == pid && c.TargetInventionId == req.InventionId); if (existing is null) { db.Cheers.Add(new CheerEntity { FromPlayerId = pid, TargetInventionId = req.InventionId, CheeredAt = DateTime.UtcNow, }); inv.CheerCount += 1; await db.SaveChangesAsync(); if (inv.CreatorPlayerId != pid) await notifications.NotifyAsync(inv.CreatorPlayerId, PushNotificationId.InventionModerationStateChanged, new { Type = "cheer", inv.Id, From = pid, inv.CheerCount }); } return Ok(new { inv.Id, inv.CheerCount }); } public sealed record ReportInventionRequest( long InventionId, int? ReportCategory, string? Details); [HttpPost("api/inventions/v1/report")] [Authorize] public async Task Report([FromBody] ReportInventionRequest req) { var pid = CurrentPlayerId; var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == req.InventionId && !x.IsDeleted); if (inv is null) return NotFound(); db.Reports.Add(new ReportEntity { ReporterPlayerId = pid, TargetPlayerId = inv.CreatorPlayerId, TargetInventionId = req.InventionId, Category = req.ReportCategory ?? 5 /* Other */, Message = (req.Details ?? string.Empty)[..Math.Min(1000, (req.Details ?? string.Empty).Length)], }); await db.SaveChangesAsync(); return Ok(new { Reported = true }); } // ── Wire serializers ───────────────────────────────────────────────── private static int ClampInventionPermission(int v) => v switch { 0 or 10 or 20 or 40 or 60 or 80 or 100 => v, _ => 0, }; // Wire shape verified against the 2020.12 watch's // OBBBPCBIMME.PPGFHEDFBEA deserializer (Cpp2IL ISIL OBBBPCBIMME.txt // lines 588-704). All keys read via Util.GetKey are required — // a missing key throws KeyNotFoundException → the watch logs // "Received malformed RecNet response" and aborts the join/fetch. // // Required (in disasm order): // InventionId, ReplicationId, CreatorPlayerId, Name, Description, // ImageName, CurrentVersionNumber, IsPublished, AllowTrial, // ModifiedAt, CreatedAt, NumPlayersHaveUsedInRoom, NumDownloads, // CheerCount, CreatorPermission, GeneralPermission, IsAGInvention, // Price, HideFromPlayer. // Optional (Util.GetKeyOrDefault): FirstPublishedAt, CreationRoomId. // // Casing is significant — LitJson + Util.GetKey are case-sensitive. // In particular IsAGInvention has a capital G (matches the asset's // "IsAGInvention" property, not the entity column IsAgInvention). private static object ToWire(InventionEntity i) => new { InventionId = i.Id, ReplicationId = string.IsNullOrEmpty(i.ReplicationId) ? Guid.Empty.ToString("D") : i.ReplicationId, // Wire field is int — IDs in this server are <2^31 so safe. CreatorPlayerId = (int)i.CreatorPlayerId, i.Name, i.Description, i.ImageName, i.CurrentVersionNumber, i.IsPublished, AllowTrial = true, ModifiedAt = i.UpdatedAt, i.CreatedAt, i.FirstPublishedAt, i.CreationRoomId, i.NumPlayersHaveUsedInRoom, NumDownloads = i.SpawnCount, i.CheerCount, i.CreatorPermission, i.GeneralPermission, IsAGInvention = i.IsAgInvention, i.Price, HideFromPlayer = false, }; private static object ToWireV4(InventionEntity i) => ToWire(i); private static object ToVersionWire(InventionVersionEntity v) => new { v.InventionId, ReplicationId = string.IsNullOrEmpty(v.ReplicationId) ? Guid.Empty.ToString("D") : v.ReplicationId, v.VersionNumber, v.InstantiationCost, v.LightsCost, v.BlobName, }; }