using Microsoft.AspNetCore.Mvc; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using Campus_live_rewrite.api; using Campus_live_rewrite.api.dbs; using Campus_live_rewrite.api.server; namespace Campus_live_rewrite.Controllers { // Real invention SAVE (Maker Pen "save selection as invention") + publish + my-list. // The save endpoints used to ack with the wrong shape, so the client reported // "Error uploading invention data". The client's decoder is lenient (it reads // Invention.InventionId + the Status/Success envelope and drops unknown fields), so a // correctly-STRUCTURED reply is what it needs. The circuit blob is uploaded separately and // referenced here by inventionDataFilename. [ApiController] public class InventionController : ControllerBase { private static ContentResult J(object o, int status = 200) => new() { Content = JsonConvert.SerializeObject(o), ContentType = "application/json", StatusCode = status }; private async Task ReadBody() { try { using var r = new StreamReader(Request.Body); var s = await r.ReadToEndAsync(); return string.IsNullOrWhiteSpace(s) ? new JObject() : JObject.Parse(s); } catch { return null; } } private static InventionDB.Invention BuildFromBody(ulong creator, JObject b) { string name = (b.Value("name") ?? "").Trim(); if (name.Length == 0) name = "Untitled"; string desc = (b.Value("description") ?? "").Trim(); if (desc.Length == 0) desc = "No description yet"; var inv = new InventionDB.Invention { CreatorPlayerId = creator, Name = name.Length > 24 ? name.Substring(0, 24) : name, Description = desc.Length > 512 ? desc.Substring(0, 512) : desc, ImageName = b.Value("imageName") ?? "", BlobName = b.Value("inventionDataFilename") ?? "", InstantiationCost = b.Value("instantiationCost") ?? 0, LightsCost = b.Value("lightsCost") ?? 0, ChipsCost = b.Value("chipsCost") ?? 0, CloudVariablesCost = b.Value("cloudVariablesCost") ?? 0, AICost = b.Value("aiCost") ?? 0, CreationRoomId = b.Value("creationRoomId") ?? 0, UgcVersion = b.Value("ugcVersion") ?? 0, HasBetaContent = b.Value("hasBetaContent") ?? false, LongDescription = b.Value("longDescription"), DisplayMetadataJson = b.Value("displayMetadataJson"), ReferencedInventions = b["referencedInventions"]?.ToObject>() ?? new(), ReferencedUnityAssetIds = b["referencedUnityAssetIds"]?.ToObject>() ?? new(), }; if (b["tagsRequest"] is JObject tr) { var custom = tr["CustomTags"]?.ToObject>() ?? new(); var auto = tr["AutoTags"]?.ToObject>() ?? new(); inv.Tags = custom.Concat(auto).Where(t => !string.IsNullOrWhiteSpace(t)).Distinct().ToList(); } return InventionDB.Create(inv); } // GET /api/inventions/v1/version?inventionId=N&version=V - the bare RRInventionVersion, // which carries the blob name the client downloads. Only the CURRENT version exists here // (nothing writes history), so any other number 404s rather than naming a blob that is not // there. version=0 means "whichever is current". (Reference: api/routes/avatar.ts.) [HttpGet("/api/inventions/v1/version")] public ContentResult GetVersion([FromQuery] int inventionId = 0, [FromQuery] int version = 0) { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; if (inventionId <= 0) return J(new { error = "inventionId is required" }, 400); var inv = InventionDB.GetById(inventionId); if (inv == null) return new ContentResult { Content = "", ContentType = "application/json", StatusCode = 404 }; if (version != 0 && version != inv.CurrentVersionNumber) return new ContentResult { Content = "", ContentType = "application/json", StatusCode = 404 }; return J(VersionDto(inv)); } private static object VersionDto(InventionDB.Invention i) => new { InventionId = i.InventionId, ReplicationId = i.ReplicationId, VersionNumber = i.CurrentVersionNumber, BlobName = i.BlobName, BlobHash = (string?)null, InstantiationCost = i.InstantiationCost, LightsCost = i.LightsCost, ChipsCost = i.ChipsCost, CloudVariablesCost = i.CloudVariablesCost, AICost = i.AICost, HasBetaContent = i.HasBetaContent, }; // The stored-record shape the read endpoints (v2/mine) + v6 save envelope use. public static object InventionDto(InventionDB.Invention i) => new { InventionId = i.InventionId, ReplicationId = i.ReplicationId, CreatorPlayerId = i.CreatorPlayerId, Name = i.Name, Description = i.Description, ImageName = i.ImageName, CurrentVersionNumber = i.CurrentVersionNumber, CurrentVersion = VersionDto(i), Accessibility = i.Accessibility, IsPublished = i.IsPublished, IsFeatured = i.IsFeatured, ModifiedAt = i.ModifiedAt, CreatedAt = i.CreatedAt, FirstPublishedAt = i.FirstPublishedAt, CreationRoomId = i.CreationRoomId, NumPlayersHaveUsedInRoom = i.NumDownloads, NumDownloads = i.NumDownloads, CheerCount = i.CheerCount, // Permissions: KGCKBAIOBNL - Unassigned 0, LimitedOneUseOnly 10, DisallowKeyLock 15, // UseOnly 20, EditAndSave 40, Publish 60, Charge 80, Unlimited 100. Both were 0 // (Unassigned), which is not a permission the maker pen will spawn from. The creator // gets Unlimited over their own invention; everyone else gets what publishing implies. CreatorPermission = 100, GeneralPermission = i.IsPublished ? 20 : 0, IsAGInvention = false, IsCertifiedInvention = false, Price = i.Price, AllowTrial = false, HideFromPlayer = false, ReferencedInventions = i.ReferencedInventions ?? new(), // The six keys the client's 28-key Invention DTO carries that this DTO never sent // (wire-shapes: inventions-v2-search). UgcVersion is the important one: the maker pen // palette filters its list by it, so an invention that arrives without one matches // neither CV1 nor CV2 and never appears in the Inventions tab. 0 is not a valid // version - default to 1 (CV1) for records saved before the field existed. UgcVersion = i.UgcVersion > 0 ? i.UgcVersion : 1, LatestVersionNumber = i.CurrentVersionNumber, LongDescription = i.LongDescription ?? "", ForceCannotPublish = false, IsRecRoomApproved = false, DisplayMetadataJson = i.DisplayMetadataJson, }; private static object SaveResultV6(InventionDB.Invention i) => new { Status = 0, Invention = InventionDto(i), InventionVersion = VersionDto(i), }; // POST /api/inventions/v6/save — JSON body (camelCase); returns { Status, Invention, InventionVersion }. [HttpPost("/api/inventions/v6/save")] public async Task SaveV6() { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return J(new { error = "Unauthorized" }, 401); var b = await ReadBody(); if (b == null) return J(new { error = "Invalid request body" }, 400); var inv = BuildFromBody(account.Id, b); Console.WriteLine($"[invention] {account.Id} saved '{inv.Name}' (#{inv.InventionId}) blob={inv.BlobName}"); return J(SaveResultV6(inv)); } // POST /api/inventions/v9/save — same save, ENVELOPED { Value, Success, Error, error_id }. [HttpPost("/api/inventions/v9/save")] public async Task SaveV9() { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return J(new { Value = (object?)null, Success = false, Error = "Unauthorized", error_id = (string?)null }); var b = await ReadBody(); if (b == null) return J(new { Value = (object?)null, Success = false, Error = "Invalid request body", error_id = (string?)null }); var inv = BuildFromBody(account.Id, b); Console.WriteLine($"[invention] {account.Id} saved (v9) '{inv.Name}' (#{inv.InventionId}) blob={inv.BlobName}"); return J(new { Value = new { Status = 0, Invention = InventionDto(inv), InventionVersion = VersionDto(inv), TagsResponse = new { Result = 0, Tags = inv.Tags ?? new List() } }, Success = true, Error = "", error_id = (string?)null, }); } // GET /api/inventions/v1/details?inventionId=X — the invention's tags card. Fetched right // after a save to open the invention; was 404ing. Returns { Tags:[{Tag,Type}] } (404 if none). [HttpGet("/api/inventions/v1/details")] public ContentResult Details() { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; if (!int.TryParse(Request.Query["inventionId"], out var id)) return J(new { error = "inventionId is required" }, 400); var inv = InventionDB.GetById(id); if (inv == null) return J(new { error = "No such invention" }, 404); var tags = (inv.Tags ?? new List()).Select(t => new { Tag = t, Type = 0 }).ToList(); return J(new { Tags = tags }); } private static object V9(object? value, string? error = null) => new { Value = value, Success = error == null, Error = error ?? "", error_id = (string?)null }; private static bool Staff(ulong id) => PlayerDB.CheckDevFlag(id) || PlayerDB.CheckModFlag(id); // ---- browse / store ----------------------------------------------------------------- // Every one of these answered "[]" (search 468 times a day), so the invention store, the // "inventions in this room" list and every batch lookup (room offers, wishlists, nested // inventions) came up empty even with inventions published. // GET /api/inventions/v2/search?value=&ugcVersion=&skip=&take= - bare array (wire-shapes: // inventions-v2-search). "#name" is a tag or a creator's @name. [HttpGet("/api/inventions/v2/search")] public ContentResult Search([FromQuery] string? value, [FromQuery] int? ugcVersion, [FromQuery] int skip = 0, [FromQuery] int take = 100) { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; var hits = InventionStore.Search(value, skip, take); if (ugcVersion.HasValue && ugcVersion.Value > 0) hits = hits.Where(i => (i.UgcVersion > 0 ? i.UgcVersion : 1) == ugcVersion.Value).ToList(); return J(hits.Select(InventionDto).ToList()); } // GET /api/inventions/v2/batch?id=1&id=2 - the ones the caller may see (published, or theirs). [HttpGet("/api/inventions/v2/batch")] public ContentResult Batch([FromQuery] List id) { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; var ids = (id ?? new()).Where(x => x > 0 && x <= int.MaxValue).Select(x => (int)x); var owned = InventionStore.OwnedIds(account.Id); var list = InventionStore.GetByIds(ids) .Where(i => i.IsPublished || i.CreatorPlayerId == account.Id || owned.Contains(i.InventionId) || Staff(account.Id)) .Select(InventionDto).ToList(); return J(list); } // GET /api/inventions/v2/mine - created + bought, newest first. [HttpGet("/api/inventions/v2/mine")] public ContentResult Mine() { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; return J(InventionStore.Mine(account.Id).Select(InventionDto).ToList()); } // GET /api/inventions/v1/room?id=N - published inventions made in that room. [HttpGet("/api/inventions/v1/room")] public ContentResult ByRoom([FromQuery] long id, [FromQuery] int skip = 0, [FromQuery] int take = 100) { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; return J(InventionStore.ByRoom(id).Skip(Math.Max(0, skip)).Take(take <= 0 ? 100 : take).Select(InventionDto).ToList()); } [HttpGet("/api/inventions/v1/featured")] [HttpGet("/api/inventions/v1/toptoday")] public ContentResult Featured([FromQuery] int skip = 0, [FromQuery] int take = 50) { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; return J(InventionStore.Top(skip, take).Select(InventionDto).ToList()); } // GET /api/inventions/v1/personaldetails/{id} - the caller's own relation (cheering?). Was 404. [HttpGet("/api/inventions/v1/personaldetails/{inventionId:int}")] public ContentResult PersonalDetails(int inventionId) { var (account, error) = AuthHelper.ValidateToken(HttpContext); ulong me = error == null && account != null ? account.Id : 0; return J(new { IsCheering = me != 0 && InventionStore.IsCheering(me, inventionId), IsOwned = me != 0 && InventionStore.Owns(me, inventionId) }); } // GET /api/inventions/v1/fulllineageowner?id=1&id=2 - does the caller own every piece? bare bool. [HttpGet("/api/inventions/v1/fulllineageowner")] public ContentResult FullLineageOwner([FromQuery] List id) { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return J(false); var ids = (id ?? new()).SelectMany(x => x.Split(',')).Select(x => int.TryParse(x.Trim(), out var n) ? n : 0).Where(n => n > 0).ToList(); return J(ids.Count > 0 && InventionStore.OwnsAll(account.Id, ids)); } // POST /api/inventions/v2/cheer - {InventionId, Cheer?} (form or JSON). Toggles when no flag. [HttpPost("/api/inventions/v2/cheer")] [HttpPost("/api/inventions/v1/cheer")] public ContentResult CheerInvention() { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; var f = Campus_live_rewrite.api.server.RoomModeration.Fields(Request); int id = (int)Campus_live_rewrite.api.server.RoomModeration.Long(f, "InventionId", "inventionId", "id"); string flag = Campus_live_rewrite.api.server.RoomModeration.Str(f, "Cheer", "cheer", "IsCheering", "value") ?? ""; bool cheer = flag.Length == 0 ? !InventionStore.IsCheering(account.Id, id) : (flag.Equals("true", StringComparison.OrdinalIgnoreCase) || flag == "1"); var inv = InventionStore.SetCheer(account.Id, id, cheer); if (inv == null) return J(V9(null, "No such invention")); return J(V9(new { Status = 0, Invention = InventionDto(inv), InventionVersion = VersionDto(inv), IsCheering = cheer, CheerCount = inv.CheerCount })); } // ---- creator writes ----------------------------------------------------------------- // POST /api/inventions/v1/updateprice {InventionId, Price} -> the v6 save result. [HttpPost("/api/inventions/v1/updateprice")] [HttpPost("/api/inventions/v2/updateprice")] public async Task UpdatePrice() { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; var b = await ReadBody() ?? new JObject(); int id = b.Value("InventionId") ?? b.Value("inventionId") ?? 0; if (id == 0 && int.TryParse(Request.Query["inventionId"], out var qid)) id = qid; int price = b.Value("Price") ?? b.Value("price") ?? (int.TryParse(Request.Query["price"], out var qp) ? qp : -1); if (price < 0) return J(new { error = "Price must be >= 0" }, 400); var inv = InventionStore.Edit(id, account.Id, i => { i.Price = price; }, Staff(account.Id)); if (inv == null) return J(new { error = "No such invention" }, 404); Console.WriteLine($"[invention] {account.Id} priced #{id} at {price}"); return J(SaveResultV6(inv)); } // PUT /api/inventions/v2/metadata {InventionId, Name?, Description?, LongDescription?, ImageName?, TagsRequest?} -> v9 envelope. [HttpPut("/api/inventions/v2/metadata")] [HttpPost("/api/inventions/v2/metadata")] [HttpPut("/api/inventions/v2/update")] // the editor's "save details" - same body (was a 404) [HttpPost("/api/inventions/v2/update")] public async Task Metadata() { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return J(V9(null, "Unauthorized"), 401); var b = await ReadBody(); if (b == null) return J(V9(null, "Invalid request body")); int id = b.Value("InventionId") ?? b.Value("inventionId") ?? 0; var inv = InventionStore.Edit(id, account.Id, i => { string? S(string k) => b[k]?.Type == JTokenType.String ? b.Value(k) : null; var name = (S("Name") ?? S("name"))?.Trim(); if (!string.IsNullOrEmpty(name)) i.Name = name.Length > 24 ? name[..24] : name; var desc = S("Description") ?? S("description"); if (desc != null) i.Description = desc.Length > 512 ? desc[..512] : desc; var ld = S("LongDescription") ?? S("longDescription"); if (ld != null) i.LongDescription = ld; var img = S("ImageName") ?? S("imageName"); if (!string.IsNullOrEmpty(img)) i.ImageName = img; var dm = S("DisplayMetadataJson") ?? S("displayMetadataJson"); if (dm != null) i.DisplayMetadataJson = dm; var price = b.Value("Price") ?? b.Value("price"); if (price is >= 0) i.Price = price.Value; if ((b["TagsRequest"] ?? b["tagsRequest"]) is JObject tr) { var custom = tr["CustomTags"]?.ToObject>() ?? new(); var auto = tr["AutoTags"]?.ToObject>() ?? new(); i.Tags = custom.Concat(auto).Where(t => !string.IsNullOrWhiteSpace(t)).Distinct().ToList(); } }, Staff(account.Id)); if (inv == null) return J(V9(null, "No such invention, or not yours")); return J(V9(new { Status = 0, Invention = InventionDto(inv), InventionVersion = VersionDto(inv), TagsResponse = new { Result = 0, Tags = inv.Tags ?? new List() } })); } // POST /api/inventions/v1/settags {InventionId, CustomTags[], AutoTags[]} -> {Result, Tags}. [HttpPost("/api/inventions/v1/settags")] public async Task SetTags() { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; var b = await ReadBody() ?? new JObject(); int id = b.Value("InventionId") ?? b.Value("inventionId") ?? 0; var custom = (b["CustomTags"] ?? b["customTags"])?.ToObject>() ?? new(); var auto = (b["AutoTags"] ?? b["autoTags"])?.ToObject>() ?? new(); var inv = InventionStore.Edit(id, account.Id, i => { i.Tags = auto.Concat(custom).Where(t => !string.IsNullOrWhiteSpace(t)).Distinct().ToList(); }, Staff(account.Id)); if (inv == null) return J(new { Result = 2, Tags = new List() }); return J(new { Result = 0, Tags = inv.Tags ?? new List() }); } // GET /api/inventions/v1?inventionId= - one invention, bare (reference). Unpublished ones // are served too, the way the reference does. [HttpGet("/api/inventions/v1")] public ContentResult One([FromQuery] int inventionId) { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; var inv = inventionId > 0 ? InventionStore.GetByIds(new[] { inventionId }).FirstOrDefault() : null; if (inv == null) return J(new { error = "No such invention" }, 404); return J(InventionDto(inv)); } // GET|POST /api/inventions/v1/update - the older edit: the fields ride as QUERY params // (inventionId, name, description, imageName); absent ones keep their value, an empty // description clears it, an empty name / imageName is ignored (reference). [HttpGet("/api/inventions/v1/update")] [HttpPost("/api/inventions/v1/update")] public ContentResult UpdateV1() { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; var f = RoomModeration.Fields(Request); int id = (int)RoomModeration.Long(f, "inventionId", "InventionId", "id"); var inv = InventionStore.Edit(id, account.Id, i => { string? name = RoomModeration.Str(f, "name", "Name")?.Trim(); if (!string.IsNullOrEmpty(name)) i.Name = name.Length > 24 ? name[..24] : name; if (f.TryGetValue("description", out var d) || f.TryGetValue("Description", out d)) i.Description = (d ?? "").Length > 512 ? d![..512] : (d ?? ""); string? img = RoomModeration.Str(f, "imageName", "ImageName"); if (!string.IsNullOrEmpty(img)) i.ImageName = img; }, Staff(account.Id)); if (inv == null) return J(new { Success = false, Error = "No such invention, or not yours" }); return J(InventionDto(inv)); } // POST /api/inventions/v1/report - filed against the invention's CREATOR (reference). [HttpPost("/api/inventions/v1/report")] public async Task Report() { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; var b = await ReadBody() ?? new JObject(); var f = RoomModeration.Fields(Request); int id = b.Value("InventionId") ?? b.Value("inventionId") ?? (int)RoomModeration.Long(f, "InventionId", "inventionId"); var inv = id > 0 ? InventionStore.GetByIds(new[] { id }).FirstOrDefault() : null; if (inv == null) return J(new { success = false, error = "No such invention" }); int category = b.Value("ReportCategory") ?? (int)RoomModeration.Long(f, "ReportCategory"); string details = b.Value("Details") ?? b.Value("ReportDescription") ?? RoomModeration.Str(f, "Details", "ReportDescription") ?? ""; Campus_live_rewrite.api.dbs.ReportsDB.Create(new Campus_live_rewrite.api.dbs.ReportsDB.Report { ReporterPlayerId = account.Id, ReportedPlayerId = inv.CreatorPlayerId, ReportCategory = category, Details = $"[invention {inv.InventionId} '{inv.Name}'] {details}".Trim(), }); Console.WriteLine($"[report] {account.Id} reported invention {inv.InventionId} by {inv.CreatorPlayerId}"); return J(new { success = true, error = "" }); } // POST /api/inventions/v2/unpublish | v1/unpublish {InventionId} -> v9 envelope. [HttpPost("/api/inventions/v2/unpublish")] [HttpPost("/api/inventions/v1/unpublish")] public async Task Unpublish() { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return J(V9(null, "Unauthorized"), 401); var b = await ReadBody() ?? new JObject(); int id = b.Value("InventionId") ?? b.Value("inventionId") ?? 0; if (id == 0 && int.TryParse(Request.Query["inventionId"], out var qid)) id = qid; var inv = InventionStore.Edit(id, account.Id, i => { i.IsPublished = false; i.Accessibility = 0; }, Staff(account.Id)); if (inv == null) return J(V9(null, "No such invention")); Console.WriteLine($"[invention] {account.Id} unpublished #{id}"); return J(V9(SaveResultV6(inv))); } // POST /api/inventions/v2/accessibility {InventionId, Accessibility} (0 private, 1 public, 2 unlisted). [HttpPost("/api/inventions/v2/accessibility")] [HttpPut("/api/inventions/v2/accessibility")] public async Task SetAccessibility() { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return J(V9(null, "Unauthorized"), 401); var b = await ReadBody() ?? new JObject(); int id = b.Value("InventionId") ?? b.Value("inventionId") ?? 0; int acc = b.Value("Accessibility") ?? b.Value("accessibility") ?? 0; var inv = InventionStore.Edit(id, account.Id, i => { i.Accessibility = acc; i.IsPublished = acc != 0; if (i.IsPublished && i.FirstPublishedAt == null) i.FirstPublishedAt = DateTime.UtcNow; }, Staff(account.Id)); if (inv == null) return J(V9(null, "No such invention")); return J(V9(SaveResultV6(inv))); } // POST /api/inventions/v2/delete {InventionId} -> {Value:null, Success, Error, error_id}. [HttpPost("/api/inventions/v2/delete")] [HttpDelete("/api/inventions/v2/delete")] public async Task DeleteInvention() { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return J(V9(null, "Unauthorized"), 401); var b = await ReadBody() ?? new JObject(); int id = b.Value("InventionId") ?? b.Value("inventionId") ?? 0; if (id == 0 && int.TryParse(Request.Query["inventionId"], out var qid)) id = qid; bool ok = InventionStore.Delete(id, account.Id, Staff(account.Id)); Console.WriteLine($"[invention] {account.Id} delete #{id}: {(ok ? "deleted" : "refused")}"); return J(V9(null, ok ? null : "No such invention, or not yours")); } // POST /api/inventions/v4/publish - the same publish, v9 envelope. [HttpPost("/api/inventions/v4/publish")] public Task PublishV4() => PublishV3(); // ---- buying --------------------------------------------------------------------------- // POST /api/storefronts/v3/buyInvention {InventionId, RequestedPrice} (2025) and // GET /api/storefronts/v2/buyInvention?inventionId=&requestedPrice= (2023). Charges Rec // Center Tokens at the STORED price, records ownership (it lands in v2/mine and counts as // a download) and answers the bare {InventionResponse, BalanceUpdateResponse} pair // (wire-shapes: storefronts-v3-buyinvention). Neither route existed - the 2025 one 404'd. [HttpPost("/api/storefronts/v3/buyInvention")] [HttpGet("/api/storefronts/v2/buyInvention")] [HttpPost("/api/storefronts/v2/buyInvention")] public async Task BuyInvention() { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; var b = await ReadBody() ?? new JObject(); int id = b.Value("InventionId") ?? b.Value("inventionId") ?? 0; if (id == 0 && int.TryParse(Request.Query["inventionId"], out var qid)) id = qid; int requested = b.Value("RequestedPrice") ?? b.Value("requestedPrice") ?? (int.TryParse(Request.Query["requestedPrice"], out var qp) ? qp : 0); const int Tokens = (int)Campus_live_rewrite.api.server.Classes.CurrencyType.RecCenterTokens; long balance = PlayerDB.GetBalance(account.Id, Tokens); ContentResult Refuse(string why, int status = 200) { Console.WriteLine($"[invention-buy] {account.Id} #{id}: {why}"); return J(new { InventionResponse = V9(null, why), BalanceUpdateResponse = (object?)null }, status); } var inv = InventionDB.GetById(id); if (inv == null) return Refuse("No such invention"); if (!inv.IsPublished && inv.CreatorPlayerId != account.Id) return Refuse("This invention is not for sale"); if (inv.CreatorPlayerId == account.Id) return Refuse("You already own your own invention"); if (InventionStore.Owns(account.Id, id)) return Refuse("Already owned"); int price = Math.Max(0, inv.Price); if (requested != price) Console.WriteLine($"[invention-buy] {account.Id} #{id}: client rendered {requested}, charging the stored {price}"); if (price > 0 && balance < price) return Refuse("Not enough tokens"); if (price > 0) balance = PlayerDB.AddBalance(account.Id, Tokens, -price); InventionStore.AddOwned(account.Id, id, price); EarningsDB.Record(inv.CreatorPlayerId, account.Id, "invention", id.ToString(), inv.Name, Tokens, price); inv = InventionDB.GetById(id) ?? inv; Campus_live_rewrite.api.server.GiftBoxes.PushBalance(account.Id, Tokens, balance, "invention purchase"); Console.WriteLine($"[invention-buy] @{account.Username} bought #{id} '{inv.Name}' for {price} -> balance {balance}"); return J(new { InventionResponse = V9(new { Status = 0, Invention = InventionDto(inv), InventionVersion = VersionDto(inv), TagsResponse = new { Result = 0, Tags = inv.Tags ?? new List() } }), BalanceUpdateResponse = new { BalanceUpdates = new[] { new { UpdateResponse = 0, Data = InventionDto(inv) } }, Balance = balance, CurrencyType = Tokens, Platform = Campus_live_rewrite.api.server.Classes.GameCurrency.AllPlatformsBucket } }); } // POST /api/inventions/v3/publish — flip the invention public. Body/query carries the id. [HttpPost("/api/inventions/v3/publish")] public async Task PublishV3() { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return J(new { Value = (object?)null, Success = false, Error = "Unauthorized", error_id = (string?)null }); var b = await ReadBody() ?? new JObject(); int id = b.Value("inventionId") ?? b.Value("InventionId") ?? 0; if (id == 0 && int.TryParse(Request.Query["inventionId"], out var qid)) id = qid; var inv = InventionDB.Publish(id, account.Id, true); if (inv == null) return J(new { Value = (object?)null, Success = false, Error = "No such invention", error_id = (string?)null }); Console.WriteLine($"[invention] {account.Id} published #{id}"); return J(new { Value = SaveResultV6(inv), Success = true, Error = "", error_id = (string?)null }); } } }