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. /// /// The 2023 DTO reads a THIRD list, TrendingFilters /// (HMDONHHKKGA.txt:099/169), which we never sent — callers projecting that /// list got an empty filter row. Popular/Trending are now derived from the /// tags actually in use () rather than /// echoing the pinned array three times; the curated list is the fallback /// so a fresh server still shows filters. The 2020.12 watch's /// AEBEPCMAABC reads only Pinned/Popular, so the extra key is /// inert there. [HttpGet("api/inventions/v1/tagfilters")] public async Task TagFilters() { string[] pinned = [ "sport", "game", "vehicle", "weapon", "decor", "tool", "art", "music", "puzzle", "combat", "build", "race", ]; // TagsCsv is a packed list no provider can split, so the frequency count // runs in memory over a bounded window of the most recently touched // tagged inventions. var rows = await db.Inventions .Where(i => !i.IsDeleted && i.IsPublished && i.TagsCsv != "") .OrderByDescending(i => i.UpdatedAt) .Select(i => new { i.TagsCsv, i.UpdatedAt }) .Take(2000) .ToListAsync(); static IEnumerable SplitTags(string csv) => csv .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); static List Rank(IEnumerable tags) => tags .GroupBy(t => t, StringComparer.OrdinalIgnoreCase) .OrderByDescending(g => g.Count()) .ThenBy(g => g.Key, StringComparer.OrdinalIgnoreCase) .Select(g => g.Key) .Take(12) .ToList(); var since = DateTime.UtcNow.AddDays(-7); var popular = Rank(rows.SelectMany(r => SplitTags(r.TagsCsv))); var trending = Rank(rows.Where(r => r.UpdatedAt >= since) .SelectMany(r => SplitTags(r.TagsCsv))); if (popular.Count == 0) popular = [.. pinned]; if (trending.Count == 0) trending = popular; return Ok(new { PinnedFilters = pinned, PopularFilters = popular, TrendingFilters = trending, }); } /// 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 — start a free /// trial of a store invention, spawned for the window returned by /// 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. /// /// TWO wire corrections. (1) The id is a FORM field, not a query param: /// DCFKEFHJAGC.txt:9037 sets verb 2 (POST) and :9050 adds the pair via /// AFGEDDANEKP("inventionId", …), and on a non-GET those pairs go /// into an HTTPUrlEncodedForm body (see the id-list block comment above), so /// [FromQuery] bound 0 and every trial 404'd. (2) The reply is the /// Status/Invention/InventionVersion envelope: the continuation at /// DCFKEFHJAGC.txt:9086 is a /// Func<BDNCJIPHHOK, FGLDKEJLAKB<IFJONDCAKKM>> which /// projects .Invention — a bare invention left that null. [HttpPost("api/storefronts/v1/trialInvention")] [Authorize] public async Task TrialInvention() { var inventionId = await ReadLongFieldAsync("inventionId") ?? 0; 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(await EnvelopeAsync(i)); } /// GET api/inventions/v1/details — the ONLY key either /// client reads off this response is Tags: the 2023 continuation is /// Action<OIABGAKJABE> (OEGFNFEAAGO.txt:6312) whose reader takes /// exactly one literal, "Tags" (DBAHPLOPFIO.txt:034), and the 2020.12 watch's /// HJPDBNLCGIB does the same. Each element is /// {Tag:String, Type:Int32} (PCMLHLIBLNJ.txt:038/057). Without the key /// the invention detail page's tag row was permanently empty. Invention + /// Versions stay on the wire — unknown members are ignored by both readers. /// Type is always 0: merges the client's /// AutoTags and CustomTags into one /// column, so the auto/custom distinction is not recoverable without a /// second column. [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 { Tags = i.TagsCsv .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Select(t => new { Tag = t, Type = 0 }), Invention = ToWire(i), Versions = versions.Select(ToVersionWire), }); } /// GET api/inventions/v1/personaldetails/{id} — the caller's /// own relationship to the invention. Both clients read a single key here: /// the 2023 continuation is FGLDKEJLAKB<CEAFHBOOBKL> /// (OEGFNFEAAGO.txt:8995) and CEAFHBOOBKL's reader takes only "IsCheering" /// (BCLCHNGENDE.txt:036); the 2020.12 watch's OEGPIPBKHCN is identical. /// Without it the detail page never lit the player's own cheer. [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(); var pid = CurrentPlayerId; return Ok(new { IsCheering = await db.Cheers.AnyAsync(c => c.FromPlayerId == pid && c.TargetInventionId == id), Invention = ToWire(i), CanEdit = i.CreatorPlayerId == pid, }); } [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; } /// RecNet.NewInventionRequestDTO carries chipsCost and /// cloudVariablesCost too (2023.06 dump.cs:1283258-1283259). /// has no column for either, so /// they cannot be persisted — but binding them lets the save response /// echo back the budget the client just reported instead of two /// zeroes. public int ChipsCost { get; set; } public int CloudVariablesCost { 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, Math.Max(0, req.ChipsCost), Math.Max(0, req.CloudVariablesCost)), }); } /// 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; var version = new InventionVersionEntity { InventionId = inv.Id, ReplicationId = Guid.NewGuid().ToString("D"), VersionNumber = nextVer, BlobName = blobName, InstantiationCost = req.InstantiationCost ?? 0, LightsCost = req.LightsCost ?? 0, }; db.InventionVersions.Add(version); await db.SaveChangesAsync(); // Envelope, not a bare invention — see EnvelopeAsync. The version we // just wrote is the one the client wants back, and the two cost fields // it sent are echoed because no column stores them. return Ok(new { Status = 0, Invention = ToWire(inv), InventionVersion = ToVersionWire( version, Math.Max(0, req.ChipsCost ?? 0), Math.Max(0, req.CloudVariablesCost ?? 0)), }); } // ── 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(await EnvelopeAsync(inv)); } /// Body shape for the POST form of updateprice — /// RecNet.UpdatePriceRequest, real field names preserved in the 2023.06 /// dump (dump.cs:1283355-1283356). Settable properties + a parameterless /// ctor because constructs the /// instance itself. public sealed class UpdatePriceRequest { public long InventionId { get; set; } public int Price { get; set; } } /// Both clients POST updateprice with a body; only the GET /// query form was registered, so ASP.NET answered 405 and re-pricing a paid /// invention always failed. 2023: OEGFNFEAAGO.txt:9850 sets verb 2 and /// :9870-9876 hands JsonUtility.ToJson(UpdatePriceRequest) to /// FJLLPHFOOJJ, which wraps it in a BestHTTP RawJsonForm /// (application/json, RawJsonForm.txt:40). December POSTs the same /// route form-encoded (2020.12 BBHENFCNLAB.txt:6480). The binder accepts /// both; the GET stays for callers that still use the query string. [HttpGet("api/inventions/v1/updateprice")] [Authorize] public Task UpdatePrice( [FromQuery] long inventionId, [FromQuery] int price) => ApplyPriceAsync(inventionId, price); [HttpPost("api/inventions/v1/updateprice")] [Authorize] public Task UpdatePricePost( [ModelBinder(typeof(Binding.FormOrJsonModelBinder))] UpdatePriceRequest req) => ApplyPriceAsync(req.InventionId, req.Price); private async Task ApplyPriceAsync(long inventionId, 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(await EnvelopeAsync(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(await EnvelopeAsync(inv)); } /// The 2023 publish call carries FOUR query params, not two: /// OEGFNFEAAGO.txt:9437/9449/9461/9473 add inventionId, /// permissionLevel, accessibility and a /// Nullable<Int32> price — the wrapper signature is /// OEGFNFEAAGO.txt:9216. Dropping accessibility meant "publish as Private / /// Unlisted" still went fully public, and a paid publish lost its price. /// Accessibility constants: Private=0, Public=1, Unlisted=2 /// (AEFFFPIJDHG.GAKJKOGJEEH, 2023.06 dump.cs:1282644-1282646). With no /// Accessibility column on the entity only the Private/not-Private half can /// be stored, so Unlisted currently behaves as Public. The param is /// NULLABLE on purpose — the 2020.12 watch sends only inventionId + /// permissionLevel (2020.12 BBHENFCNLAB.txt:6171), and a missing value must /// not be read as Private or December could never publish. [HttpGet("api/inventions/v3/publish")] [Authorize] public async Task Publish( [FromQuery] long inventionId, [FromQuery] int permissionLevel, [FromQuery] int? accessibility, [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(); var perm = ClampInventionPermission(permissionLevel); if (perm == 0) return BadRequest("permissionLevel must be > 0 (Unassigned)"); var access = accessibility ?? 1 /* Public */; inv.GeneralPermission = perm; inv.IsPublished = access != 0; if (inv.IsPublished) inv.FirstPublishedAt ??= DateTime.UtcNow; inv.Permission = perm >= 60 ? 2 : (perm >= 20 ? 1 : 0); if (price.HasValue) inv.Price = Math.Clamp(price.Value, 0, 1000000); inv.UpdatedAt = DateTime.UtcNow; await db.SaveChangesAsync(); return Ok(await EnvelopeAsync(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(await EnvelopeAsync(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. Field names are literal in the 2023.06 dump — /// RecNet.ModifyTagsRequest keeps real names (dump.cs:1283295-1283301) — /// and the body is a RAW JSON document /// (JsonUtility.ToJson → BNDIAONDFFF.FJLLPHFOOJJ, /// OEGFNFEAAGO.txt:3082-3088), so [FromBody] is right here. public sealed record SetTagsRequest( long InventionId, List? AutoTags, List? CustomTags, List? PlayerAddedTags); /// POST api/inventions/v1/settags — the reply is NOT an /// invention. The 2023 issuing method is typed /// FGLDKEJLAKB<PNGLFHEAJIH> (OEGFNFEAAGO.txt:2744) and /// PNGLFHEAJIH exposes exactly two members — an int-backed result enum and /// List<String> (PNGLFHEAJIH.txt:3/83) — read from the literals /// "Result" and "Tags" (ALJPHDEAHBK.txt:191/210). The 2020.12 watch is /// identical: NJMAEIPIOAP.PPGFHEDFBEA pulls "Result" then "Tags" /// (2020.12 NJMAEIPIOAP.txt:85/90) with the throwing Util.GetKey, so the /// old bare-invention reply killed every tag edit on December too. /// Result = 0 is success: the enum's message formatter jump-tables on /// the value and case 0 returns "Success!" (PNGLFHEAJIH.txt:334-341). /// Tags is a flat string list — NOT the {Tag,Type} pairs /// returns. [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 tags = new[] { req.AutoTags, req.CustomTags, req.PlayerAddedTags } .Where(list => list is not null) .SelectMany(list => list!) .Select(t => (t ?? string.Empty).Trim()) .Where(t => t.Length > 0) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); inv.TagsCsv = string.Join(',', tags); inv.UpdatedAt = DateTime.UtcNow; await db.SaveChangesAsync(); return Ok(new { Result = 0, Tags = tags }); } /// RecNet.CheerRequest carries a SECOND field — Cheer, the /// desired state — whose real name survives in the 2023.06 dump /// (dump.cs:1283338-1283342: long InventionId @0x10, /// bool Cheer @0x18). Both writes are visible in the ISIL: 2023 sets /// [rdi+16] and [rdi+24] before JsonUtility.ToJson /// (OEGFNFEAAGO.txt:11228-11233) and December does the same /// (2020.12 BBHENFCNLAB.txt:7624-7628) — its wrapper signature is /// ANJHOOPIAKM(Int64, Boolean) (BBHENFCNLAB.txt:7503). Ignoring the /// flag made un-cheering a no-op: the button toggled off in the UI and the /// cheer stayed on the invention forever. public sealed record CheerRequest(long InventionId, bool Cheer = true); /// POST api/inventions/v1/cheer — answers with the /// Status/Invention/InventionVersion envelope like every other invention /// mutation: the 2023 method is FGLDKEJLAKB<BDNCJIPHHOK> /// (OEGFNFEAAGO.txt:11065) and December's is /// IPromise<AHEPPAEOLOD> (2020.12 BBHENFCNLAB.txt:7503). The old /// {Id, CheerCount} reply left Invention null on 2023 and threw /// KeyNotFoundException on December, so the tile never refreshed its /// count. [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 in both directions: one cheer row per (player, invention). var existing = await db.Cheers.FirstOrDefaultAsync(c => c.FromPlayerId == pid && c.TargetInventionId == req.InventionId); if (req.Cheer && 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 }); } else if (!req.Cheer && existing is not null) { db.Cheers.Remove(existing); inv.CheerCount = Math.Max(0, inv.CheerCount - 1); await db.SaveChangesAsync(); } return Ok(await EnvelopeAsync(inv)); } public sealed record ReportInventionRequest( long InventionId, int? ReportCategory, string? Details); /// POST api/inventions/v1/report — the reply is a /// success/message pair, not a status flag of our own invention. The 2023 /// method is FGLDKEJLAKB<PHMHCPEMABG> (OEGFNFEAAGO.txt:10917) /// and PHMHCPEMABG holds a Boolean + a String (PHMHCPEMABG.txt:3/23) read /// from the literals "Success"/"Message" (GBPDOLJBABB.txt:191/210); the /// 2020.12 watch's KLAMKCBENEA.PPGFHEDFBEA pulls the same two literals /// (2020.12 KLAMKCBENEA.txt:85/90). Our old {Reported:true} left /// Success at default false, so the report UI reported failure even though /// the row persisted. Message must be a string — December's /// Util.GetKey casts it — so send "" rather than null. [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 { Success = true, Message = string.Empty }); } // ── 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, // The 2023-03-21 DTO dropped IsPublished and reads "Accessibility" // instead (IOEPPCKGBFL.txt:309 / :1602 — the member switch has no // IsPublished arm at all). Constants come from the nested enum // AEFFFPIJDHG.GAKJKOGJEEH in the 2023.06 dump // (dump.cs:1282644-1282646): Private=0, Public=1, Unlisted=2. With the // key absent the reader left the field at its default, so EVERY // published store invention presented as Private in the 2023 UI. // InventionEntity has no Accessibility column, so only the // Private/Public bit round-trips; Unlisted needs a real column. // IsPublished stays on the wire because the 2020.12 watch's // OBBBPCBIMME reader still requires it. Accessibility = i.IsPublished ? 1 : 0, // IOEPPCKGBFL.txt:565 — DorkNet runs no certification programme. IsCertifiedInvention = false, i.Price, HideFromPlayer = false, }; private static object ToWireV4(InventionEntity i) => ToWire(i); private static object ToVersionWire(InventionVersionEntity v) => ToVersionWire(v, 0, 0); /// The 2023 InventionVersion DTO reads NINE keys /// (OLPHKLCPFEF.txt:084-295): the six we already emitted plus /// ChipsCost, CloudVariablesCost and BlobHash. /// has no column for any of the three, /// so stored rows report 0/empty and only the save/addversion round-trip can /// echo back the costs the client just sent. An empty BlobHash is safe: /// PLIKEBBPJGI's hash getter (EHENFDMIAIM, OLPHKLCPFEF's owner type /// PLIKEBBPJGI.txt:167) has no call site anywhere in the IsilDump — the /// client only ever writes it — so the key's presence is what matters. private static object ToVersionWire( InventionVersionEntity v, int chipsCost, int cloudVariablesCost) => new { v.InventionId, ReplicationId = string.IsNullOrEmpty(v.ReplicationId) ? Guid.Empty.ToString("D") : v.ReplicationId, v.VersionNumber, v.InstantiationCost, v.LightsCost, ChipsCost = chipsCost, CloudVariablesCost = cloudVariablesCost, v.BlobName, BlobHash = string.Empty, }; /// Every invention MUTATION route answers with the /// Status/Invention/InventionVersion envelope, never a bare invention: the /// 2023 client deserialises BDNCJIPHHOK (key literals at /// GMBGBPNMGBA.txt:044-095) and the 2020.12 watch deserialises /// AHEPPAEOLOD, whose reader pulls the same three literals /// (2020.12 IsilDump AHEPPAEOLOD.txt:110/115/120). Returning the invention /// flat left Invention null on 2023 and threw KeyNotFoundException on /// December. Status = 0 is success — /// EnterInventionNameDialog.txt:622-624 loads the boxed Status field and /// does test eax,eax / je <success>. private async Task EnvelopeAsync(InventionEntity inv) { var current = await db.InventionVersions .Where(x => x.InventionId == inv.Id) .OrderByDescending(x => x.VersionNumber) .FirstOrDefaultAsync(); object? versionWire = current is null ? null : ToVersionWire(current); return new { Status = 0, Invention = ToWire(inv), InventionVersion = versionWire, }; } }