fix: second pass over the 2023-client wire defects, plus a coverage harness

Fourteen agents worked one server file each over the audit's open findings,
re-verifying every claim against the client binary before changing anything.
Route+verb coverage against the recovered client inventory is now 474/522
(90.8%), up from 86.2% when the harness was first wired up.

Adds ClientRouteCoverageTests, which turns "are we compliant?" into a number
instead of an opinion. It reads data/client-routes-2023.tsv — every request the
2023-03-21 client can issue, recovered from the binary — and asserts each one
has a handler under the verb the client actually uses, separating "no handler"
from "handler exists, wrong verb" since those have different fixes. Remaining
gaps live in KnownGaps, which is expected to shrink and never grow.

Also fixes a real blind spot in EndpointContractDiscovery: it only collected
HttpMethodAttribute, so an action carrying a bare [Route] with no verb attribute
was invisible to it. The CDN's unity_assets handler is exactly that shape, which
had been showing up as a phantom gap.

Notable findings from this pass:

- The club permissions envelope key is literally "Type", not "MembershipType"
  as the audit's suggested fix guessed. Read off the generated reader, then
  cross-checked against the 2020 decompile — the int bitmask we were emitting
  was wrong for BOTH client generations, not just 2023.
- Room-consumable purchase responses carry OperationResult as an OBJECT
  { Status, InventoryItem }; the scalar operation enum belongs in
  BalanceUpdateResult. The base type OKHNOPLBOFP holds a single BGHBAILNNJJ,
  which exposes exactly those two properties. A test asserting the old scalar
  shape was corrected rather than the code.
- buyProgressionEventXpBoost blind-inserted an ObjectiveProgress row whose
  (PlayerId, Key) is unique, so a second purchase of the same boost threw
  "UNIQUE constraint failed" and 500'd AFTER taking the player's currency.
  Now an upsert.

Agents were forbidden from faking fixes they could not complete in their own
file, and several correctly refused: club role permissions, club additional
image slots, announcement meta, MinLevel/ClubChatEnabled, currency purchase
offer ordering, and room-key image/currency columns all need new entities or
columns. Those are deliberately left for a single coherent schema change rather
than stubbed out here.
This commit is contained in:
Alexa
2026-07-28 08:50:58 +03:00
parent 289072583e
commit 90e069ee4b
14 changed files with 3747 additions and 790 deletions
@@ -21,6 +21,14 @@ public class CustomAvatarItemsController(
IObjectStorage storage,
ILogger<CustomAvatarItemsController> logger) : ControllerBase
{
/// <summary>Floor price for a publicly listed custom item. The client
/// asks for this over the wire (<c>api/customAvatarItems/v1/minPriceForPublicItem</c>,
/// RecNet.Runtime/MPBLNLMCEDL.txt:6893) and then enforces it locally in the
/// designer, so the number the create/update paths clamp to and the number
/// served on that route have to be the same constant or the client's own
/// validation disagrees with the server's rejection.</summary>
private const int MinPublicItemPrice = 100;
[HttpGet]
[AllowAnonymous]
public async Task<IActionResult> List(
@@ -529,7 +537,7 @@ public class CustomAvatarItemsController(
item.Name = name;
item.Description = description;
item.Price = Math.Max(100, req.Price ?? item.Price);
item.Price = Math.Max(MinPublicItemPrice, req.Price ?? item.Price);
item.ItemType = req.ItemType ?? item.ItemType;
item.BaseAvatarItemId = req.BaseAvatarItemId ?? item.BaseAvatarItemId;
item.Color = Trim(req.Color, 64);
@@ -939,6 +947,30 @@ public class CustomAvatarItemsController(
public List<Guid>? ItemIds { get; set; }
}
/// <summary>The designer's in-progress draft, as persisted to the
/// per-player <c>caidesign_p&lt;id&gt;.json</c> object (see
/// <see cref="LoadDesignAsync"/>). Mirrors the three payload properties of
/// the client's design DTOs — the metadata object GIOBJIGEMNG carries the
/// nullable base-item id and the "#RRGGBB" colour, and the texture arrives
/// as the separate "design" file part whose stored blob name lands in
/// <see cref="ImageName"/>.
///
/// <see cref="Deleted"/> is the tombstone: <see cref="IObjectStorage"/>
/// exposes no delete operation, so DELETE design overwrites the record with
/// a deleted marker and LoadDesignAsync reports it as "no saved design".
/// Public properties with setters because the round-trip goes through
/// System.Text.Json.</summary>
public sealed class StoredDesign
{
public int? BaseAvatarItemId { get; set; }
public string Color { get; set; } = string.Empty;
public string ImageName { get; set; } = string.Empty;
public bool Deleted { get; set; }
}
public sealed class DesignRequest
{
public Guid? Id { get; set; }
@@ -387,19 +387,59 @@ public class InventionsController(
/// <c>List → Dictionary</c> cast inside <c>Util.Deserialize&lt;T&gt;</c>
/// 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.</summary>
/// dorm-load destabilisation we've been hunting.
///
/// The 2023 DTO reads a THIRD list, <c>TrendingFilters</c>
/// (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 (<see cref="InventionEntity.TagsCsv"/>) 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
/// <c>AEBEPCMAABC</c> reads only Pinned/Popular, so the extra key is
/// inert there.</summary>
[HttpGet("api/inventions/v1/tagfilters")]
public IActionResult TagFilters()
public async Task<IActionResult> TagFilters()
{
var tags = new[]
{
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<string> SplitTags(string csv) => csv
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
static List<string> Rank(IEnumerable<string> 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 = tags,
PopularFilters = tags,
PinnedFilters = pinned,
PopularFilters = popular,
TrendingFilters = trending,
});
}
@@ -520,18 +560,26 @@ public class InventionsController(
return Ok(ToWire(i));
}
/// <summary>POST <c>api/storefronts/v1/trialInvention?inventionId={id}</c>
/// — start a free trial of a store invention. Client
/// (RecNet.Runtime <c>DCFKEFHJAGC.PPCLLFMHJLD(long)</c>) expects a SINGLE
/// Invention DTO back (same <see cref="ToWire"/> shape it uses for
/// search), which it spawns for the trial window (length from
/// <c>trialInvention/duration</c>). Hosted here — not in
/// <summary>POST <c>api/storefronts/v1/trialInvention</c> — start a free
/// trial of a store invention, spawned for the window returned by
/// <c>trialInvention/duration</c>. Hosted here — not in
/// StorefrontsController — so it can reuse the invention wire builder.
/// We record the trial start so it's an auditable action.</summary>
/// 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
/// <c>AFGEDDANEKP("inventionId", …)</c>, and on a non-GET those pairs go
/// into an HTTPUrlEncodedForm body (see the id-list block comment above), so
/// <c>[FromQuery]</c> bound 0 and every trial 404'd. (2) The reply is the
/// Status/Invention/InventionVersion envelope: the continuation at
/// DCFKEFHJAGC.txt:9086 is a
/// <c>Func&lt;BDNCJIPHHOK, FGLDKEJLAKB&lt;IFJONDCAKKM&gt;&gt;</c> which
/// projects <c>.Invention</c> — a bare invention left that null.</summary>
[HttpPost("api/storefronts/v1/trialInvention")]
[Authorize]
public async Task<ActionResult> TrialInvention([FromQuery] long inventionId)
public async Task<ActionResult> 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)
@@ -550,9 +598,21 @@ public class InventionsController(
row.Value = DateTime.UtcNow.ToString("O");
await db.SaveChangesAsync();
}
return Ok(ToWire(i));
return Ok(await EnvelopeAsync(i));
}
/// <summary>GET <c>api/inventions/v1/details</c> — the ONLY key either
/// client reads off this response is <c>Tags</c>: the 2023 continuation is
/// <c>Action&lt;OIABGAKJABE&gt;</c> (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
/// <c>{Tag:String, Type:Int32}</c> (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.
/// <c>Type</c> is always 0: <see cref="SetTags"/> merges the client's
/// AutoTags and CustomTags into one <see cref="InventionEntity.TagsCsv"/>
/// column, so the auto/custom distinction is not recoverable without a
/// second column.</summary>
[HttpGet("api/inventions/v1/details")]
public async Task<ActionResult> Details([FromQuery] long inventionId)
{
@@ -566,21 +626,33 @@ public class InventionsController(
.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),
});
}
/// <summary>GET <c>api/inventions/v1/personaldetails/{id}</c> — the caller's
/// own relationship to the invention. Both clients read a single key here:
/// the 2023 continuation is <c>FGLDKEJLAKB&lt;CEAFHBOOBKL&gt;</c>
/// (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.</summary>
[HttpGet("api/inventions/v1/personaldetails/{id:long}")]
[Authorize]
public async Task<ActionResult> 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 == CurrentPlayerId,
CanEdit = i.CreatorPlayerId == pid,
});
}
@@ -613,6 +685,14 @@ public class InventionsController(
public string? ImageName { get; set; }
public int InstantiationCost { get; set; }
public int LightsCost { get; set; }
/// <summary>RecNet.NewInventionRequestDTO carries <c>chipsCost</c> and
/// <c>cloudVariablesCost</c> too (2023.06 dump.cs:1283258-1283259).
/// <see cref="InventionVersionEntity"/> 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.</summary>
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; }
@@ -686,7 +766,8 @@ public class InventionsController(
{
Status = 0,
Invention = ToWireV4(inv),
InventionVersion = ToVersionWire(v1),
InventionVersion = ToVersionWire(
v1, Math.Max(0, req.ChipsCost), Math.Max(0, req.CloudVariablesCost)),
});
}
@@ -727,7 +808,7 @@ public class InventionsController(
inv.CurrentVersionNumber = nextVer;
inv.UpdatedAt = DateTime.UtcNow;
db.InventionVersions.Add(new InventionVersionEntity
var version = new InventionVersionEntity
{
InventionId = inv.Id,
ReplicationId = Guid.NewGuid().ToString("D"),
@@ -735,9 +816,20 @@ public class InventionsController(
BlobName = blobName,
InstantiationCost = req.InstantiationCost ?? 0,
LightsCost = req.LightsCost ?? 0,
});
};
db.InventionVersions.Add(version);
await db.SaveChangesAsync();
return Ok(ToWire(inv));
// 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) ───────────────────────────
@@ -775,14 +867,42 @@ public class InventionsController(
}
inv.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToWire(inv));
return Ok(await EnvelopeAsync(inv));
}
/// <summary>Body shape for the POST form of <c>updateprice</c> —
/// RecNet.UpdatePriceRequest, real field names preserved in the 2023.06
/// dump (dump.cs:1283355-1283356). Settable properties + a parameterless
/// ctor because <see cref="Binding.FormOrJsonModelBinder"/> constructs the
/// instance itself.</summary>
public sealed class UpdatePriceRequest
{
public long InventionId { get; set; }
public int Price { get; set; }
}
/// <summary>Both clients POST <c>updateprice</c> 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 <c>JsonUtility.ToJson(UpdatePriceRequest)</c> to
/// <c>FJLLPHFOOJJ</c>, which wraps it in a BestHTTP RawJsonForm
/// (<c>application/json</c>, 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.</summary>
[HttpGet("api/inventions/v1/updateprice")]
[Authorize]
public async Task<ActionResult> UpdatePrice(
public Task<ActionResult> UpdatePrice(
[FromQuery] long inventionId,
[FromQuery] int price)
=> ApplyPriceAsync(inventionId, price);
[HttpPost("api/inventions/v1/updateprice")]
[Authorize]
public Task<ActionResult> UpdatePricePost(
[ModelBinder(typeof(Binding.FormOrJsonModelBinder))] UpdatePriceRequest req)
=> ApplyPriceAsync(req.InventionId, req.Price);
private async Task<ActionResult> ApplyPriceAsync(long inventionId, int price)
{
var pid = CurrentPlayerId;
var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == inventionId && !x.IsDeleted);
@@ -791,7 +911,7 @@ public class InventionsController(
inv.Price = Math.Clamp(price, 0, 1000000);
inv.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToWire(inv));
return Ok(await EnvelopeAsync(inv));
}
[HttpGet("api/inventions/v1/delete")]
@@ -807,13 +927,29 @@ public class InventionsController(
inv.IsPublished = false;
inv.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToWire(inv));
return Ok(await EnvelopeAsync(inv));
}
/// <summary>The 2023 publish call carries FOUR query params, not two:
/// OEGFNFEAAGO.txt:9437/9449/9461/9473 add <c>inventionId</c>,
/// <c>permissionLevel</c>, <c>accessibility</c> and a
/// <c>Nullable&lt;Int32&gt;</c> <c>price</c> — 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.</summary>
[HttpGet("api/inventions/v3/publish")]
[Authorize]
public async Task<ActionResult> Publish(
[FromQuery] long inventionId, [FromQuery] int permissionLevel)
[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);
@@ -821,13 +957,15 @@ public class InventionsController(
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 = true;
inv.FirstPublishedAt ??= DateTime.UtcNow;
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(ToWire(inv));
return Ok(await EnvelopeAsync(inv));
}
[HttpGet("api/inventions/v1/unpublish")]
@@ -843,7 +981,7 @@ public class InventionsController(
inv.Permission = 0;
inv.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToWire(inv));
return Ok(await EnvelopeAsync(inv));
}
[HttpGet("api/inventions/v1/download")]
@@ -870,13 +1008,30 @@ public class InventionsController(
/// <summary>The client sends <c>AutoTags</c> and <c>CustomTags</c> as JSON
/// ARRAYS of strings. Binding them to <c>string</c> made deserialization
/// throw, so every tag edit 400'd. <c>PlayerAddedTags</c> is kept as an
/// alias for older callers.</summary>
/// 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
/// (<c>JsonUtility.ToJson</c> → <c>BNDIAONDFFF.FJLLPHFOOJJ</c>,
/// OEGFNFEAAGO.txt:3082-3088), so [FromBody] is right here.</summary>
public sealed record SetTagsRequest(
long InventionId,
List<string>? AutoTags,
List<string>? CustomTags,
List<string>? PlayerAddedTags);
/// <summary>POST <c>api/inventions/v1/settags</c> — the reply is NOT an
/// invention. The 2023 issuing method is typed
/// <c>FGLDKEJLAKB&lt;PNGLFHEAJIH&gt;</c> (OEGFNFEAAGO.txt:2744) and
/// PNGLFHEAJIH exposes exactly two members — an int-backed result enum and
/// <c>List&lt;String&gt;</c> (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.
/// <c>Result = 0</c> is success: the enum's message formatter jump-tables on
/// the value and case 0 returns "Success!" (PNGLFHEAJIH.txt:334-341).
/// <c>Tags</c> is a flat string list — NOT the {Tag,Type} pairs
/// <see cref="Details"/> returns.</summary>
[HttpPost("api/inventions/v1/settags")]
[Authorize]
public async Task<ActionResult> SetTags([FromBody] SetTagsRequest req)
@@ -885,20 +1040,39 @@ public class InventionsController(
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 }
var tags = 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;
.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(ToWire(inv));
return Ok(new { Result = 0, Tags = tags });
}
public sealed record CheerRequest(long InventionId);
/// <summary>RecNet.CheerRequest carries a SECOND field — <c>Cheer</c>, the
/// desired state — whose real name survives in the 2023.06 dump
/// (dump.cs:1283338-1283342: <c>long InventionId</c> @0x10,
/// <c>bool Cheer</c> @0x18). Both writes are visible in the ISIL: 2023 sets
/// [rdi+16] and [rdi+24] before <c>JsonUtility.ToJson</c>
/// (OEGFNFEAAGO.txt:11228-11233) and December does the same
/// (2020.12 BBHENFCNLAB.txt:7624-7628) — its wrapper signature is
/// <c>ANJHOOPIAKM(Int64, Boolean)</c> (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.</summary>
public sealed record CheerRequest(long InventionId, bool Cheer = true);
/// <summary>POST <c>api/inventions/v1/cheer</c> — answers with the
/// Status/Invention/InventionVersion envelope like every other invention
/// mutation: the 2023 method is <c>FGLDKEJLAKB&lt;BDNCJIPHHOK&gt;</c>
/// (OEGFNFEAAGO.txt:11065) and December's is
/// <c>IPromise&lt;AHEPPAEOLOD&gt;</c> (2020.12 BBHENFCNLAB.txt:7503). The old
/// <c>{Id, CheerCount}</c> reply left Invention null on 2023 and threw
/// KeyNotFoundException on December, so the tile never refreshed its
/// count.</summary>
[HttpPost("api/inventions/v1/cheer")]
[Authorize]
public async Task<ActionResult> Cheer([FromBody] CheerRequest req)
@@ -907,10 +1081,11 @@ public class InventionsController(
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).
// 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 (existing is null)
if (req.Cheer && existing is null)
{
db.Cheers.Add(new CheerEntity
{
@@ -926,12 +1101,29 @@ public class InventionsController(
PushNotificationId.InventionModerationStateChanged,
new { Type = "cheer", inv.Id, From = pid, inv.CheerCount });
}
return Ok(new { inv.Id, 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);
/// <summary>POST <c>api/inventions/v1/report</c> — the reply is a
/// success/message pair, not a status flag of our own invention. The 2023
/// method is <c>FGLDKEJLAKB&lt;PHMHCPEMABG&gt;</c> (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 <c>{Reported:true}</c> left
/// Success at default false, so the report UI reported failure even though
/// the row persisted. <c>Message</c> must be a string — December's
/// Util.GetKey casts it — so send "" rather than null.</summary>
[HttpPost("api/inventions/v1/report")]
[Authorize]
public async Task<ActionResult> Report([FromBody] ReportInventionRequest req)
@@ -949,7 +1141,7 @@ public class InventionsController(
Message = (req.Details ?? string.Empty)[..Math.Min(1000, (req.Details ?? string.Empty).Length)],
});
await db.SaveChangesAsync();
return Ok(new { Reported = true });
return Ok(new { Success = true, Message = string.Empty });
}
// ── Wire serializers ─────────────────────────────────────────────────
@@ -1000,13 +1192,40 @@ public class InventionsController(
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) => new
private static object ToVersionWire(InventionVersionEntity v)
=> ToVersionWire(v, 0, 0);
/// <summary>The 2023 InventionVersion DTO reads NINE keys
/// (OLPHKLCPFEF.txt:084-295): the six we already emitted plus
/// <c>ChipsCost</c>, <c>CloudVariablesCost</c> and <c>BlobHash</c>.
/// <see cref="InventionVersionEntity"/> 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 (<c>EHENFDMIAIM</c>, 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.</summary>
private static object ToVersionWire(
InventionVersionEntity v, int chipsCost, int cloudVariablesCost) => new
{
v.InventionId,
ReplicationId = string.IsNullOrEmpty(v.ReplicationId)
@@ -1014,6 +1233,34 @@ public class InventionsController(
v.VersionNumber,
v.InstantiationCost,
v.LightsCost,
ChipsCost = chipsCost,
CloudVariablesCost = cloudVariablesCost,
v.BlobName,
BlobHash = string.Empty,
};
/// <summary>Every invention MUTATION route answers with the
/// Status/Invention/InventionVersion envelope, never a bare invention: the
/// 2023 client deserialises <c>BDNCJIPHHOK</c> (key literals at
/// GMBGBPNMGBA.txt:044-095) and the 2020.12 watch deserialises
/// <c>AHEPPAEOLOD</c>, whose reader pulls the same three literals
/// (2020.12 IsilDump AHEPPAEOLOD.txt:110/115/120). Returning the invention
/// flat left <c>Invention</c> null on 2023 and threw KeyNotFoundException on
/// December. <c>Status = 0</c> is success —
/// EnterInventionNameDialog.txt:622-624 loads the boxed Status field and
/// does <c>test eax,eax / je &lt;success&gt;</c>.</summary>
private async Task<object> 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,
};
}
}
@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Globalization;
using System.Text.Json;
using DorkNet.Models.Notification;
using DorkNet.Server.Auth;
@@ -11,8 +12,22 @@ using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.PlayerEvents;
/// <summary>
/// api.rec.net/api/playerevents/v1/* — create, list, RSVP for
/// player-scheduled gatherings.
/// api.rec.net/api/playerevents/v1|v2/* — create, browse, edit, RSVP and
/// broadcast for player-scheduled gatherings.
///
/// The 2023-03-21 client's event API surface is <c>RecNet.Runtime/CBKANFIOBCF</c>
/// (route literals at <c>CBKANFIOBCF.txt:161-4156</c>). Three wire types drive
/// nearly every handler here:
/// * <b>HPIOAGDJHDH</b> (PlayerEvent) — 17 keys, formatter
/// <c>HPMMKFGDAEC.txt:1139-1518</c>.
/// * <b>MDCBEPJCJPO</b> (PlayerEvent + <c>Tags:[{Tag,Type}]</c>) — formatter
/// <c>CENEMCMGDKG.txt:1191-1586</c>, tag entry keys
/// <c>PCMLHLIBLNJ.txt:191-218</c>.
/// * <b>PHHAKLPGNGC</b> (<c>{PlayerEvent,Result,TagModifyResult}</c>) —
/// formatter <c>EEAAGIJHLOJ.txt:267-326</c>, <c>TagModifyResult</c> keys
/// <c>ALJPHDEAHBK.txt:191-218</c>. Every v2 create/edit/delete/field
/// mutation deserialises this wrapper, so a flat event body leaves the
/// client's <c>PlayerEvent</c> null after a save.
/// </summary>
[ApiController]
[Authorize]
@@ -22,14 +37,23 @@ public class PlayerEventsController(
{
private long Me => this.RequireCurrentPlayerId();
// ── Browse (v2) ──────────────────────────────────────────────────────
/// <summary>PlayerEventResponseType.NotGoing — the one RSVP state that
/// does not count towards AttendeeCount.</summary>
private const int NotGoingResponse = 2;
/// <summary>GET <c>api/playerevents/v2</c> — upcoming events list.
/// Returns the next 50 events ordered by start time. Wire shape
/// is the standard PlayerEvent (Id, CreatorPlayerId, RoomId,
/// Title, Description, StartsAt, EndsAt, Capacity).</summary>
// ── Browse ───────────────────────────────────────────────────────────
/// <summary>GET <c>api/playerevents/v2</c> (and <c>v1</c>) — upcoming
/// events list, a BARE JSON array of PlayerEvent.
///
/// The 2023 watch's Events tab reads the unversioned path: HIHEFBMPOGC
/// returns <c>List&lt;HPIOAGDJHDH&gt;</c> and dispatches with verb 0
/// (<c>CBKANFIOBCF.txt:1305</c>, route literal <c>:1304</c>). Only
/// <c>[HttpPost]</c> was registered on <c>api/playerevents/v1</c>, so the
/// whole tab 405'd.</summary>
[AllowAnonymous]
[HttpGet("api/playerevents/v2")]
[HttpGet("api/playerevents/v1")]
public async Task<ActionResult> ListUpcoming(
[FromQuery] int skip = 0, [FromQuery] int take = 50)
{
@@ -41,9 +65,17 @@ public class PlayerEventsController(
.Skip(skip)
.Take(take)
.ToListAsync();
return Ok(rows.Select(ToWire));
return Ok(await ToWireManyAsync(rows));
}
/// <summary>GET <c>api/playerevents/v1|v2/{id}</c>. The client calls this
/// three ways — plain (NLEFILEAMIP → HPIOAGDJHDH) and with
/// <c>?includeDetails=</c> / <c>?clubId=</c> (HLJBCGMONEH / OAKKDBLNKLG →
/// MDCBEPJCJPO, query keys at
/// <c>CBKANFIOBCF_NestedType_DEIBMMABHIK.txt:86,99</c>). Both DTOs are the
/// same object, the detail one just adds <c>Tags</c>, and the Utf8Json
/// formatters skip unknown keys — so we always emit the superset and no
/// branch is needed.</summary>
[AllowAnonymous]
[HttpGet("api/playerevents/v2/{eventId:long}")]
[HttpGet("api/playerevents/v1/{eventId:long}")]
@@ -51,23 +83,28 @@ public class PlayerEventsController(
{
var ev = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == eventId);
if (ev is null) return NotFound();
return Ok(ToWire(ev));
return Ok(await ToWireOneAsync(ev));
}
/// <summary>GET <c>api/playerevents/v1/search</c>. Query keys are
/// <c>query</c>, <c>sort</c> and <c>scheduleFilter</c>
/// (<c>CBKANFIOBCF_NestedType_IONCOJJGHNJ.txt:99,113,126</c>); the latter
/// two are enum ordinals (PJBLEKMMACM / BJBLPLKMLBE).</summary>
[AllowAnonymous]
[HttpGet("api/playerevents/v1/search")]
[HttpGet("api/playerevents/v1/searchlive")]
public async Task<ActionResult> Search(
[FromQuery] string? q = null,
[FromQuery] string? query = null,
[FromQuery] int? sort = null,
[FromQuery] int? scheduleFilter = null,
[FromQuery] int skip = 0,
[FromQuery] int take = 50)
{
var needle = (q ?? query ?? string.Empty).Trim().ToLowerInvariant();
skip = Math.Max(0, skip);
take = Math.Clamp(take, 1, 100);
var rows = db.PlayerEvents
.Where(e => e.EndsAt > DateTime.UtcNow);
var rows = ApplyScheduleFilter(db.PlayerEvents.AsQueryable(), scheduleFilter);
if (needle.Length > 0)
{
rows = rows.Where(e =>
@@ -75,12 +112,70 @@ public class PlayerEventsController(
e.Description.ToLower().Contains(needle));
}
var result = await rows
.OrderBy(e => e.StartsAt)
var result = await ApplySort(rows, sort)
.Skip(skip)
.Take(take)
.ToListAsync();
return Ok(result.Select(ToWire));
return Ok(await ToWireManyAsync(result));
}
/// <summary>GET <c>api/playerevents/v1/searchlive</c> — "happening now".
/// Separate handler from <see cref="Search"/> because the response element
/// type differs: FIKHCEPDIBF returns <c>List&lt;CLDDIKOJMAM&gt;</c>, which
/// is the PlayerEvent object plus <c>PlayerCount</c> (Int32) and
/// <c>IsFull</c> (Boolean) — formatter <c>PDIFGFKAMBG.txt:1267,1294</c>.
/// Head-count comes from the room's live GameSessions rows (their RoomId is
/// the numeric room id as a string, see
/// <c>Services/GameSessionService.cs:91-96</c>).</summary>
[AllowAnonymous]
[HttpGet("api/playerevents/v1/searchlive")]
public async Task<ActionResult> SearchLive(
[FromQuery] string? q = null,
[FromQuery] string? query = null,
[FromQuery] int take = 50)
{
var needle = (q ?? query ?? string.Empty).Trim().ToLowerInvariant();
take = Math.Clamp(take, 1, 100);
var now = DateTime.UtcNow;
var live = db.PlayerEvents.Where(e => e.StartsAt <= now && e.EndsAt > now);
if (needle.Length > 0)
{
live = live.Where(e =>
e.Title.ToLower().Contains(needle) ||
e.Description.ToLower().Contains(needle));
}
var rows = await live.OrderBy(e => e.StartsAt).Take(take).ToListAsync();
var wire = await ToWireManyAsync(rows);
var roomKeys = rows.Select(r => r.RoomId.ToString()).Distinct().ToList();
var sessions = await db.GameSessions
.Where(s => roomKeys.Contains(s.RoomId))
.GroupBy(s => s.RoomId)
.Select(g => new
{
RoomId = g.Key,
Players = g.Sum(s => s.PlayerCount),
Capacity = g.Sum(s => s.MaxCapacity),
})
.ToListAsync();
var byRoom = sessions.ToDictionary(s => s.RoomId, s => s);
for (var i = 0; i < rows.Count; i++)
{
var players = 0;
var capacity = 0;
if (byRoom.TryGetValue(rows[i].RoomId.ToString(), out var agg))
{
players = agg.Players;
capacity = agg.Capacity;
}
wire[i]["PlayerCount"] = players;
wire[i]["IsFull"] = capacity > 0 && players >= capacity;
}
return Ok(wire);
}
[AllowAnonymous]
@@ -92,53 +187,53 @@ public class PlayerEventsController(
.OrderBy(e => e.StartsAt)
.Take(100)
.ToListAsync();
return Ok(rows.Select(ToWire));
return Ok(await ToWireManyAsync(rows));
}
/// <summary>GET <c>api/playerevents/v1/club/{clubId}</c>. PHNEDJAPIKI
/// returns IOKLNPFOLGI — an OBJECT
/// <c>{Events:[PlayerEvent],ContinuationToken:String}</c>
/// (<c>GHBIGGJKPFI.txt:203-238</c>), not a bare array, and passes
/// <c>take</c> / <c>continuationToken</c> query params
/// (<c>CBKANFIOBCF_NestedType_PKDDOCBDBIM.txt:80,89</c>). The token here is
/// just the next skip offset; empty string means "no more pages" (the
/// client's field is a plain String, so never emit null).</summary>
[AllowAnonymous]
[HttpGet("api/playerevents/v1/club/{clubId:long}")]
public async Task<ActionResult> ForClub(long clubId)
public async Task<ActionResult> ForClub(
long clubId,
[FromQuery] int? take = null,
[FromQuery] string? continuationToken = null)
{
var memberIds = await db.ClubMemberships
.Where(m => m.ClubId == clubId)
.Select(m => m.PlayerId)
.ToListAsync();
if (memberIds.Count == 0) return Ok(Array.Empty<object>());
var pageSize = Math.Clamp(take ?? 50, 1, 100);
var skip = int.TryParse(continuationToken, out var parsed) && parsed > 0 ? parsed : 0;
var roomIds = await db.Rooms
.Where(r => memberIds.Contains(r.CreatorPlayerId))
.Select(r => r.Id)
.ToListAsync();
if (roomIds.Count == 0) return Ok(Array.Empty<object>());
// Fetch one extra row to find out whether another page exists.
var rows = await ClubEventsAsync(new[] { clubId }, skip, pageSize + 1);
var hasMore = rows.Count > pageSize;
if (hasMore) rows.RemoveAt(rows.Count - 1);
var rows = await db.PlayerEvents
.Where(e => roomIds.Contains(e.RoomId) && e.EndsAt > DateTime.UtcNow)
.OrderBy(e => e.StartsAt)
.Take(100)
.ToListAsync();
return Ok(rows.Select(ToWire));
return Ok(new
{
Events = await ToWireManyAsync(rows),
ContinuationToken = hasMore ? (skip + pageSize).ToString() : string.Empty,
});
}
/// <summary>GET <c>api/playerevents/v1/clubs</c> — events for a specific
/// set of clubs. IEGLMOOPLED appends one repeated <c>id</c> query value per
/// club (<c>CBKANFIOBCF_NestedType_LKJFABHPIAN.txt:59</c>); ignoring them
/// returned every club's events and the watch could not bucket the list.
/// </summary>
[AllowAnonymous]
[HttpGet("api/playerevents/v1/clubs")]
public async Task<ActionResult> ClubEvents([FromQuery] int take = 100)
{
take = Math.Clamp(take, 1, 200);
var clubPlayerIds = await db.ClubMemberships
.Select(m => m.PlayerId)
.Distinct()
.ToListAsync();
if (clubPlayerIds.Count == 0) return Ok(Array.Empty<object>());
var roomIds = await db.Rooms
.Where(r => clubPlayerIds.Contains(r.CreatorPlayerId))
.Select(r => r.Id)
.ToListAsync();
var rows = await db.PlayerEvents
.Where(e => roomIds.Contains(e.RoomId) && e.EndsAt > DateTime.UtcNow)
.OrderBy(e => e.StartsAt)
.Take(take)
.ToListAsync();
return Ok(rows.Select(ToWire));
var clubIds = new List<long>();
foreach (var value in Request.Query["id"]) AddDelimitedIds(clubIds, value);
var rows = await ClubEventsAsync(clubIds.Distinct().ToList(), 0, take);
return Ok(await ToWireManyAsync(rows));
}
public sealed class EventBulkRequest
@@ -148,6 +243,10 @@ public class PlayerEventsController(
public List<long>? Ids { get; set; }
}
/// <summary>POST <c>api/playerevents/v1/bulk</c> — verb 2 at
/// <c>CBKANFIOBCF.txt:660</c>; the form field is <c>Ids</c>
/// (<c>CBKANFIOBCF_NestedType_PIKPJPBHOAC.txt:64</c>). GET is kept for the
/// 2020 client, which puts the ids in the query string.</summary>
[AllowAnonymous]
[HttpPost("api/playerevents/v1/bulk")]
[HttpGet("api/playerevents/v1/bulk")]
@@ -158,21 +257,29 @@ public class PlayerEventsController(
var rows = await db.PlayerEvents
.Where(e => ids.Contains(e.Id))
.ToListAsync();
return Ok(rows.Select(ToWire));
return Ok(await ToWireManyAsync(rows));
}
/// <summary>GET <c>api/playerevents/v1/tagfilters</c> — AKCLLEJNFFD reads
/// three String lists: <c>PinnedFilters</c>, <c>PopularFilters</c> and
/// <c>TrendingFilters</c> (<c>HMDONHHKKGA.txt:279-346</c>). A missing key
/// leaves the list null and the chips UI NREs when it enumerates.</summary>
[AllowAnonymous]
[HttpGet("api/playerevents/v1/tagfilters")]
public IActionResult TagFilters() => Ok(new
{
PinnedFilters = new[] { "game", "social", "competition", "class", "club" },
PopularFilters = new[] { "game", "social", "competition", "class", "club" },
TrendingFilters = new[] { "game", "social", "competition", "class", "club" },
});
/// <summary>GET <c>api/playerevents/v1/all</c> — the watch's
/// <c>LocalPlayerEventInfo</c> fetch (caller's created + RSVP'd
/// events). Response keys (both required, via
/// <c>Util.GetObjectListKey</c>): <c>Created</c>, <c>Responses</c>.</summary>
/// <c>LocalPlayerEventInfo</c> fetch (caller's created + RSVP'd events).
/// PCELKKHNPHJ is <c>{Created:[PlayerEvent], Responses:[COAGAJPELCG]}</c>
/// (<c>ENJDEONHNMO.txt:191-218</c>) where each response entry is
/// <c>{PlayerEvent, PlayerEventResponse}</c> (<c>HJKLIIPPPEM.txt:215-258</c>)
/// — a flat response row leaves <c>PlayerEvent</c> null and the My Events
/// page NREs.</summary>
[AllowAnonymous]
[HttpGet("api/playerevents/v1/all")]
public async Task<ActionResult> AllPlayerEvents()
@@ -180,99 +287,477 @@ public class PlayerEventsController(
var pid = this.CurrentPlayerId();
if (pid is not long me)
return Ok(new { Created = Array.Empty<object>(), Responses = Array.Empty<object>() });
var created = await db.PlayerEvents
.Where(e => e.CreatorPlayerId == me)
.OrderByDescending(e => e.StartsAt)
.Select(e => new
{
e.Id,
e.Title,
e.Description,
e.RoomId,
e.StartsAt,
e.EndsAt,
e.Capacity,
})
.ToListAsync();
var responses = await db.PlayerEventResponses
.Where(r => r.PlayerId == me)
.Select(r => new
{
r.Id,
r.EventId,
r.Response,
r.CreatedAt,
})
.ToListAsync();
return Ok(new { Created = created, Responses = responses });
return await AllForPlayerAsync(me);
}
[AllowAnonymous]
[HttpGet("api/playerevents/v1/all/{accountId:long}")]
public async Task<ActionResult> AllPlayerEventsForAccount(long accountId)
public Task<ActionResult> AllPlayerEventsForAccount(long accountId)
=> AllForPlayerAsync(accountId);
private async Task<ActionResult> AllForPlayerAsync(long playerId)
{
var created = await db.PlayerEvents
.Where(e => e.CreatorPlayerId == accountId)
.Where(e => e.CreatorPlayerId == playerId)
.OrderByDescending(e => e.StartsAt)
.Select(e => new
{
e.Id,
e.Title,
e.Description,
e.RoomId,
e.StartsAt,
e.EndsAt,
e.Capacity,
})
.Take(200)
.ToListAsync();
var responses = await db.PlayerEventResponses
.Where(r => r.PlayerId == accountId)
.Select(r => new
{
r.Id,
r.EventId,
r.Response,
r.CreatedAt,
})
.Where(r => r.PlayerId == playerId)
.OrderByDescending(r => r.CreatedAt)
.Take(200)
.ToListAsync();
return Ok(new { Created = created, Responses = responses });
// Each response entry embeds its own event, which may not be one the
// caller created — pull those in too so PlayerEvent is never null.
var createdIds = created.Select(e => e.Id).ToHashSet();
var missingIds = responses.Select(r => r.EventId).Where(id => !createdIds.Contains(id)).Distinct().ToList();
var extraEvents = new List<PlayerEventEntity>();
if (missingIds.Count > 0)
extraEvents = await db.PlayerEvents.Where(e => missingIds.Contains(e.Id)).ToListAsync();
var all = created.Concat(extraEvents).ToList();
var wire = await ToWireMapAsync(all);
return Ok(new
{
Created = created.Select(e => wire[e.Id]).ToList(),
Responses = responses
.Where(r => wire.ContainsKey(r.EventId))
.Select(r => new
{
PlayerEvent = wire[r.EventId],
PlayerEventResponse = ResponseWire(r),
})
.ToList(),
});
}
/// <summary>POST <c>api/playerevents/v1/{eventId}/responses</c> —
/// alternate RSVP route the watch uses for the response-list
/// flow (older / sub-room view). Body is the same RsvpRequest
/// as <c>v1/{id}/rsvp</c>; behaves identically.</summary>
[HttpPost("api/playerevents/v1/{eventId:long}/responses")]
public Task<ActionResult> RespondList(long eventId, [FromBody] RsvpRequest body)
=> Rsvp(eventId, body);
/// <summary>Wire shape verified against
/// <c>Cpp2IL_ISIL/.../RecNet/PlayerEvent.txt:769-815</c>:
/// PascalCase keys <c>PlayerEventId, Name, Description,
/// StartTime, EndTime, CreatorPlayerId, AttendeeCount, RoomId,
/// ImageName, Accessibility</c>. Server-side we store these
/// under different names (Title, StartsAt, EndsAt, Capacity) —
/// remap explicitly here so the watch's
/// <c>PlayerEvent.Deserialize</c> picks them up.</summary>
private static object ToWire(PlayerEventEntity ev) => new
/// <summary>GET <c>api/playerevents/v1/{eventId}/responses</c> — the event
/// detail page's attendee list. DFABBIDLDPE dispatches with verb 0
/// (<c>CBKANFIOBCF.txt:1699</c>, route literal <c>:1689</c>) and reads
/// <c>List&lt;JPIKCIGABBI&gt;</c>; the path was registered POST-only, so
/// the list 405'd.</summary>
[AllowAnonymous]
[HttpGet("api/playerevents/v1/{eventId:long}/responses")]
public async Task<ActionResult> ResponsesForEvent(long eventId)
{
PlayerEventId = ev.Id,
Name = ev.Title,
ev.Description,
StartTime = ev.StartsAt,
EndTime = ev.EndsAt,
CreatorPlayerId = (int)ev.CreatorPlayerId,
AttendeeCount = 0, // computed at query time when needed
ev.RoomId,
ImageName = string.Empty,
Accessibility = 1, // Public
var rows = await db.PlayerEventResponses
.Where(r => r.EventId == eventId)
.OrderBy(r => r.CreatedAt)
.Take(500)
.ToListAsync();
return Ok(rows.Select(ResponseWire).ToList());
}
// ── Wire shape ───────────────────────────────────────────────────────
/// <summary>The PlayerEvent fields the 2023 client carries that
/// <see cref="PlayerEventEntity"/> has no column for. Adding columns needs
/// a migration in <c>Data/Entities</c>, so these live in the PlayerSettings
/// side-table: one JSON row per event under
/// <c>playerevent:{id}:wire</c>, plus a flat <c>playerevent:{id}:club</c>
/// row that keeps club lookups a single indexed query.
///
/// Public (not private) purely so System.Text.Json's reflection-based
/// (de)serialiser can see the type and its accessors.</summary>
public sealed class EventExtras
{
public long? SubRoomId { get; set; }
public long? ClubId { get; set; }
public string ImageName { get; set; } = string.Empty;
/// <summary>BAMLHAODDOG ordinal. The enum's member order is not
/// recoverable from the ISIL (enums carry no method bodies), so events
/// that never had accessibility set keep the historical default of 1
/// rather than guessing a new one; once the client PUTs a value we
/// echo exactly what it sent.</summary>
public int Accessibility { get; set; } = 1;
public bool IsMultiInstance { get; set; }
public bool SupportMultiInstanceRoomChat { get; set; }
public int DefaultBroadcastPermissions { get; set; }
public int CanRequestBroadcastPermissions { get; set; }
public long? BroadcastingRoomInstanceId { get; set; }
public List<string> Tags { get; set; } = [];
}
private static string ExtrasKey(long eventId) => $"playerevent:{eventId}:wire";
private static string ClubKey(long eventId) => $"playerevent:{eventId}:club";
/// <summary>Wire shape verified against the client's Utf8Json formatter
/// <c>HPMMKFGDAEC.txt:1139-1518</c> (and the details variant
/// <c>CENEMCMGDKG.txt:1191-1586</c>): PascalCase keys <c>PlayerEventId,
/// CreatorPlayerId, RoomId, SubRoomId, ClubId, Name, Description,
/// ImageName, StartTime, EndTime, AttendeeCount, Accessibility,
/// IsMultiInstance, SupportMultiInstanceRoomChat,
/// DefaultBroadcastPermissions, CanRequestBroadcastPermissions,
/// BroadcastingRoomInstanceId</c> — property types read off the getters in
/// <c>HPIOAGDJHDH.txt</c>. Server-side we store some of these under other
/// names (Title, StartsAt, EndsAt), so remap explicitly.</summary>
private static Dictionary<string, object?> ToWire(
PlayerEventEntity ev, EventExtras extras, int attendeeCount) => new()
{
["PlayerEventId"] = ev.Id,
["CreatorPlayerId"] = (int)ev.CreatorPlayerId,
["RoomId"] = ev.RoomId,
["SubRoomId"] = extras.SubRoomId,
["ClubId"] = extras.ClubId,
["Name"] = ev.Title,
["Description"] = ev.Description,
["ImageName"] = extras.ImageName,
["StartTime"] = ev.StartsAt,
["EndTime"] = ev.EndsAt,
["AttendeeCount"] = attendeeCount,
["Accessibility"] = extras.Accessibility,
["IsMultiInstance"] = extras.IsMultiInstance,
["SupportMultiInstanceRoomChat"] = extras.SupportMultiInstanceRoomChat,
["DefaultBroadcastPermissions"] = extras.DefaultBroadcastPermissions,
["CanRequestBroadcastPermissions"] = extras.CanRequestBroadcastPermissions,
["BroadcastingRoomInstanceId"] = extras.BroadcastingRoomInstanceId,
// MDCBEPJCJPO (the includeDetails DTO) is this same object plus
// Tags:[{Tag,Type}] (entry keys PCMLHLIBLNJ.txt:191-218). The tagless
// formatter skips unknown keys, so emitting Tags unconditionally is
// inert there and saves branching on includeDetails. Type is the
// KIKIEHKBHNM ordinal — we only ever store free-form tags, so 0.
["Tags"] = extras.Tags.Select(t => new { Tag = t, Type = 0 }).ToList(),
};
/// <summary>JPIKCIGABBI — <c>{PlayerEventResponseId, PlayerEventId,
/// PlayerId(Int32), CreatedAt, Type}</c>, keys at
/// <c>DDDLCGDEPNH.txt:395-502</c>, types off the getters in
/// <c>JPIKCIGABBI.txt</c>.</summary>
private static object ResponseWire(PlayerEventResponseEntity r) => new
{
PlayerEventResponseId = r.Id,
PlayerEventId = r.EventId,
PlayerId = (int)r.PlayerId,
r.CreatedAt,
Type = r.Response,
};
/// <summary>PHHAKLPGNGC — the wrapper every v2 mutation deserialises.
/// </summary>
private static object Wrapper(PlayerEventEntity ev, EventExtras extras, int attendeeCount) => new
{
PlayerEvent = ToWire(ev, extras, attendeeCount),
Result = 0, // BNCFHOOCHAI success
TagModifyResult = new { Result = 0, Tags = extras.Tags },
};
private async Task<object> WrapperAsync(PlayerEventEntity ev)
{
var extras = (await LoadExtrasAsync(new[] { ev }))[ev.Id];
var counts = await AttendeeCountsAsync(new[] { ev.Id });
return Wrapper(ev, extras, counts.GetValueOrDefault(ev.Id));
}
private async Task<List<Dictionary<string, object?>>> ToWireManyAsync(IReadOnlyList<PlayerEventEntity> rows)
{
if (rows.Count == 0) return [];
var extras = await LoadExtrasAsync(rows);
var counts = await AttendeeCountsAsync(rows.Select(r => r.Id).ToList());
return rows
.Select(r => ToWire(r, extras[r.Id], counts.GetValueOrDefault(r.Id)))
.ToList();
}
private async Task<Dictionary<long, Dictionary<string, object?>>> ToWireMapAsync(
IReadOnlyList<PlayerEventEntity> rows)
{
var wire = await ToWireManyAsync(rows);
var map = new Dictionary<long, Dictionary<string, object?>>();
for (var i = 0; i < rows.Count; i++) map[rows[i].Id] = wire[i];
return map;
}
private async Task<Dictionary<string, object?>> ToWireOneAsync(PlayerEventEntity ev)
=> (await ToWireManyAsync(new[] { ev }))[0];
private async Task<Dictionary<long, EventExtras>> LoadExtrasAsync(IReadOnlyCollection<PlayerEventEntity> events)
{
var map = new Dictionary<long, EventExtras>();
foreach (var ev in events) map[ev.Id] = new EventExtras();
if (map.Count == 0) return map;
var keys = map.Keys.Select(ExtrasKey).ToList();
var rows = await db.PlayerSettings
.AsNoTracking()
.Where(s => keys.Contains(s.Key))
.Select(s => new { s.Key, s.Value })
.ToListAsync();
foreach (var row in rows)
{
if (EventIdFromKey(row.Key) is not long id || !map.ContainsKey(id)) continue;
var parsed = DeserializeExtras(row.Value);
if (parsed is not null) map[id] = parsed;
}
return map;
}
private static long? EventIdFromKey(string key)
{
var parts = key.Split(':');
return parts.Length >= 3 && long.TryParse(parts[1], out var id) ? id : null;
}
private static EventExtras? DeserializeExtras(string json)
{
if (string.IsNullOrWhiteSpace(json)) return null;
try
{
return JsonSerializer.Deserialize<EventExtras>(json);
}
catch (JsonException)
{
return null;
}
}
private async Task SaveExtrasAsync(PlayerEventEntity evt, EventExtras extras)
{
// PlayerSettingEntity.Value is MaxLength(1024); bound the tag list so a
// long one can never truncate the JSON blob into unparseable garbage.
if (extras.Tags.Count > 12) extras.Tags = extras.Tags.Take(12).ToList();
extras.Tags = extras.Tags.Select(t => Clamp(t, 32)).ToList();
await UpsertSettingAsync(evt.CreatorPlayerId, ExtrasKey(evt.Id),
JsonSerializer.Serialize(extras));
// Flat index row so "events for club X" stays one indexed lookup
// instead of a scan through the JSON blobs.
await UpsertSettingAsync(evt.CreatorPlayerId, ClubKey(evt.Id),
extras.ClubId?.ToString(CultureInfo.InvariantCulture) ?? string.Empty);
evt.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
}
private async Task UpsertSettingAsync(long playerId, string key, string value)
{
var row = await db.PlayerSettings
.FirstOrDefaultAsync(s => s.PlayerId == playerId && s.Key == key);
if (row is null)
{
db.PlayerSettings.Add(new PlayerSettingEntity
{
PlayerId = playerId,
Key = key,
Value = value,
});
}
else
{
row.Value = value;
}
}
private async Task<Dictionary<long, int>> AttendeeCountsAsync(IReadOnlyCollection<long> eventIds)
{
if (eventIds.Count == 0) return new Dictionary<long, int>();
var ids = eventIds.ToList();
var rows = await db.PlayerEventResponses
.Where(r => ids.Contains(r.EventId) && r.Response != NotGoingResponse)
.GroupBy(r => r.EventId)
.Select(g => new { EventId = g.Key, Count = g.Count() })
.ToListAsync();
return rows.ToDictionary(r => r.EventId, r => r.Count);
}
// ── Query shaping ────────────────────────────────────────────────────
/// <summary>'scheduleFilter' (BJBLPLKMLBE) is an enum ordinal. Only the
/// query-key literal survives in the ISIL — enums have no method bodies to
/// disassemble — so the member order below is UNVERIFIED. Ordinal 0 (and
/// absent) reproduces the previous behaviour exactly, and a wrong guess for
/// the others still returns a valid, non-empty event list rather than an
/// error.</summary>
private static IQueryable<PlayerEventEntity> ApplyScheduleFilter(
IQueryable<PlayerEventEntity> rows, int? scheduleFilter)
{
var now = DateTime.UtcNow;
return scheduleFilter switch
{
1 => rows.Where(e => e.StartsAt <= now && e.EndsAt > now),
2 => rows.Where(e => e.EndsAt > now && e.StartsAt < now.AddDays(1)),
3 => rows.Where(e => e.EndsAt > now && e.StartsAt < now.AddDays(7)),
_ => rows.Where(e => e.EndsAt > now),
};
}
/// <summary>'sort' (PJBLEKMMACM) — same UNVERIFIED-ordinal caveat as
/// <see cref="ApplyScheduleFilter"/>; 0/absent keeps soonest-first.</summary>
private static IQueryable<PlayerEventEntity> ApplySort(
IQueryable<PlayerEventEntity> rows, int? sort) => sort switch
{
1 => rows.OrderByDescending(e => e.CreatedAt),
2 => rows.OrderBy(e => e.EndsAt),
_ => rows.OrderBy(e => e.StartsAt),
};
/// <summary>Events attached to any of <paramref name="clubIds"/>. Explicit
/// ClubId (persisted since the v2 create/edit body carries it) wins; when
/// nothing carries one we fall back to the historical heuristic of "events
/// in rooms owned by club members" so pre-existing data still populates the
/// tab.</summary>
private async Task<List<PlayerEventEntity>> ClubEventsAsync(
IReadOnlyCollection<long> clubIds, int skip, int take)
{
var explicitIds = await EventIdsForClubsAsync(clubIds);
IQueryable<PlayerEventEntity> q;
if (explicitIds.Count > 0)
{
q = db.PlayerEvents.Where(e => explicitIds.Contains(e.Id) && e.EndsAt > DateTime.UtcNow);
}
else
{
var roomIds = await ClubRoomIdsAsync(clubIds);
if (roomIds.Count == 0) return [];
q = db.PlayerEvents.Where(e => roomIds.Contains(e.RoomId) && e.EndsAt > DateTime.UtcNow);
}
return await q.OrderBy(e => e.StartsAt).Skip(skip).Take(take).ToListAsync();
}
private async Task<List<long>> EventIdsForClubsAsync(IReadOnlyCollection<long> clubIds)
{
if (clubIds.Count == 0) return [];
var wanted = clubIds.Select(c => c.ToString(CultureInfo.InvariantCulture)).ToList();
var keys = await db.PlayerSettings
.AsNoTracking()
.Where(s => wanted.Contains(s.Value)
&& s.Key.StartsWith("playerevent:")
&& s.Key.EndsWith(":club"))
.Select(s => s.Key)
.ToListAsync();
return keys
.Select(EventIdFromKey)
.Where(id => id is not null)
.Select(id => id!.Value)
.Distinct()
.ToList();
}
private async Task<List<long>> ClubRoomIdsAsync(IReadOnlyCollection<long> clubIds)
{
var memberships = db.ClubMemberships.AsQueryable();
if (clubIds.Count > 0)
{
var ids = clubIds.ToList();
memberships = memberships.Where(m => ids.Contains(m.ClubId));
}
var memberIds = await memberships.Select(m => m.PlayerId).Distinct().ToListAsync();
if (memberIds.Count == 0) return [];
return await db.Rooms
.Where(r => memberIds.Contains(r.CreatorPlayerId))
.Select(r => r.Id)
.ToListAsync();
}
// ── Create / edit (v2) ───────────────────────────────────────────────
/// <summary>POST <c>api/playerevents/v2</c> — the 2023 create-event flow.
/// KKMMBCDGFFN posts BIEFKAOABMP as a RawJsonForm body (verb 2 at
/// <c>CBKANFIOBCF.txt:2390</c>) whose 14 keys are
/// <c>RoomId, SubRoomId, ClubId, Name, Description, Tags, ImageName,
/// StartTime, EndTime, Accessibility, IsMultiInstance,
/// SupportMultiInstanceRoomChat, DefaultBroadcastPermissions,
/// CanRequestBroadcastPermissions</c> (<c>ACKNGEEHHAE.txt:935-1234</c>),
/// and reads the PHHAKLPGNGC wrapper back. Only <c>[HttpGet]</c> was
/// registered on this path, so creating an event 405'd.</summary>
[HttpPost("api/playerevents/v2")]
public async Task<IActionResult> CreateV2()
{
var name = (await ReadStringFieldAsync("Name", "name", "title"))?.Trim() ?? string.Empty;
if (name.Length == 0) return BadRequest("missing_name");
var start = await ReadDateFieldAsync("StartTime", "startTime", "startsAt") ?? DateTime.UtcNow;
var end = await ReadDateFieldAsync("EndTime", "endTime", "endsAt") ?? start.AddHours(1);
var description = (await ReadStringFieldAsync("Description", "description"))?.Trim() ?? string.Empty;
var evt = new PlayerEventEntity
{
CreatorPlayerId = Me,
RoomId = await ReadLongFieldAsync("RoomId", "roomId") ?? 0,
Title = Clamp(name, 128),
Description = Clamp(description, 2000),
StartsAt = start,
EndsAt = end > start ? end : start.AddHours(1),
};
db.PlayerEvents.Add(evt);
await db.SaveChangesAsync();
var extras = new EventExtras();
await ApplyBodyToExtrasAsync(extras);
await SaveExtrasAsync(evt, extras);
return Ok(await WrapperAsync(evt));
}
/// <summary>POST <c>api/playerevents/v2/{eventId}</c> — edit-event save.
/// IOEJKIPJFOI posts the same BIEFKAOABMP body with verb 2
/// (<c>CBKANFIOBCF.txt:2595</c>, route literal <c>:2579</c>); the path was
/// GET-only, so editing 405'd.</summary>
[HttpPost("api/playerevents/v2/{eventId:long}")]
public async Task<IActionResult> EditV2(long eventId)
{
var evt = await GetOwnedEventAsync(eventId);
if (evt is null) return NotFound();
var name = (await ReadStringFieldAsync("Name", "name", "title"))?.Trim();
if (!string.IsNullOrEmpty(name)) evt.Title = Clamp(name, 128);
var description = (await ReadStringFieldAsync("Description", "description"))?.Trim();
if (description is not null) evt.Description = Clamp(description, 2000);
var roomId = await ReadLongFieldAsync("RoomId", "roomId");
if (roomId is long room && room > 0) evt.RoomId = room;
var start = await ReadDateFieldAsync("StartTime", "startTime", "startsAt");
var end = await ReadDateFieldAsync("EndTime", "endTime", "endsAt");
if (start is DateTime s) evt.StartsAt = s;
if (end is DateTime e) evt.EndsAt = e > evt.StartsAt ? e : evt.StartsAt.AddHours(1);
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
await ApplyBodyToExtrasAsync(extras);
await SaveExtrasAsync(evt, extras);
await NotifyAttendeesAsync(evt, PushNotificationId.PlayerEventUpdated);
return Ok(await WrapperAsync(evt));
}
/// <summary>Copies whatever subset of the BIEFKAOABMP body is present onto
/// <paramref name="extras"/>. Absent keys are left alone so the same helper
/// serves both create and edit.</summary>
private async Task ApplyBodyToExtrasAsync(EventExtras extras)
{
var fields = await ReadBodyAsync();
if (fields.Fields.ContainsKey("SubRoomId"))
extras.SubRoomId = await ReadLongFieldAsync("SubRoomId", "subRoomId");
if (fields.Fields.ContainsKey("ClubId"))
extras.ClubId = await ReadLongFieldAsync("ClubId", "clubId");
var imageName = await ReadStringFieldAsync("ImageName", "imageName");
if (imageName is not null) extras.ImageName = Clamp(imageName.Trim(), 128);
if (await ReadIntFieldAsync("Accessibility", "accessibility") is int accessibility)
extras.Accessibility = accessibility;
if (await ReadBoolFieldAsync("IsMultiInstance", "isMultiInstance") is bool multi)
extras.IsMultiInstance = multi;
if (await ReadBoolFieldAsync("SupportMultiInstanceRoomChat", "supportMultiInstanceRoomChat",
"supportsMultiInstanceRoomChat") is bool chat)
extras.SupportMultiInstanceRoomChat = chat;
if (await ReadIntFieldAsync("DefaultBroadcastPermissions", "defaultBroadcastPermissions") is int def)
extras.DefaultBroadcastPermissions = def;
if (await ReadIntFieldAsync("CanRequestBroadcastPermissions", "canRequestBroadcastPermissions") is int req)
extras.CanRequestBroadcastPermissions = req;
var tags = await ReadStringListFieldAsync("Tags", "tags");
if (tags is not null) extras.Tags = tags;
}
// ── Create / RSVP (v1, shared with the 2020 client) ──────────────────
public sealed record CreateEventRequest(
string Title, string? Description, long RoomId,
DateTime StartsAt, DateTime EndsAt, int Capacity);
@@ -295,11 +780,7 @@ public class PlayerEventsController(
};
db.PlayerEvents.Add(ev);
await db.SaveChangesAsync();
return Ok(new
{
ev.Id, ev.Title, ev.Description, ev.RoomId,
ev.StartsAt, ev.EndsAt, ev.Capacity,
});
return Ok(await ToWireOneAsync(ev));
}
public sealed record RsvpRequest(int Response);
@@ -328,25 +809,45 @@ public class PlayerEventsController(
await notifications.NotifyAsync(ev.CreatorPlayerId,
PushNotificationId.PlayerEventResponseChanged,
new { ev.Id, From = Me, body.Response });
// Left as the historical shape on purpose: this route is 2020-only
// (the 2023 client GETs .../responses and POSTs v1/respond instead),
// so there is no 2023 DTO to match here.
return Ok(new { row.EventId, row.PlayerId, row.Response });
}
/// <summary>POST <c>api/playerevents/v1/respond</c> — set the
/// caller's RSVP state for an event. <c>Response</c> is the
/// <c>PlayerEventResponseType</c> enum (0=Going, 1=Maybe, 2=Pass).
/// Idempotent — re-responding overwrites.
///
/// Wire return is a BARE int (<c>DGOPHENCPOC</c> enum value:
/// 0=Success, plus various error codes). Returning an object
/// here throws InvalidCastException on the 2020.12 deserialiser
/// — verified at <c>recroom-2020-client-response-contracts.md</c>
/// under the playerevents/v1/respond section.</summary>
/// <summary>POST <c>api/playerevents/v1/{eventId}/responses</c> —
/// alternate RSVP route the 2020 watch uses for the response-list
/// flow (older / sub-room view). Body is the same RsvpRequest
/// as <c>v1/{id}/rsvp</c>; behaves identically. The 2023 client GETs this
/// path instead — see <see cref="ResponsesForEvent"/>.</summary>
[HttpPost("api/playerevents/v1/{eventId:long}/responses")]
public Task<ActionResult> RespondList(long eventId, [FromBody] RsvpRequest body)
=> Rsvp(eventId, body);
/// <summary>POST <c>api/playerevents/v1/respond</c> — set the caller's RSVP
/// state. Two client generations share this route with different
/// contracts:
/// * 2020.12 posts a form and reads a BARE int (DGOPHENCPOC); returning
/// an object throws InvalidCastException there — see
/// <c>recroom-2020-client-response-contracts.md</c>.
/// * 2023-03 posts a RawJsonForm body <c>{PlayerEventId, Type}</c>
/// (NKIFBKJALEJ, keys at <c>GFHNJMMBHFD.txt:203-238</c>; body build at
/// <c>CBKANFIOBCF_NestedType_NFCEOKLHDAG.txt:81-112</c>) and reads
/// CEKABGOIOAF, an OBJECT <c>{Result:int}</c>
/// (<c>GJJGDENPOAP.txt:139-150</c>).
/// Branch the response shape on the body encoding the caller actually
/// used — that is the only signal that distinguishes them.</summary>
[HttpPost("api/playerevents/v1/respond")]
public async Task<IActionResult> RespondForm(
[FromForm(Name = "EventId")] long? eventId,
[FromForm(Name = "Response")] int response = 0)
public async Task<IActionResult> RespondForm()
{
if (eventId is not long evt || evt <= 0) return Ok(2 /*NoSuchEvent*/);
var legacyForm = Request.HasFormContentType;
var eventId = await ReadLongFieldAsync("PlayerEventId", "playerEventId", "EventId", "eventId");
if (eventId is not long evt || evt <= 0) return RespondResult(legacyForm, 2 /*NoSuchEvent*/);
var response = await ReadIntFieldAsync("Type", "type", "Response", "response") ?? 0;
if (!await db.PlayerEvents.AnyAsync(e => e.Id == evt))
return RespondResult(legacyForm, 2 /*NoSuchEvent*/);
var existing = await db.PlayerEventResponses
.FirstOrDefaultAsync(r => r.PlayerId == Me && r.EventId == evt);
if (existing is null)
@@ -354,35 +855,65 @@ public class PlayerEventsController(
else
existing.Response = response;
await db.SaveChangesAsync();
return Ok(0 /*Success*/);
return RespondResult(legacyForm, 0 /*Success*/);
}
/// <summary>POST <c>api/playerevents/v1/deleteResponse</c> — drop
/// the caller's RSVP row. Same bare-int wire shape as
/// <see cref="RespondForm"/>.</summary>
/// <summary>POST <c>api/playerevents/v1/deleteResponse</c> — drop the
/// caller's RSVP row. Same two-generation split as
/// <see cref="RespondForm"/>. The 2023 body is
/// <c>JsonUtility.ToJson(RecNet.Events.DeleteResponseRequest)</c>
/// (<c>CBKANFIOBCF_NestedType_MDJMHFHPOBO.txt:65-78</c>) whose single field
/// is name-preserved: <c>public long PlayerEventId</c>
/// (2023.06.21 <c>dump.cs:1291301</c>).</summary>
[HttpPost("api/playerevents/v1/deleteResponse")]
public async Task<IActionResult> DeleteResponseForm([FromForm(Name = "EventId")] long? eventId)
public async Task<IActionResult> DeleteResponseForm()
{
if (eventId is not long evt) return Ok(2 /*NoSuchEvent*/);
var legacyForm = Request.HasFormContentType;
var eventId = await ReadLongFieldAsync("PlayerEventId", "playerEventId", "EventId", "eventId");
if (eventId is not long evt) return RespondResult(legacyForm, 2 /*NoSuchEvent*/);
await db.PlayerEventResponses
.Where(r => r.PlayerId == Me && r.EventId == evt)
.ExecuteDeleteAsync();
return Ok(0 /*Success*/);
return RespondResult(legacyForm, 0 /*Success*/);
}
/// <summary>DELETE <c>api/playerevents/v2/delete/{id}</c> — hard
/// delete an event the caller created.</summary>
private IActionResult RespondResult(bool legacyForm, int code)
=> legacyForm ? Ok(code) : Ok(new { Result = code });
/// <summary>DELETE/POST <c>api/playerevents/v2/delete/{id}</c> — hard
/// delete an event the caller created. GJMCEDALKJO dispatches with verb 2
/// (<c>CBKANFIOBCF.txt:4166</c>, route literal <c>:4156</c>) and reads the
/// PHHAKLPGNGC wrapper, so POST plus the wrapper body are both required;
/// DELETE stays for the 2020 client.</summary>
[HttpPost("api/playerevents/v2/delete/{id:long}")]
[HttpDelete("api/playerevents/v2/delete/{id:long}")]
public async Task<IActionResult> DeleteByPath(long id)
{
var evt = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == id && e.CreatorPlayerId == Me);
if (evt is null) return NotFound();
// Snapshot the wire shape before the rows go away — the wrapper still
// has to echo the event that was deleted.
var extras = (await LoadExtrasAsync(new[] { evt }))[id];
var attendees = (await AttendeeCountsAsync(new[] { id })).GetValueOrDefault(id);
db.PlayerEvents.Remove(evt);
await db.PlayerEventResponses.Where(r => r.EventId == id).ExecuteDeleteAsync();
await db.PlayerSettings
.Where(s => s.Key == ExtrasKey(id) || s.Key == ClubKey(id))
.ExecuteDeleteAsync();
await db.SaveChangesAsync();
return Ok(new { success = true, error = "" });
return Ok(Wrapper(evt, extras, attendees));
}
// ── Field mutations (v2) ─────────────────────────────────────────────
// Every one of these is a PUT (verb 3, e.g. CBKANFIOBCF.txt:2744) whose
// fields arrive as x-www-form-urlencoded, and every one returns the
// PHHAKLPGNGC wrapper — a flat event body leaves the client's PlayerEvent
// null right after a successful save. POST is kept alongside for the 2020
// client.
[HttpPost("api/playerevents/v2/{eventId:long}/name")]
[HttpPut("api/playerevents/v2/{eventId:long}/name")]
public async Task<IActionResult> UpdateName(long eventId)
@@ -391,10 +922,10 @@ public class PlayerEventsController(
if (evt is null) return NotFound();
var value = await ReadStringFieldAsync("name", "Name", "value");
if (string.IsNullOrWhiteSpace(value)) return BadRequest("missing_name");
evt.Title = value.Trim()[..Math.Min(value.Trim().Length, 128)];
evt.Title = Clamp(value.Trim(), 128);
evt.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToWire(evt));
return Ok(await WrapperAsync(evt));
}
[HttpPost("api/playerevents/v2/{eventId:long}/description")]
@@ -404,12 +935,17 @@ public class PlayerEventsController(
var evt = await GetOwnedEventAsync(eventId);
if (evt is null) return NotFound();
var value = await ReadStringFieldAsync("description", "Description", "value") ?? string.Empty;
evt.Description = value.Trim()[..Math.Min(value.Trim().Length, 2000)];
evt.Description = Clamp(value.Trim(), 2000);
evt.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToWire(evt));
return Ok(await WrapperAsync(evt));
}
/// <summary>PUT <c>api/playerevents/v2/{id}/room</c> — FPPNHCMPAKK sends
/// <c>roomId</c> AND an optional <c>subRoomId</c>
/// (<c>CBKANFIOBCF_NestedType_AKPPDNLCACI.txt:88,101</c>); dropping the
/// latter meant the event never pointed at the sub-room the host picked.
/// </summary>
[HttpPost("api/playerevents/v2/{eventId:long}/room")]
[HttpPut("api/playerevents/v2/{eventId:long}/room")]
public async Task<IActionResult> UpdateRoom(long eventId)
@@ -420,9 +956,11 @@ public class PlayerEventsController(
if (roomId is not long id || id <= 0) return BadRequest("missing_room");
if (!await db.Rooms.AnyAsync(r => r.Id == id)) return NotFound("room_not_found");
evt.RoomId = id;
evt.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToWire(evt));
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
extras.SubRoomId = await ReadLongFieldAsync("subRoomId", "SubRoomId");
await SaveExtrasAsync(evt, extras);
return Ok(await WrapperAsync(evt));
}
[HttpPost("api/playerevents/v2/{eventId:long}/time")]
@@ -438,7 +976,7 @@ public class PlayerEventsController(
if (start is null && end is null) return BadRequest("missing_time");
evt.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToWire(evt));
return Ok(await WrapperAsync(evt));
}
[HttpPost("api/playerevents/v2/{eventId:long}/image")]
@@ -448,8 +986,10 @@ public class PlayerEventsController(
var evt = await GetOwnedEventAsync(eventId);
if (evt is null) return NotFound();
var value = await ReadStringFieldAsync("imageName", "ImageName", "value") ?? string.Empty;
await SetEventSettingAsync(evt, "image", value.Trim());
return Ok(ToWire(evt));
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
extras.ImageName = Clamp(value.Trim(), 128);
await SaveExtrasAsync(evt, extras);
return Ok(await WrapperAsync(evt));
}
[HttpPost("api/playerevents/v2/{eventId:long}/accessibility")]
@@ -460,44 +1000,92 @@ public class PlayerEventsController(
if (evt is null) return NotFound();
var value = await ReadIntFieldAsync("accessibility", "Accessibility", "value");
if (value is not int accessibility) return BadRequest("missing_accessibility");
await SetEventSettingAsync(evt, "accessibility", accessibility.ToString());
return Ok(ToWire(evt));
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
extras.Accessibility = accessibility;
await SaveExtrasAsync(evt, extras);
return Ok(await WrapperAsync(evt));
}
/// <summary>PUT <c>api/playerevents/v2/{id}/tags</c>. JHLMDMGELFD does NOT
/// send form fields — it serialises <c>List&lt;String&gt;</c> straight into
/// a RawJsonForm body, i.e. a BARE JSON ARRAY
/// (<c>CBKANFIOBCF_NestedType_PNFGKCKCGEL.txt:63-81</c>). The old field
/// reader only looked inside JSON objects, so every save stored an empty
/// string and silently cleared the tags. The wrapper's
/// <c>TagModifyResult.Tags</c> is what the client re-reads afterwards.
/// </summary>
[HttpPost("api/playerevents/v2/{eventId:long}/tags")]
[HttpPut("api/playerevents/v2/{eventId:long}/tags")]
public async Task<IActionResult> UpdateTags(long eventId)
{
var evt = await GetOwnedEventAsync(eventId);
if (evt is null) return NotFound();
var value = await ReadStringFieldAsync("tags", "Tags", "value") ?? string.Empty;
await SetEventSettingAsync(evt, "tags", value.Trim());
return Ok(ToWire(evt));
var tags = await ReadStringListFieldAsync("tags", "Tags", "value") ?? new List<string>();
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
extras.Tags = tags;
await SaveExtrasAsync(evt, extras);
return Ok(await WrapperAsync(evt));
}
/// <summary>PUT <c>api/playerevents/v2/{id}/multiinstance</c>. IEGDKKAKHBD
/// sends FOUR form fields — <c>isMultiInstance</c>,
/// <c>supportsMultiInstanceRoomChat</c>, <c>defaultBroadcastPermissions</c>,
/// <c>canRequestBroadcastPermissions</c>
/// (<c>CBKANFIOBCF_NestedType_EONGJBMLEFO.txt:122-161</c>). The handler used
/// to look for a field named <c>multiInstance</c>, which the client never
/// sends, so the toggle always stored false and the other three were
/// dropped. Note the request spells it "supports…" while the response key
/// is "Support…" — both spellings are accepted here.</summary>
[HttpPost("api/playerevents/v2/{eventId:long}/multiinstance")]
[HttpPut("api/playerevents/v2/{eventId:long}/multiinstance")]
public async Task<IActionResult> UpdateMultiInstance(long eventId)
{
var evt = await GetOwnedEventAsync(eventId);
if (evt is null) return NotFound();
var value = await ReadBoolFieldAsync("multiInstance", "MultiInstance", "value");
await SetEventSettingAsync(evt, "multiinstance", (value ?? false) ? "true" : "false");
return Ok(ToWire(evt));
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
extras.IsMultiInstance =
await ReadBoolFieldAsync("isMultiInstance", "IsMultiInstance", "multiInstance", "value") ?? false;
extras.SupportMultiInstanceRoomChat =
await ReadBoolFieldAsync("supportsMultiInstanceRoomChat", "supportMultiInstanceRoomChat",
"SupportMultiInstanceRoomChat") ?? false;
extras.DefaultBroadcastPermissions =
await ReadIntFieldAsync("defaultBroadcastPermissions", "DefaultBroadcastPermissions") ?? 0;
extras.CanRequestBroadcastPermissions =
await ReadIntFieldAsync("canRequestBroadcastPermissions", "CanRequestBroadcastPermissions") ?? 0;
await SaveExtrasAsync(evt, extras);
return Ok(await WrapperAsync(evt));
}
/// <summary>PUT <c>api/playerevents/v2/{id}/club</c>. JLHJHGANFDM takes a
/// <c>Nullable&lt;Int64&gt;</c> and boxes it into the single form field
/// <c>clubId</c> (<c>CBKANFIOBCF_NestedType_DJLDOCJBFPO.txt:60-71</c>) — a
/// null therefore arrives as an absent/empty field, which is the client's
/// only way to DETACH the club. Rejecting that with 400 made detaching
/// impossible.</summary>
[HttpPost("api/playerevents/v2/{eventId:long}/club")]
[HttpPut("api/playerevents/v2/{eventId:long}/club")]
public async Task<IActionResult> UpdateClub(long eventId)
{
var evt = await GetOwnedEventAsync(eventId);
if (evt is null) return NotFound();
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
var clubId = await ReadLongFieldAsync("clubId", "ClubId", "value");
if (clubId is not long id || id <= 0) return BadRequest("missing_club");
if (!await db.ClubMemberships.AnyAsync(m => m.ClubId == id && m.PlayerId == Me))
return Forbid();
await SetEventSettingAsync(evt, "club", id.ToString());
return Ok(ToWire(evt));
if (clubId is long id && id > 0)
{
if (!await db.ClubMemberships.AnyAsync(m => m.ClubId == id && m.PlayerId == Me))
return Forbid();
extras.ClubId = id;
}
else
{
extras.ClubId = null;
}
await SaveExtrasAsync(evt, extras);
return Ok(await WrapperAsync(evt));
}
[HttpDelete("api/playerevents/v1/{eventId:long}")]
@@ -512,10 +1100,13 @@ public class PlayerEventsController(
var responses = db.PlayerEventResponses.Where(r => r.EventId == eventId);
db.PlayerEventResponses.RemoveRange(responses);
await db.SaveChangesAsync();
await db.PlayerSettings
.Where(s => s.Key == ExtrasKey(eventId) || s.Key == ClubKey(eventId))
.ExecuteDeleteAsync();
return Ok();
}
// ── Bulk-invite (Phase 8) ────────────────────────────────────────────
// ── Bulk-invite ──────────────────────────────────────────────────────
public sealed class BulkInviteRequest
{
@@ -523,12 +1114,17 @@ public class PlayerEventsController(
public List<int>? InvitedPlayerIds { get; set; }
}
/// <summary>POST <c>/api/playerevents/v1/bulkInvite</c> — wire
/// type per agent ISIL extraction: <c>BulkInviteRequest{PlayerEventId,
/// InvitedPlayerIds:List&lt;int&gt;}</c>; response
/// <c>BulkInviteResponse{FailedInvites,Result(CreateModifyPlayerEventStatus)}</c>.
/// We insert one MessageEntity per recipient so the watch's inbox
/// shows the invite, and push a per-recipient notification.</summary>
/// <summary>POST <c>/api/playerevents/v1/bulkInvite</c>. Request is
/// <c>JsonUtility.ToJson(RecNet.Events.BulkInviteRequest)</c> —
/// name-preserved fields <c>PlayerEventId</c> / <c>InvitedPlayerIds</c>
/// (2023.06.21 <c>dump.cs:1288323-1288324</c>, body build at
/// <c>CBKANFIOBCF_NestedType_MIKAHKKCIEE.txt:69-96</c>). Response is
/// IDELKAJIOLI <c>{FailedInvites, Result}</c>
/// (<c>GAODOHODBDE.txt:203-238</c>) where each failed entry is
/// <c>{InvitedPlayerId, Result}</c> (<c>MOJLGDKFECO.txt:203-238</c>) — the
/// entries used to be <c>{PlayerId, Error}</c> and deserialised as all
/// defaults. Error paths return the same JSON shape rather than a
/// NotFound()/Forbid() body the client cannot parse.</summary>
[HttpPost("api/playerevents/v1/bulkInvite")]
public async Task<IActionResult> BulkInvite([FromBody] BulkInviteRequest req)
{
@@ -536,8 +1132,11 @@ public class PlayerEventsController(
return Ok(new { FailedInvites = Array.Empty<object>(), Result = 0 });
var ev = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == req.PlayerEventId);
if (ev is null) return NotFound();
if (ev.CreatorPlayerId != Me) return Forbid();
// BNCFHOOCHAI's member order isn't recoverable (enums have no method
// bodies to disassemble); any non-zero code reads as "failed" on the
// client, which is all the invite sheet branches on.
if (ev is null) return Ok(new { FailedInvites = Array.Empty<object>(), Result = 1 });
if (ev.CreatorPlayerId != Me) return Ok(new { FailedInvites = Array.Empty<object>(), Result = 1 });
var failed = new List<object>();
var sender = Me;
@@ -547,7 +1146,7 @@ public class PlayerEventsController(
var exists = await db.Players.AnyAsync(p => p.Id == rid);
if (!exists)
{
failed.Add(new { PlayerId = rid, Error = "unknown player" });
failed.Add(new { InvitedPlayerId = rid, Result = 1 });
continue;
}
db.Messages.Add(new MessageEntity
@@ -564,45 +1163,47 @@ public class PlayerEventsController(
return Ok(new { FailedInvites = failed, Result = 0 });
}
public sealed class BroadcastRequest
/// <summary>POST <c>api/playerevents/v1/broadcast</c> — set or clear the
/// room instance an event is being broadcast from. JBHPMONELAO posts
/// <c>RecNet.Events.BroadcastRoomInstanceRequest</c>
/// (<c>CBKANFIOBCF_NestedType_EHHMGKFGGCC.txt:81</c>) whose JSON keys are
/// <c>PlayerEventId</c> and the nullable <c>BroadcastRoomInstanceId</c>
/// (<c>EOFEKNIHNNC.txt:215-258</c>), and reads the PHHAKLPGNGC wrapper.
/// This used to be implemented as a text-message blast to every RSVP,
/// which both spammed attendees and returned an unparseable body.</summary>
[HttpPost("api/playerevents/v1/broadcast")]
public async Task<IActionResult> Broadcast()
{
public long PlayerEventId { get; set; }
public string? Message { get; set; }
var eventId = await ReadLongFieldAsync("PlayerEventId", "playerEventId", "EventId", "eventId");
if (eventId is not long id) return BadRequest("missing_event");
var evt = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == id);
if (evt is null) return NotFound();
if (evt.CreatorPlayerId != Me) return Forbid();
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
extras.BroadcastingRoomInstanceId = await ReadLongFieldAsync(
"BroadcastRoomInstanceId", "broadcastRoomInstanceId",
"BroadcastingRoomInstanceId", "broadcastingRoomInstanceId");
await SaveExtrasAsync(evt, extras);
// Attendees' event cards key off BroadcastingRoomInstanceId, so nudge
// them to re-fetch instead of sending a chat message.
await NotifyAttendeesAsync(evt, PushNotificationId.PlayerEventStateChanged);
return Ok(await WrapperAsync(evt));
}
[HttpPost("api/playerevents/v1/broadcast")]
public async Task<IActionResult> Broadcast([FromBody] BroadcastRequest req)
private async Task NotifyAttendeesAsync(PlayerEventEntity evt, PushNotificationId id)
{
var ev = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == req.PlayerEventId);
if (ev is null) return NotFound();
if (ev.CreatorPlayerId != Me) return Forbid();
var recipients = await db.PlayerEventResponses
.Where(r => r.EventId == ev.Id && r.Response != 2)
.Where(r => r.EventId == evt.Id && r.Response != NotGoingResponse)
.Select(r => r.PlayerId)
.Distinct()
.ToListAsync();
var body = string.IsNullOrWhiteSpace(req.Message)
? $"Update for '{ev.Title}'"
: req.Message.Trim();
foreach (var playerId in recipients.Where(id => id != Me))
{
db.Messages.Add(new MessageEntity
{
SenderPlayerId = Me,
RecipientPlayerId = playerId,
Body = body,
});
await notifications.NotifyAsync(playerId,
PushNotificationId.PlayerEventResponseChanged,
new { ev.Id, ev.Title, Message = body });
}
await db.SaveChangesAsync();
return Ok(new { Success = true, Sent = recipients.Count });
foreach (var playerId in recipients.Where(p => p != evt.CreatorPlayerId))
await notifications.NotifyAsync(playerId, id, new { PlayerEventId = evt.Id, evt.Title });
}
// ── Report (Phase 8) ─────────────────────────────────────────────────
// ── Report ───────────────────────────────────────────────────────────
public sealed class PlayerEventReportRequest
{
@@ -611,174 +1212,6 @@ public class PlayerEventsController(
public string? Details { get; set; }
}
private async Task<PlayerEventEntity?> GetOwnedEventAsync(long eventId)
=> await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == eventId && e.CreatorPlayerId == Me);
private async Task<List<long>> ReadEventIdsAsync()
{
var ids = new List<long>();
foreach (var value in Request.Query.SelectMany(q => q.Value))
AddDelimitedIds(ids, value);
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
foreach (var key in new[] { "playerEventIds", "PlayerEventIds", "eventIds", "EventIds", "ids", "Ids" })
foreach (var value in form[key])
AddDelimitedIds(ids, value);
}
else if ((Request.ContentLength ?? 0) > 0
&& Request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true)
{
try
{
using var doc = await JsonDocument.ParseAsync(Request.Body);
if (doc.RootElement.ValueKind == JsonValueKind.Object)
{
foreach (var key in new[] { "playerEventIds", "PlayerEventIds", "eventIds", "EventIds", "ids", "Ids" })
{
if (doc.RootElement.TryGetProperty(key, out var prop))
AddJsonIds(ids, prop);
}
}
else
{
AddJsonIds(ids, doc.RootElement);
}
}
catch (JsonException)
{
}
}
return ids.Distinct().Take(200).ToList();
}
private static void AddDelimitedIds(List<long> ids, string? value)
{
foreach (var part in (value ?? string.Empty)
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
if (long.TryParse(part, out var id) && id > 0) ids.Add(id);
}
}
private static void AddJsonIds(List<long> ids, JsonElement element)
{
switch (element.ValueKind)
{
case JsonValueKind.Array:
foreach (var item in element.EnumerateArray())
AddJsonIds(ids, item);
break;
case JsonValueKind.Number:
if (element.TryGetInt64(out var id) && id > 0) ids.Add(id);
break;
case JsonValueKind.String:
AddDelimitedIds(ids, element.GetString());
break;
}
}
private async Task<string?> ReadStringFieldAsync(params string[] names)
{
var fields = await ReadRequestFieldsAsync();
foreach (var name in names)
if (fields.TryGetValue(name, out var value))
return value;
return null;
}
private async Task<long?> ReadLongFieldAsync(params string[] names)
{
var value = await ReadStringFieldAsync(names);
return long.TryParse(value, out var parsed) ? parsed : null;
}
private async Task<int?> ReadIntFieldAsync(params string[] names)
{
var value = await ReadStringFieldAsync(names);
return int.TryParse(value, out var parsed) ? parsed : null;
}
private async Task<bool?> ReadBoolFieldAsync(params string[] names)
{
var value = await ReadStringFieldAsync(names);
return bool.TryParse(value, out var parsed) ? parsed : null;
}
private async Task<DateTime?> ReadDateFieldAsync(params string[] names)
{
var value = await ReadStringFieldAsync(names);
return DateTime.TryParse(value, out var parsed) ? parsed : null;
}
private async Task<Dictionary<string, string>> ReadRequestFieldsAsync()
{
const string itemKey = "__playerevent_fields";
if (HttpContext.Items.TryGetValue(itemKey, out var cached)
&& cached is Dictionary<string, string> existing)
{
return existing;
}
var fields = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var pair in Request.Query)
fields[pair.Key] = pair.Value.FirstOrDefault() ?? string.Empty;
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
foreach (var pair in form)
fields[pair.Key] = pair.Value.FirstOrDefault() ?? string.Empty;
}
else if ((Request.ContentLength ?? 0) > 0
&& Request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true)
{
try
{
using var doc = await JsonDocument.ParseAsync(Request.Body);
if (doc.RootElement.ValueKind == JsonValueKind.Object)
{
foreach (var prop in doc.RootElement.EnumerateObject())
fields[prop.Name] = prop.Value.ValueKind == JsonValueKind.String
? prop.Value.GetString() ?? string.Empty
: prop.Value.GetRawText();
}
}
catch (JsonException)
{
}
}
HttpContext.Items[itemKey] = fields;
return fields;
}
private async Task SetEventSettingAsync(PlayerEventEntity evt, string key, string value)
{
var settingKey = $"playerevent:{evt.Id}:{key}";
var row = await db.PlayerSettings
.FirstOrDefaultAsync(s => s.PlayerId == evt.CreatorPlayerId && s.Key == settingKey);
if (row is null)
{
db.PlayerSettings.Add(new PlayerSettingEntity
{
PlayerId = evt.CreatorPlayerId,
Key = settingKey,
Value = value,
});
}
else
{
row.Value = value;
}
evt.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
}
/// <summary>POST <c>api/playerevents/v1/report</c> — file a
/// moderation report against an event. Wire response is
/// <c>KLAMKCBENEA{Success, Message}</c>; returning a bare
@@ -800,4 +1233,213 @@ public class PlayerEventsController(
await db.SaveChangesAsync();
return Ok(new { Success = true, Message = string.Empty });
}
// ── Request parsing ──────────────────────────────────────────────────
private static string Clamp(string value, int max)
=> value.Length <= max ? value : value[..max];
private async Task<PlayerEventEntity?> GetOwnedEventAsync(long eventId)
=> await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == eventId && e.CreatorPlayerId == Me);
private async Task<List<long>> ReadEventIdsAsync()
{
var ids = new List<long>();
foreach (var value in Request.Query.SelectMany(q => q.Value))
AddDelimitedIds(ids, value);
var body = await ReadBodyAsync();
foreach (var key in new[] { "playerEventIds", "eventIds", "ids" })
{
if (body.Repeated.TryGetValue(key, out var values))
foreach (var value in values) AddDelimitedIds(ids, value);
}
if (body.RootArray is not null)
foreach (var value in body.RootArray) AddDelimitedIds(ids, value);
return ids.Distinct().Take(200).ToList();
}
private static void AddDelimitedIds(List<long> ids, string? value)
{
foreach (var part in (value ?? string.Empty)
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
if (long.TryParse(part, out var id) && id > 0) ids.Add(id);
}
}
private async Task<string?> ReadStringFieldAsync(params string[] names)
{
var body = await ReadBodyAsync();
foreach (var name in names)
if (body.Fields.TryGetValue(name, out var value))
return value;
return null;
}
/// <summary>Reads a list-valued field. Handles all three encodings the
/// client uses: a bare JSON array body (the tags PUT), a JSON array nested
/// under a key (the v2 create/edit body's <c>Tags</c>), and repeated /
/// comma-joined form values.</summary>
private async Task<List<string>?> ReadStringListFieldAsync(params string[] names)
{
var body = await ReadBodyAsync();
if (body.RootArray is not null) return body.RootArray;
foreach (var name in names)
{
if (body.Repeated.TryGetValue(name, out var repeated) && repeated.Count > 1)
return repeated.Select(v => v.Trim()).Where(v => v.Length > 0).ToList();
if (!body.Fields.TryGetValue(name, out var raw)) continue;
raw = raw.Trim();
if (raw.Length == 0) return new List<string>();
if (raw.StartsWith('['))
{
var parsed = ParseJsonStringArray(raw);
if (parsed is not null) return parsed;
}
return raw
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.ToList();
}
return null;
}
private static List<string>? ParseJsonStringArray(string raw)
{
try
{
using var doc = JsonDocument.Parse(raw);
if (doc.RootElement.ValueKind != JsonValueKind.Array) return null;
return ElementToStrings(doc.RootElement);
}
catch (JsonException)
{
return null;
}
}
private static List<string> ElementToStrings(JsonElement array)
{
var list = new List<string>();
foreach (var item in array.EnumerateArray())
{
var value = item.ValueKind == JsonValueKind.String ? item.GetString() : item.GetRawText();
if (!string.IsNullOrWhiteSpace(value)) list.Add(value.Trim());
}
return list;
}
private async Task<long?> ReadLongFieldAsync(params string[] names)
{
var value = await ReadStringFieldAsync(names);
return long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)
? parsed
: null;
}
private async Task<int?> ReadIntFieldAsync(params string[] names)
{
var value = await ReadStringFieldAsync(names);
return int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)
? parsed
: null;
}
private async Task<bool?> ReadBoolFieldAsync(params string[] names)
{
var value = await ReadStringFieldAsync(names);
if (bool.TryParse(value, out var parsed)) return parsed;
// Form-encoded booleans arrive as "True"/"False" from the client's
// boxed-bool ToString, but a JSON body may carry 1/0.
return value switch { "1" => true, "0" => false, _ => null };
}
private async Task<DateTime?> ReadDateFieldAsync(params string[] names)
{
var value = await ReadStringFieldAsync(names);
// The client always sends UTC (Utf8Json ISO-8601 with a Z, or
// DateTime.ToString() on an already-UTC value). Force the parse to land
// on Kind=Utc so it compares correctly against DateTime.UtcNow.
return DateTime.TryParse(value, CultureInfo.InvariantCulture,
DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out var parsed)
? parsed
: null;
}
/// <summary>Everything the request carries, read once and cached — the body
/// stream can only be consumed a single time.</summary>
private sealed class ParsedBody
{
public Dictionary<string, string> Fields { get; } = new(StringComparer.OrdinalIgnoreCase);
public Dictionary<string, List<string>> Repeated { get; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>Set when the whole body is a bare JSON array — the shape
/// the tags PUT sends.</summary>
public List<string>? RootArray { get; set; }
}
private const string BodyItemKey = "__playerevent_body";
private async Task<ParsedBody> ReadBodyAsync()
{
if (HttpContext.Items.TryGetValue(BodyItemKey, out var cached) && cached is ParsedBody existing)
return existing;
var body = new ParsedBody();
foreach (var pair in Request.Query)
{
body.Fields[pair.Key] = pair.Value.FirstOrDefault() ?? string.Empty;
body.Repeated[pair.Key] = pair.Value.Where(v => v is not null).Select(v => v!).ToList();
}
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
foreach (var pair in form)
{
body.Fields[pair.Key] = pair.Value.FirstOrDefault() ?? string.Empty;
body.Repeated[pair.Key] = pair.Value.Where(v => v is not null).Select(v => v!).ToList();
}
}
else if ((Request.ContentLength ?? 0) > 0)
{
// Read the raw text rather than gating on Content-Type: the client's
// RawJsonForm does set application/json, but the JSON path is also
// the fallback for anything else that isn't a form.
using var reader = new StreamReader(
Request.Body, System.Text.Encoding.UTF8,
detectEncodingFromByteOrderMarks: true, leaveOpen: true);
var raw = await reader.ReadToEndAsync();
try
{
using var doc = JsonDocument.Parse(raw);
switch (doc.RootElement.ValueKind)
{
case JsonValueKind.Object:
foreach (var prop in doc.RootElement.EnumerateObject())
{
body.Fields[prop.Name] = prop.Value.ValueKind == JsonValueKind.String
? prop.Value.GetString() ?? string.Empty
: prop.Value.GetRawText();
if (prop.Value.ValueKind == JsonValueKind.Array)
body.Repeated[prop.Name] = ElementToStrings(prop.Value);
}
break;
case JsonValueKind.Array:
body.RootArray = ElementToStrings(doc.RootElement);
break;
}
}
catch (JsonException)
{
// Empty or non-JSON body → query/form values only.
}
}
HttpContext.Items[BodyItemKey] = body;
return body;
}
}
@@ -18,10 +18,16 @@ namespace DorkNet.Server.Controllers.API.RoomConsumables;
/// LPFAFIGAIOE / HJOKFJKKEJM / IDBFKCFEAHM / PECGEJAAMHB / DPNLANKOHON):
///
/// desc = { RoomConsumableId, RoomId, Name, Description, ImageName,
/// PriceAndCurrency: { Price, CurrencyId } }
/// Price, PurchaseCurrencyId, ModifiedAt }
/// inventory = { RoomConsumableId, AccountId, Count, ConcurrencyCode,
/// ModifiedAt, Consumable: desc }
///
/// The desc price is FLAT on the way OUT even though it is NESTED on the way
/// IN. The response formatter FCIBLPCOODP's key map is exactly the eight keys
/// above — Price at index 5, PurchaseCurrencyId at 6, ModifiedAt at 7, and no
/// PriceAndCurrency key at all (FCIBLPCOODP.txt:670/686/710). See ToDescWire.
/// The inventory key map is likewise exact (CICEHLNDLDE.txt:447-562).
///
/// <c>Consumable</c> must never be null on an inventory row — the
/// client's RoomConsumablesManager dereferences it unconditionally while
/// processing the room-join inventory fetch and NREs otherwise (that NRE
@@ -311,7 +311,11 @@ public class RoomCurrenciesController(DorkNetDbContext db, LevelService level) :
if (offerIds.Count > 0) query = query.Where(x => offerIds.Contains(x.o.PublicId));
if (currencyIds.Count > 0) query = query.Where(x => currencyIds.Contains(x.c.PublicId));
var rows = await query.Take(200).ToListAsync();
// The client reads an "Order" key (AALDCAANEMM.txt:550) but there is no
// column to persist it into yet, so ToOfferWire reports 0 for every pack
// and the client keeps the order it received them in — order by insertion
// id so that fallback is at least stable across fetches.
var rows = await query.OrderBy(x => x.o.Id).Take(200).ToListAsync();
// The response is GROUPED: List<{CurrencyId, PurchaseOffers}> (formatter
// LKBGCOELLIN.txt:215-258 reads only those two keys). A flat offer list
@@ -453,13 +457,13 @@ public class RoomCurrenciesController(DorkNetDbContext db, LevelService level) :
{
var fields = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var pair in Request.Query)
fields[pair.Key] = pair.Value.FirstOrDefault() ?? string.Empty;
fields[pair.Key] = Flatten(pair.Value);
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
foreach (var pair in form)
fields[pair.Key] = pair.Value.FirstOrDefault() ?? string.Empty;
fields[pair.Key] = Flatten(pair.Value);
return fields;
}
@@ -489,6 +493,21 @@ public class RoomCurrenciesController(DorkNetDbContext db, LevelService level) :
return fields;
}
/// <summary>Collapses a multi-valued query/form key into the comma-joined form
/// <see cref="ReadGuidList"/>/<see cref="ReadLongList"/> already split on.
///
/// getPurchaseOffersBatch pushes its currency-id list through the generic batch
/// param helper (<c>0x181F2DCD0</c> with <c>"ids"</c>,
/// MIHKAJFMIPE_NestedType_OBLJADOFIED.txt:228-230), which is the same helper
/// every other RecNet batch route uses to REPEAT the key once per id
/// (<c>?ids=a&amp;ids=b</c>). Taking only <c>FirstOrDefault()</c> silently
/// dropped every id after the first, so a multi-currency room fetched packs for
/// exactly one of its currencies.</summary>
private static string Flatten(Microsoft.Extensions.Primitives.StringValues values) =>
values.Count <= 1
? values.FirstOrDefault() ?? string.Empty
: string.Join(",", values.Where(v => !string.IsNullOrWhiteSpace(v)));
private async Task<string?> ReadJsonBodyAsync()
{
if ((Request.ContentLength ?? 0) <= 0) return null;
@@ -2409,8 +2409,31 @@ public class RoomsController(
[HttpGet("api/rooms/v2/myrecent")]
public IActionResult MyOtherTabs() => Ok(Array.Empty<object>());
/// <summary>Per-(player, room) persistence blob, base64. Lives in the
/// generic <see cref="PlayerSettingEntity"/> key/value table — the same
/// "no schema change needed" trick MatchPlayerController uses for its
/// matchmaking prefs (MatchPlayerController.cs:213-217). The Value column
/// is declared <c>MaxLength(1024)</c> but SQLite stores it as unconstrained
/// TEXT and EF Core does not run DataAnnotations validation on SaveChanges,
/// so oversized blobs round-trip intact.</summary>
private static string PlayerRoomDataKey(long roomId) => $"roomplayerdata:{roomId}";
/// <summary>Fallback blob handed to a player who has never saved anything
/// in this room. <c>CAE=</c> is a one-field protobuf the client parses as
/// "empty, version 1"; a truly empty string makes the 2023 reader throw.</summary>
private const string DefaultPlayerRoomData = "CAE=";
/// <summary>GET <c>rooms/{roomId}/playerdata/me</c> — the per-room player
/// blob (CircuitsV2 "player persistence"). Client contract is
/// <c>Task&lt;ILKMFMCOPPO&gt;</c> whose reader requires the key
/// <c>Data</c> (OPNPNPMKLGI.txt ISIL 034/060); the request is issued from
/// NLDBPDCNNCF (NLDBPDCNNCF_NestedType_IGENGEIMGMJ.txt:169), so it arrives
/// with the <c>roomserver/</c> prefix — the bare-only registration 404'd.
/// This used to return a hardcoded <c>CAE=</c> for everyone; it now returns
/// whatever <see cref="SavePlayerDataForMe"/> last stored.</summary>
[HttpGet("player_room_data/{roomId:long}")]
[HttpGet("rooms/{roomId:long}/playerdata/me")]
[HttpGet("roomserver/rooms/{roomId:long}/playerdata/me")]
[Authorize]
public async Task<IActionResult> PlayerDataForMe(long roomId)
{
@@ -2431,9 +2454,15 @@ public class RoomsController(
.Select(v => new { v.VisitCount, v.FirstVisitAt, v.LastVisitAt })
.FirstOrDefaultAsync();
var key = PlayerRoomDataKey(roomId);
var stored = await db.PlayerSettings.AsNoTracking()
.Where(s => s.PlayerId == pid.Value && s.Key == key)
.Select(s => s.Value)
.FirstOrDefaultAsync();
return Ok(new
{
Data = "CAE=",
Data = string.IsNullOrEmpty(stored) ? DefaultPlayerRoomData : stored,
RoomId = room?.Id ?? roomId,
PlayerId = pid.Value,
Favorite = favorited,
@@ -2453,6 +2482,71 @@ public class RoomsController(
});
}
public sealed class PlayerRoomDataRequest { public string? Data { get; set; } }
/// <summary>PUT <c>rooms/{roomId}/playerdata/me</c> — persist the caller's
/// per-room blob. Client method <c>NLDBPDCNNCF.LIHFBBCDAGO(long, string)</c>
/// ("save player room data") moves verb ordinal 3 = PUT into r8 and posts a
/// single form field <c>data</c>
/// (NLDBPDCNNCF.txt ISIL 045/061/065 around :2566-2586; form key at
/// NLDBPDCNNCF_NestedType_ANPKFGHOOAJ.txt ISIL 015). Its return type is the
/// non-generic <c>Task</c>, so no response body is parsed.
///
/// <para>Only the GET was registered before, so every save 405'd and per-room
/// player state never persisted.</para></summary>
[HttpPut("rooms/{roomId:long}/playerdata/me")]
[HttpPost("rooms/{roomId:long}/playerdata/me")]
[HttpPut("roomserver/rooms/{roomId:long}/playerdata/me")]
[HttpPost("roomserver/rooms/{roomId:long}/playerdata/me")]
[HttpPut("player_room_data/{roomId:long}")]
[Authorize]
public async Task<IActionResult> SavePlayerDataForMe(
long roomId,
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] PlayerRoomDataRequest req)
{
var pid = CurrentPlayerId;
if (pid is null) return Unauthorized();
var data = req.Data ?? string.Empty;
var key = PlayerRoomDataKey(roomId);
var row = await db.PlayerSettings
.FirstOrDefaultAsync(s => s.PlayerId == pid.Value && s.Key == key);
if (row is null)
{
db.PlayerSettings.Add(new PlayerSettingEntity
{
PlayerId = pid.Value, Key = key, Value = data,
});
}
else
{
row.Value = data;
}
await db.SaveChangesAsync();
// The client ignores the body (non-generic Task); the echo is for
// curl/admin debugging only.
return Ok(new { Result = 0, RoomId = roomId, PlayerId = pid.Value, Data = data });
}
/// <summary>GET <c>rooms/requiring/{restriction}</c> — the restricted-rooms
/// list. The path segment is <c>mode.ToString().ToLower()</c>
/// (Matchmaking.txt ISIL 067-072 around :21263, concat onto the literal
/// <c>"rooms/requiring/"</c> at :21264).
///
/// <para><b>Element type is String, not Int64 — verified, do not "fix" this
/// to room ids.</b> The issuing method
/// <c>Matchmaking.POIMPHMONON(IOCOJFJEDCM)</c> (Matchmaking.txt:21030)
/// resolves its promise as <c>FGLDKEJLAKB&lt;List&lt;String&gt;&gt;</c>
/// (typeof load at Matchmaking.txt:21327, continuation delegate
/// <c>Action&lt;List&lt;String&gt;&gt;</c> at :21315) and hands the payload to
/// <c>&lt;RequestRoomRestrictions&gt;b__2(List&lt;String&gt; rooms)</c>
/// (Matchmaking_NestedType_PMNEOAMKLJL.txt:14), which forwards it to
/// <c>Matchmaking.IINEGHJJBAN(IEnumerable&lt;String&gt;, IOCOJFJEDCM)</c>
/// (:20758) where the strings become the KEYS of the restriction map
/// (<c>List&lt;IOCOJFJEDCM&gt;</c> per key, ISIL 069-079 at :20963-20974).
/// So a bare array of room NAMES is the correct wire shape. Failures log
/// "Failed to get restricted rooms list: " (Matchmaking.txt:21306).</para></summary>
[HttpGet("rooms/requiring/{restriction}")]
[HttpGet("roomserver/rooms/requiring/{restriction}")]
[Authorize]
@@ -2708,19 +2802,31 @@ public class RoomsController(
/// <para>We accept both POST and PUT because the watch's ISIL
/// doesn't expose the explicit HTTPMethod literal at this call
/// site — covering both is cheap and means we can't get bitten
/// by the wrong one.</para></summary>
/// by the wrong one.</para>
///
/// <para><b>2023 client:</b> <c>NLDBPDCNNCF.LHNDKOJNKBE</c>
/// ("change room name", NLDBPDCNNCF.txt:5069) moves verb ordinal 3 = PUT,
/// sends the lowercase form key <c>name</c>
/// (NLDBPDCNNCF_NestedType_LMADOAEHOPP.txt ISIL 015) and returns
/// <c>Task&lt;FGCPNAACHIK&gt;</c> — the FULL room-details object, not a
/// status wrapper. It also carries the <c>roomserver/</c> prefix like every
/// other room mutation from that client
/// (docs/recroom-2023-room-save.md:83-93), so the bare-only routes 404'd.
/// The old signature mixed <c>[FromForm]</c> with a <c>[FromBody]</c>
/// parameter, which under <c>[ApiController]</c> makes the form PUT 415
/// before the handler runs — hence the shared FormOrJsonModelBinder.</para></summary>
public sealed class RenameBody { public string? Name { get; set; } }
[HttpPost("rooms/{roomId:long}/name")]
[HttpPut("rooms/{roomId:long}/name")]
[HttpPost("roomserver/rooms/{roomId:long}/name")]
[HttpPut("roomserver/rooms/{roomId:long}/name")]
[Authorize]
public async Task<IActionResult> RenameRoom(
long roomId,
[FromForm(Name = "Name")] string? nameForm,
[FromForm(Name = "name")] string? nameFormLower,
[FromBody] RenameBody? body)
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] RenameBody body)
{
var newName = nameForm ?? nameFormLower ?? body?.Name;
var newName = body?.Name;
if (string.IsNullOrWhiteSpace(newName))
return BadRequest(new { Result = 1, error = "empty_name" });
newName = newName.Trim();
@@ -2748,35 +2854,24 @@ public class RoomsController(
await db.SaveChangesAsync();
logger.LogInformation("[room-rename] room={Room} by={By} → '{Name}'", roomId, me, newName);
// Return the full updated Room DTO. The watch's
// <c>PPENFJMFPNE</c> deserialiser inherits from
// <c>KLCOGEIGEBJ</c> (the standard Room shape) so we can
// forward to the same v3-details payload builder by re-reading.
// BuildRoomDetailsV3Async is too heavy here — just return the
// minimal mutated subset; the watch UI re-fetches details
// shortly after for the room-info screen.
return Ok(new
{
Result = 0,
RoomId = room.Id,
Name = room.Name,
Description = room.Description,
ImageName = room.ImageName,
CreatorPlayerId = room.CreatorPlayerId,
CreatorAccountId = room.CreatorPlayerId,
State = room.State,
Accessibility = room.Accessibility,
IsAGRoom = room.IsAGRoom,
IsRRO = false,
IsDormRoom = room.IsDormRoom,
});
// Full room details, not the old minimal {Result,RoomId,Name,…} object:
// the 2023 issuing method returns Task<FGCPNAACHIK> (NLDBPDCNNCF.txt:5069)
// and that reader needs Room/Scenes/CoOwners/Stats — the trimmed payload
// made a successful rename surface as a parse failure in the client.
return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId));
}
/// <summary>POST <c>rooms/{roomId}/subrooms/{subRoomId}/name</c> —
/// rename a scene/sub-room within a multi-scene room. Watch path:
/// <c>EJDCNGBEICB.PAENPJCDLNJ(long roomId, long subRoomId, string newName)</c>.
/// Updates the matching <see cref="RoomSceneEntity.Name"/> after
/// verifying ownership and intra-room uniqueness.</summary>
/// verifying ownership and intra-room uniqueness.
///
/// <para><b>2023 client:</b> <c>NLDBPDCNNCF.EJOHFBMFEGB</c>
/// ("change subroom name", NLDBPDCNNCF.txt:8694) is verb 3 = PUT with form
/// key <c>name</c> and returns <c>Task&lt;FGCPNAACHIK&gt;</c>. Same
/// [FromForm]+[FromBody] 415 hazard as the room rename above, so it binds
/// through the shared FormOrJsonModelBinder and echoes full details.</para></summary>
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/name")]
[HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/name")]
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/name")]
@@ -2785,11 +2880,9 @@ public class RoomsController(
public async Task<IActionResult> RenameSubroom(
long roomId,
long subRoomId,
[FromForm(Name = "Name")] string? nameForm,
[FromForm(Name = "name")] string? nameFormLower,
[FromBody] RenameBody? body)
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] RenameBody body)
{
var newName = nameForm ?? nameFormLower ?? body?.Name;
var newName = body?.Name;
if (string.IsNullOrWhiteSpace(newName))
return BadRequest(new { Result = 1, error = "empty_name" });
newName = newName.Trim();
@@ -2822,19 +2915,7 @@ public class RoomsController(
await db.SaveChangesAsync();
logger.LogInformation("[subroom-rename] room={Room} sub={Sub} by={By} → '{Name}'", roomId, subRoomId, me, newName);
return Ok(new
{
Result = 0,
RoomId = roomId,
RoomSceneId = scene.OrderIndex,
SubRoomId = scene.OrderIndex,
Name = scene.Name,
scene.DataBlobName,
RoomSceneLocationId = scene.RoomSceneLocationId,
scene.IsSandbox,
scene.MaxPlayers,
scene.CanMatchmakeInto,
});
return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId));
}
/// <summary>POST <c>api/rooms/v1/cheer</c> — cheer a room (body
@@ -2881,9 +2962,21 @@ public class RoomsController(
}
// ── Bare-path interactionby/me toggles ─────────────────────────────
//
// Verbs come straight from the 2023 client's four one-liner methods on
// NLDBPDCNNCF (each a non-generic Task, so no response body is parsed):
// "cheer room" :13467 r8 = 3 (PUT)
// "uncheer room" :13550 r8 = 4 (DELETE)
// "favorite room" :13633 r8 = 3 (PUT)
// "unfavorite room" :13716 r8 = 4 (DELETE)
// All four are issued from NLDBPDCNNCF, so they arrive with the
// roomserver/ prefix (docs/recroom-2023-room-save.md:83-93) — without the
// twins the cheer/favorite buttons 404'd and failed silently.
[HttpPost("rooms/{roomId:long}/interactionby/me/cheer")]
[HttpPut("rooms/{roomId:long}/interactionby/me/cheer")]
[HttpPost("roomserver/rooms/{roomId:long}/interactionby/me/cheer")]
[HttpPut("roomserver/rooms/{roomId:long}/interactionby/me/cheer")]
[Authorize]
public async Task<IActionResult> BareCheer(long roomId)
{
@@ -2893,6 +2986,7 @@ public class RoomsController(
}
[HttpDelete("rooms/{roomId:long}/interactionby/me/cheer")]
[HttpDelete("roomserver/rooms/{roomId:long}/interactionby/me/cheer")]
[Authorize]
public async Task<IActionResult> BareUncheer(long roomId)
{
@@ -2903,6 +2997,8 @@ public class RoomsController(
[HttpPost("rooms/{roomId:long}/interactionby/me/favorite")]
[HttpPut("rooms/{roomId:long}/interactionby/me/favorite")]
[HttpPost("roomserver/rooms/{roomId:long}/interactionby/me/favorite")]
[HttpPut("roomserver/rooms/{roomId:long}/interactionby/me/favorite")]
[Authorize]
public async Task<IActionResult> BareFavorite(long roomId)
{
@@ -2912,6 +3008,7 @@ public class RoomsController(
}
[HttpDelete("rooms/{roomId:long}/interactionby/me/favorite")]
[HttpDelete("roomserver/rooms/{roomId:long}/interactionby/me/favorite")]
[Authorize]
public async Task<IActionResult> BareUnfavorite(long roomId)
{
@@ -3418,10 +3515,20 @@ public class RoomsController(
}
/// <summary>POST <c>rooms/{id}/subrooms</c> — append a new scene.
/// Owner-gated. OrderIndex = max+1, Name unique within room.</summary>
/// Owner-gated. OrderIndex = max+1, Name unique within room.
///
/// <para><b>2023 client:</b> <c>NLDBPDCNNCF.CMHBCOIBDME</c> ("add new
/// subroom", NLDBPDCNNCF.txt:9396) is verb 2 = POST with a single form key
/// <c>name</c> (NLDBPDCNNCF_NestedType_AIPHLHBILDN.txt ISIL 015) and returns
/// <c>Task&lt;FGCPNAACHIK&gt;</c>. It needs the roomserver/ twin, a binder
/// that accepts the form body (<c>[FromBody]</c> 415s it), and full room
/// details in the response instead of the scene row.</para></summary>
[HttpPost("rooms/{roomId:long}/subrooms")]
[HttpPost("roomserver/rooms/{roomId:long}/subrooms")]
[Authorize]
public async Task<IActionResult> AddSubRoom(long roomId, [FromBody] CreateSubRoomRequest req)
public async Task<IActionResult> AddSubRoom(
long roomId,
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] CreateSubRoomRequest req)
{
var pid = this.RequireCurrentPlayerId();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
@@ -3448,10 +3555,18 @@ public class RoomsController(
};
db.RoomScenes.Add(scene);
await db.SaveChangesAsync();
return Ok(SceneWire(scene));
return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId));
}
/// <summary>DELETE <c>rooms/{id}/subrooms/{sub}</c>. The 2023 caller
/// <c>NLDBPDCNNCF.HGNGMIMJFJL</c> ("delete subroom", NLDBPDCNNCF.txt:9827)
/// moves verb ordinal 4 = DELETE and returns
/// <c>Task&lt;FGCPNAACHIK&gt;</c>; the only registration here used to be the
/// bare one (the roomserver/ twin at the top of the file is the GET), so
/// deleting a subroom 404'd, and the <c>{Result:0}</c> body was not the
/// details object the client re-parses.</summary>
[HttpDelete("rooms/{roomId:long}/subrooms/{subRoomId:long}")]
[HttpDelete("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}")]
[Authorize]
public async Task<IActionResult> DeleteSubRoom(long roomId, long subRoomId)
{
@@ -3465,20 +3580,69 @@ public class RoomsController(
if (scene.OrderIndex == 0) return BadRequest(new { error = "cannot_delete_entry_scene" });
db.RoomScenes.Remove(scene);
await db.SaveChangesAsync();
return Ok(new { Result = 0 });
return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId));
}
public sealed class SubRoomIntRequest { public int? Value { get; set; } }
public sealed class SubRoomBoolRequest { public bool? Value { get; set; } }
// Subroom mutation DTOs. Property names mirror the LOWERCASE form keys the
// 2023 client actually sends (FormOrJsonModelBinder matches case-insensitively):
// maxPlayers NLDBPDCNNCF_NestedType_BLNOGFGHIIF.txt ISIL 020
// accessibility NLDBPDCNNCF_NestedType_GNCLJBNJEKP.txt ISIL 020
// name/accessibility/maxPlayers ..._JEEACJHABAF.txt ISIL 021/035/048
// newRoomId NLDBPDCNNCF_NestedType_COIOGADEBKJ.txt ISIL 020
// The older {Value:…} DTOs matched no client key at all, and being bound
// [FromBody] they 415'd the form body before the handler ran.
public sealed class SubRoomMaxPlayersRequest { public int? MaxPlayers { get; set; } }
// Accessibility is typed string, not int, on purpose — the two client call
// sites disagree about how they stringify it. The standalone
// "change subroom accessibility" closure boxes the value as
// typeof(JHAOFLCJNAL) (GNCLJBNJEKP.txt ISIL 015), and the form writer's
// object overload falls through to a virtual ToString() for anything that
// isn't String/DateTime (BNDIAONDFFF.txt ISIL 107-110) — i.e. an enum
// MEMBER NAME goes on the wire. The bulk "modify" closure boxes the very
// same value as typeof(System.Int32) (JEEACJHABAF.txt ISIL 031) and sends a
// number. ParseSubRoomAccessibility below accepts both spellings.
public sealed class SubRoomAccessibilityRequest { public string? Accessibility { get; set; } }
public sealed class SubRoomModifyRequest
{
public string? Name { get; set; }
public string? Accessibility { get; set; }
public int? MaxPlayers { get; set; }
public bool? IsSandbox { get; set; }
public bool? CanMatchmakeInto { get; set; }
public string? RoomSceneLocationId { get; set; }
}
public sealed class SubRoomMoveRequest { public int? NewIndex { get; set; } }
public sealed class SubRoomMoveRequest
{
/// <summary>Target ROOM for the scene — the 2023 "move subroom" flow
/// re-parents a scene into another room. Nullable&lt;Int64&gt; on the
/// wire (NLDBPDCNNCF.MGGALMIAMBG, NLDBPDCNNCF.txt:9659).</summary>
public long? NewRoomId { get; set; }
/// <summary>Legacy in-room reorder used by the admin/import tooling.
/// Not something the 2023 client sends.</summary>
public int? NewIndex { get; set; }
}
/// <summary>One row of the per-subroom role-permission matrix the 2023
/// client PUTs to <c>subrooms/{sub}/permissions</c>. Keys verified in the
/// writer <c>HDOJMEBCAGP.txt</c> ISIL 050/069/085/101/117.</summary>
public sealed class SubRoomPermissionEntry
{
// Field order + CLR types come from the BNLPBMJJOMM constructor
// (BNLPBMJJOMM.txt:126): (String Permission, OMMBGJMJJPN Role,
// Boolean Override, FEOJFGECKGH Type, String Value). The two enum
// members serialize as numbers (the role writer boxes them
// System.Int32 — BFKNABDHMGG.txt ISIL 015), hence int? here.
public string? Permission { get; set; }
public int? Role { get; set; }
public bool? Override { get; set; }
public int? Type { get; set; }
public string? Value { get; set; }
}
public sealed class PublishSubRoomSaveRequest
{
public long? SubRoomDataSaveId { get; set; }
@@ -3501,29 +3665,94 @@ public class RoomsController(
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/maxplayers")]
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/maxplayers")]
[Authorize]
public Task<IActionResult> SubRoomMaxPlayers(long roomId, long subRoomId, [FromBody] SubRoomIntRequest req) =>
MutateScene(roomId, subRoomId, s => { if (req.Value is int v) s.MaxPlayers = Math.Max(1, v); });
public Task<IActionResult> SubRoomMaxPlayers(
long roomId,
long subRoomId,
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] SubRoomMaxPlayersRequest req) =>
MutateScene(roomId, subRoomId, s => { if (req.MaxPlayers is int v) s.MaxPlayers = Math.Max(1, v); });
/// <summary>PUT <c>rooms/{id}/subrooms/{sub}/accessibility</c>. Client:
/// <c>NLDBPDCNNCF.OAINKEAFPGE(long, long, JHAOFLCJNAL)</c>
/// (NLDBPDCNNCF.txt:9032, verb 3), form key <c>accessibility</c>, response
/// <c>Task&lt;FGCPNAACHIK&gt;</c>. We only model the matchmaking bit
/// (<see cref="RoomSceneEntity.CanMatchmakeInto"/>), so Private collapses to
/// "not a matchmaking target" and everything else to "is one".</summary>
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/accessibility")]
[HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/accessibility")]
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/accessibility")]
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/accessibility")]
[Authorize]
public Task<IActionResult> SubRoomAccessibility(long roomId, long subRoomId, [FromBody] SubRoomBoolRequest req) =>
MutateScene(roomId, subRoomId, s => { if (req.Value is bool v) s.CanMatchmakeInto = v; });
public Task<IActionResult> SubRoomAccessibility(
long roomId,
long subRoomId,
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] SubRoomAccessibilityRequest req) =>
MutateScene(roomId, subRoomId, s =>
{
if (ParseSubRoomAccessibility(req.Accessibility) is int a)
s.CanMatchmakeInto = a != AccessibilityPrivate;
});
/// <summary>PUT <c>rooms/{id}/subrooms/{sub}/permissions</c> — the
/// role-permission matrix. Client:
/// <c>NLDBPDCNNCF.AAMEOMHKPHF(long, long, IReadOnlyList&lt;BNLPBMJJOMM&gt;)</c>
/// (NLDBPDCNNCF.txt:9198, verb 3). Unlike its siblings this one does NOT
/// form-encode: the closure calls <c>BNDIAONDFFF.FJLLPHFOOJJ(String)</c>
/// (CCNOMNNHNNG.txt ISIL 009), which stamps body-kind 4 = raw string
/// (BNDIAONDFFF.txt:275, <c>mov dword ptr [rbx+38h],4</c>) — i.e. a raw
/// JSON ARRAY. FormOrJsonModelBinder handles that (non-form → JSON
/// deserialize into the List).
///
/// <para><b>Scope note — deliberately not persisted.</b> DorkNet has no
/// per-subroom permission storage: <see cref="RoomSceneEntity"/> has no
/// column for it and there is no permissions table. Adding one means an
/// entity + DbContext + migration, which is outside this file. Until then
/// the matrix is accepted, logged, and the room details echoed back so the
/// client's promise resolves instead of 415-ing; the values do not survive
/// a round-trip. The old handler silently bound an unrelated DTO whose keys
/// the client never sends, which was strictly worse.</para></summary>
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/permissions")]
[HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/permissions")]
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/permissions")]
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/permissions")]
[Authorize]
public Task<IActionResult> SubRoomPermissions(long roomId, long subRoomId, [FromBody] SubRoomModifyRequest? req) =>
MutateScene(roomId, subRoomId, s =>
{
if (req?.CanMatchmakeInto is bool canMatchmakeInto) s.CanMatchmakeInto = canMatchmakeInto;
if (req?.MaxPlayers is int maxPlayers) s.MaxPlayers = Math.Max(1, maxPlayers);
if (req?.IsSandbox is bool isSandbox) s.IsSandbox = isSandbox;
});
public async Task<IActionResult> SubRoomPermissions(
long roomId,
long subRoomId,
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] List<SubRoomPermissionEntry> req)
{
var pid = this.RequireCurrentPlayerId();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound();
if (room.CreatorPlayerId != pid) return Forbid();
var scene = await GetOrCreateSceneForMutationAsync(room, subRoomId);
if (scene is null) return NotFound();
await db.SaveChangesAsync();
logger.LogWarning(
"[subroom-permissions] room={Room} sub={Sub} by={By} entries={Count} — accepted but NOT persisted (no permission storage yet)",
roomId, subRoomId, pid, req?.Count ?? 0);
return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId));
}
// JHAOFLCJNAL member values. The numeric leg is verified (the "modify"
// closure boxes the same value as System.Int32 — JEEACJHABAF.txt ISIL 031);
// the NAMES are inferred from the room-accessibility enum in the nearest
// dump with real names (RecRoom-2023.06.21 dump.cs:1293997-1294004
// MKDFFJGGKDL { Private = 0, Public = 1, Unlisted = 2 }), which is the enum
// Enum.ToString() would spell out on the standalone accessibility call.
private const int AccessibilityPrivate = 0;
private static int? ParseSubRoomAccessibility(string? raw)
{
if (string.IsNullOrWhiteSpace(raw)) return null;
var value = raw.Trim();
if (int.TryParse(value, out var numeric)) return numeric;
if (value.StartsWith("priv", StringComparison.OrdinalIgnoreCase)) return 0;
if (value.StartsWith("pub", StringComparison.OrdinalIgnoreCase)) return 1;
if (value.StartsWith("unlist", StringComparison.OrdinalIgnoreCase)) return 2;
return null;
}
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/publish_save")]
[HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/publish_save")]
@@ -3558,7 +3787,9 @@ public class RoomsController(
scene.DataModifiedAt = DateTime.UtcNow;
room.UpdatedAt = scene.DataModifiedAt;
await db.SaveChangesAsync();
return Ok(SceneWire(scene));
// "restore subroom save" returns Task<FGCPNAACHIK> (NLDBPDCNNCF.txt:10476),
// so the scene row alone made a successful restore read as a failure.
return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId));
}
private async Task<PublishSubRoomSaveRequest> ReadPublishSubRoomSaveRequestAsync()
@@ -3640,34 +3871,68 @@ public class RoomsController(
};
db.RoomScenes.Add(clone);
await db.SaveChangesAsync();
return Ok(SceneWire(clone));
// Task<FGCPNAACHIK> ("clone subroom", NLDBPDCNNCF.txt:9554) — the scene
// row is not what the client re-parses.
return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId));
}
// Subroom settings save POSTs here on the roomserver host ("failed to
// modify room" = the bare-only routes 404'd). Same roomserver/ prefix as
// the sibling subroom mutations.
//
// Body is form-urlencoded name/accessibility/maxPlayers ("modify room",
// NLDBPDCNNCF.txt:10846 verb 3; keys at JEEACJHABAF.txt ISIL 021/035/048).
// [FromBody] rejected that with 415 before the handler ran, and the DTO had
// no 'accessibility' member at all, so the bulk subroom-settings save was a
// guaranteed failure.
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/modify")]
[HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/modify")]
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/modify")]
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/modify")]
[Authorize]
public Task<IActionResult> ModifySubRoom(long roomId, long subRoomId, [FromBody] SubRoomModifyRequest req) =>
public Task<IActionResult> ModifySubRoom(
long roomId,
long subRoomId,
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] SubRoomModifyRequest req) =>
MutateScene(roomId, subRoomId, s =>
{
if (!string.IsNullOrWhiteSpace(req.Name)) s.Name = req.Name.Trim();
if (req.MaxPlayers is int mp) s.MaxPlayers = Math.Max(1, mp);
if (req.IsSandbox is bool sb) s.IsSandbox = sb;
if (req.CanMatchmakeInto is bool cmi) s.CanMatchmakeInto = cmi;
if (ParseSubRoomAccessibility(req.Accessibility) is int a)
s.CanMatchmakeInto = a != AccessibilityPrivate;
if (req.RoomSceneLocationId is not null) s.RoomSceneLocationId = req.RoomSceneLocationId;
});
/// <summary>POST <c>rooms/{id}/subrooms/{sub}/move</c> — reorder a
/// scene. Body NewIndex is the target OrderIndex; the in-between
/// rows shift to fill the gap.</summary>
/// <summary>POST <c>rooms/{id}/subrooms/{sub}/move</c>.
///
/// <para><b>The 2023 semantics are "move to another ROOM", not "reorder".</b>
/// <c>NLDBPDCNNCF.MGGALMIAMBG(long roomId, long subRoomId,
/// Nullable&lt;Int64&gt; newRoomId)</c> ("move subroom",
/// NLDBPDCNNCF.txt:9659, verb 2 = POST) sends the single form key
/// <c>newRoomId</c> (COIOGADEBKJ.txt ISIL 013-020, boxed as
/// <c>Nullable&lt;Int64&gt;</c>) and returns
/// <c>Task&lt;FGCPNAACHIK&gt;</c>. The old handler bound
/// <c>[FromBody] {NewIndex}</c> — a key the client never sends, on a body
/// binding that 415s form posts — and reordered scenes in place instead.</para>
///
/// <para>The caller must own BOTH rooms. The scene is re-parented to the end
/// of the target room's scene list and the source room's remaining scenes
/// are re-packed so <c>OrderIndex</c> stays a dense 0..n-1 sequence — the
/// wire <c>RoomSceneId</c> and matchmaking <c>SubRoomId</c> are that index.
/// Scene 0 is the room's entry scene and cannot be moved out.
/// <c>newIndex</c> (no client sends it) keeps the old in-room reorder for
/// the import tooling.</para></summary>
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/move")]
[HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/move")]
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/move")]
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/move")]
[Authorize]
public async Task<IActionResult> MoveSubRoom(long roomId, long subRoomId, [FromBody] SubRoomMoveRequest req)
public async Task<IActionResult> MoveSubRoom(
long roomId,
long subRoomId,
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] SubRoomMoveRequest req)
{
var pid = this.RequireCurrentPlayerId();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
@@ -3677,19 +3942,69 @@ public class RoomsController(
.Where(s => s.RoomId == roomId).OrderBy(s => s.OrderIndex).ToListAsync();
var moving = scenes.FirstOrDefault(s => s.OrderIndex == subRoomId);
if (moving is null) return NotFound();
var newIndex = Math.Clamp(req.NewIndex ?? 0, 0, scenes.Count - 1);
scenes.Remove(moving);
scenes.Insert(newIndex, moving);
// Recompute OrderIndex on the entire row set. The unique
// (RoomId, OrderIndex) index would clash if we wrote rows in
// place — flush a sentinel offset first, then settle to
// final indices.
// `newRoomId` is Nullable<Int64> on the wire and the form writer
// stringifies it with ToString(), which yields "" for the empty case —
// so an absent target arrives as a blank field, not a missing one.
// Treat "no target and no index" as a no-op; the old code fell through
// to `NewIndex ?? 0` and silently reordered the scene to the front.
if (req.NewRoomId is not long targetRoomId || targetRoomId == roomId)
{
if (req.NewIndex is not int requestedIndex)
return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId));
var newIndex = Math.Clamp(requestedIndex, 0, scenes.Count - 1);
scenes.Remove(moving);
scenes.Insert(newIndex, moving);
await RepackSceneOrderAsync(scenes);
return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId));
}
// Cross-room move — the path the 2023 client actually takes.
var target = await db.Rooms.FirstOrDefaultAsync(r => r.Id == targetRoomId);
if (target is null) return NotFound(new { error = "target_room_not_found", targetRoomId });
if (target.CreatorPlayerId != pid) return Forbid();
if (moving.OrderIndex == 0)
return BadRequest(new { error = "cannot_move_entry_scene" });
// Names are unique per room (RoomScenes index on (RoomId, Name)).
var movedName = moving.Name;
var suffix = 1;
while (await db.RoomScenes.AnyAsync(s => s.RoomId == targetRoomId && s.Name == movedName))
{
suffix += 1;
movedName = $"{moving.Name} {suffix}";
}
var targetOrder = (await db.RoomScenes.Where(s => s.RoomId == targetRoomId)
.Select(s => (int?)s.OrderIndex).MaxAsync() ?? -1) + 1;
moving.RoomId = targetRoomId;
moving.OrderIndex = targetOrder;
moving.Name = movedName;
moving.DataModifiedAt = DateTime.UtcNow;
room.UpdatedAt = moving.DataModifiedAt;
target.UpdatedAt = moving.DataModifiedAt;
await db.SaveChangesAsync();
// Re-pack the source room so its remaining scenes keep dense indices
// (same sentinel dance as the in-room reorder above).
await RepackSceneOrderAsync(scenes.Where(s => s.Id != moving.Id).ToList());
logger.LogInformation("[subroom-move] scene={Scene} room={From}→{To} by={By}",
moving.Id, roomId, targetRoomId, pid);
return Ok(await BuildRoomServerDetailsWithRolesAsync(targetRoomId));
}
/// <summary>Rewrite <see cref="RoomSceneEntity.OrderIndex"/> to 0..n-1 in
/// list order. The unique (RoomId, OrderIndex) index would clash if rows
/// were written in place, so flush a sentinel offset first, then settle.</summary>
private async Task RepackSceneOrderAsync(List<RoomSceneEntity> scenes)
{
if (scenes.Count == 0) return;
const int sentinelOffset = 100_000;
for (int i = 0; i < scenes.Count; i++) scenes[i].OrderIndex = i + sentinelOffset;
await db.SaveChangesAsync();
for (int i = 0; i < scenes.Count; i++) scenes[i].OrderIndex = i;
await db.SaveChangesAsync();
return Ok(SceneWire(moving));
}
/// <summary>POST <c>roomserver/rooms/{id}/subrooms/{sub}/data</c> —
@@ -3762,6 +4077,11 @@ public class RoomsController(
return null;
}
/// <summary>Shared owner-gate + mutate + save for the subroom setting
/// endpoints. Responds with the FULL room details: every 2023 caller that
/// lands here (maxplayers / accessibility / modify) is typed
/// <c>Task&lt;FGCPNAACHIK&gt;</c>, so the old scene-row payload made a
/// successful mutation surface as a client-side parse failure.</summary>
private async Task<IActionResult> MutateScene(long roomId, long subRoomId, Action<RoomSceneEntity> mutator)
{
var pid = this.RequireCurrentPlayerId();
@@ -3773,7 +4093,7 @@ public class RoomsController(
mutator(scene);
scene.DataModifiedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(SceneWire(scene));
return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId));
}
private async Task<RoomSceneEntity?> GetOrCreateSceneForMutationAsync(RoomEntity room, long subRoomId)
@@ -3798,25 +4118,21 @@ public class RoomsController(
return scene;
}
private static object SceneWire(RoomSceneEntity s) => new
{
Result = 0,
RoomId = s.RoomId,
RoomSceneId = s.OrderIndex,
SubRoomId = s.OrderIndex,
Name = s.Name,
s.DataBlobName,
RoomSceneLocationId = s.RoomSceneLocationId,
s.IsSandbox,
s.MaxPlayers,
s.CanMatchmakeInto,
};
// SceneWire (the old {Result,RoomId,RoomSceneId,…} scene-row payload) is
// gone: every subroom mutation the 2023 client issues is typed
// Task<FGCPNAACHIK> and re-parses the full room details, so all of these
// handlers now go through BuildRoomServerDetailsWithRolesAsync.
// ── Bare-path roles ────────────────────────────────────────────────
/// <summary>GET <c>rooms/{id}/roles</c> — account role DTOs consumed by
/// 2023's EFHPLDPNGIM deserializer.</summary>
/// 2023's EFHPLDPNGIM deserializer
/// (<c>NLDBPDCNNCF.LAJDCNIMBAB</c> → <c>Task&lt;List&lt;EFHPLDPNGIM&gt;&gt;</c>,
/// NLDBPDCNNCF.txt:2111, verb 0 = GET). Like every other NLDBPDCNNCF call it
/// arrives with the <c>roomserver/</c> prefix, so the bare-only registration
/// 404'd the permissions screen's list fetch.</summary>
[HttpGet("rooms/{roomId:long}/roles")]
[HttpGet("roomserver/rooms/{roomId:long}/roles")]
public async Task<IActionResult> RolesList(long roomId)
{
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
@@ -3939,7 +4255,12 @@ public class RoomsController(
return new StudioBundleInfo(saveId, target, version, name);
}
/// <summary>GET <c>rooms/{id}/roles/{playerId}</c> — one role row
/// (<c>NLDBPDCNNCF.PCFOIPELKHN</c> → <c>Task&lt;EFHPLDPNGIM&gt;</c>,
/// NLDBPDCNNCF.txt:2192). roomserver/ twin required, same as the list
/// above.</summary>
[HttpGet("rooms/{roomId:long}/roles/{playerId:long}")]
[HttpGet("roomserver/rooms/{roomId:long}/roles/{playerId:long}")]
public async Task<IActionResult> RoleForPlayer(long roomId, long playerId)
{
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
@@ -3954,10 +4275,17 @@ public class RoomsController(
public sealed class GrantRoleRequest { public int? Role { get; set; } }
/// <summary>POST <c>rooms/{id}/roles/{playerId}</c> — owner grants
/// a role to a player. Auto-accepted (no invite step).</summary>
/// <summary>PUT <c>rooms/{id}/roles/{playerId}</c> — owner grants
/// a role to a player. Auto-accepted (no invite step).
/// <c>NLDBPDCNNCF.LMIEIJMCEAB</c> ("change room account role",
/// NLDBPDCNNCF.txt:7380) is verb 3 = PUT, form key <c>role</c> carrying a
/// boxed Int32 (BFKNABDHMGG.txt ISIL 015), and returns the full
/// <c>FGCPNAACHIK</c> details — which this already does. Only the
/// roomserver/ twin was missing.</summary>
[HttpPut("rooms/{roomId:long}/roles/{playerId:long}")]
[HttpPost("rooms/{roomId:long}/roles/{playerId:long}")]
[HttpPut("roomserver/rooms/{roomId:long}/roles/{playerId:long}")]
[HttpPost("roomserver/rooms/{roomId:long}/roles/{playerId:long}")]
[HttpPut("/api/rooms/v1/rooms/{roomId:long}/roles/{playerId:long}")]
[HttpPost("/api/rooms/v1/rooms/{roomId:long}/roles/{playerId:long}")]
[HttpPut("/api/rooms/v2/rooms/{roomId:long}/roles/{playerId:long}")]
@@ -3999,9 +4327,13 @@ public class RoomsController(
/// <summary>POST <c>rooms/{id}/roles/{playerId}/invite</c> — same
/// as grant but Accepted=false; the target's accept-invite flow
/// flips the flag (separate endpoint, not yet exposed). For now
/// invited rows surface in RoomDetails.InvitedCoOwners etc.</summary>
/// invited rows surface in RoomDetails.InvitedCoOwners etc.
/// <c>NLDBPDCNNCF.KIJDCLINOIC</c> ("invite account to role",
/// NLDBPDCNNCF.txt:7550) is verb 3 = PUT under the roomserver/ prefix.</summary>
[HttpPut("rooms/{roomId:long}/roles/{playerId:long}/invite")]
[HttpPost("rooms/{roomId:long}/roles/{playerId:long}/invite")]
[HttpPut("roomserver/rooms/{roomId:long}/roles/{playerId:long}/invite")]
[HttpPost("roomserver/rooms/{roomId:long}/roles/{playerId:long}/invite")]
[HttpPut("/api/rooms/v1/rooms/{roomId:long}/roles/{playerId:long}/invite")]
[HttpPost("/api/rooms/v1/rooms/{roomId:long}/roles/{playerId:long}/invite")]
[HttpPut("/api/rooms/v2/rooms/{roomId:long}/roles/{playerId:long}/invite")]
@@ -721,43 +721,6 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase
return null;
}
private async Task<JsonElement?> ReadJsonElementAsync(params string[] stringKeys)
{
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
var dict = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var key in form.Keys)
{
var value = form[key].FirstOrDefault();
if (!string.IsNullOrWhiteSpace(value)) dict[key] = value;
}
if (dict.Count == 0) return null;
return JsonSerializer.SerializeToElement(dict);
}
if ((Request.ContentLength ?? 0) > 0)
{
try
{
using var doc = await JsonDocument.ParseAsync(Request.Body);
return doc.RootElement.Clone();
}
catch (JsonException)
{
return null;
}
}
foreach (var key in stringKeys)
{
var value = Request.Query[key].FirstOrDefault();
if (!string.IsNullOrWhiteSpace(value)) return JsonSerializer.SerializeToElement(value);
}
return null;
}
// ── Multi-field payload reader ──────────────────────────────────────
//
// The single-key Read*Async helpers above each parse Request.Body, and a
@@ -1184,6 +1147,18 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase
});
}
/// <summary>PUT <c>rooms/{id}/promo_external</c> — "add room promotional
/// content" (verb byte 3 = PUT at NLDBPDCNNCF.txt:8181). Both the add and
/// the remove closures send exactly two form keys: <c>type</c> — a BOXED
/// System.Int32 (NLDBPDCNNCF/OJAKIAHKALI:017 boxes typeof(System.Int32),
/// key at :022) — and <c>reference</c>, a string (:031).
///
/// The old code stored the raw form dictionary, so Type landed on the
/// wire as the STRING "1"; the client's promo-external reader
/// (EPPPACFECMH.txt:191/:210 registers Type + Reference, and :233 reads
/// Type through the numeric accessor FAHHHNKECAB while :254 reads
/// Reference through the string accessor BKKEIINKNCL) rejects a string
/// there. Build the two members explicitly and typed.</summary>
[HttpPost("rooms/{roomId:long}/promo_external")]
[HttpPut("rooms/{roomId:long}/promo_external")]
[HttpPost("roomserver/rooms/{roomId:long}/promo_external")]
@@ -1191,70 +1166,186 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public async Task<IActionResult> BarePromoExternalAdd(long roomId)
{
var payload = await ReadJsonElementAsync("url", "Url", "value", "Value");
if (payload is null) return BadRequest(new { error = "missing_promo_external" });
var payload = await ReadPayloadAsync();
var reference = Value(payload, "reference", "Reference", "url", "Url", "value", "Value");
if (string.IsNullOrWhiteSpace(reference))
return BadRequest(new { error = "missing_promo_external" });
var type = IntValue(payload, "type", "Type") ?? 0;
return await ApplyAndReturn(roomId, r =>
{
var items = ReadJsonElementList(r.PromoExternalContentJson);
var raw = payload.Value.GetRawText();
if (!items.Any(i => string.Equals(i.GetRawText(), raw, StringComparison.Ordinal)))
items.Add(payload.Value);
var items = ReadJsonElementList(r.PromoExternalContentJson)
.Where(i => !PromoExternalMatches(i, type, reference))
.ToList();
items.Add(JsonSerializer.SerializeToElement(new
{
Type = type,
Reference = reference,
}));
r.PromoExternalContentJson = SerializeLimited(items);
});
}
/// <summary>DELETE <c>rooms/{id}/promo_external</c> — "remove room
/// promotional content" (verb byte 4 = DELETE at NLDBPDCNNCF.txt:8343).
/// Identity is the (<c>type</c>, <c>reference</c>) PAIR
/// (NLDBPDCNNCF/LLFPMJMCLBP:022 and :031), not an id/url — the old
/// id/url/value lookup always missed, so every remove 400'd with
/// missing_promo_external and promo links could not be deleted.</summary>
[HttpDelete("rooms/{roomId:long}/promo_external")]
[HttpDelete("roomserver/rooms/{roomId:long}/promo_external")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public async Task<IActionResult> BarePromoExternalRemove(long roomId)
{
var match = await ReadStringValueAsync("id", "Id", "url", "Url", "value", "Value");
if (string.IsNullOrWhiteSpace(match)) return BadRequest(new { error = "missing_promo_external" });
var payload = await ReadPayloadAsync();
var reference = Value(payload, "reference", "Reference", "id", "Id", "url", "Url", "value", "Value");
if (string.IsNullOrWhiteSpace(reference))
return BadRequest(new { error = "missing_promo_external" });
// type is absent on the 2020.12 watch's remove — treat "no type" as
// "match on reference alone".
var type = IntValue(payload, "type", "Type");
return await ApplyAndReturn(roomId, r =>
{
var items = ReadJsonElementList(r.PromoExternalContentJson)
.Where(i => !PromoExternalMatches(i, match))
.Where(i => !PromoExternalMatches(i, type, reference))
.ToList();
r.PromoExternalContentJson = SerializeLimited(items);
});
}
private static bool PromoExternalMatches(JsonElement item, string match)
/// <summary>A stored promo-external entry matches when its Reference
/// does and (when the caller supplied one) its Type agrees. Legacy rows
/// written before the typed shape landed may be bare strings or carry
/// Url/Id instead of Reference, so those spellings are still probed.
/// </summary>
private static bool PromoExternalMatches(JsonElement item, int? type, string reference)
{
if (string.Equals(item.GetRawText(), match, StringComparison.OrdinalIgnoreCase)) return true;
if (item.ValueKind == JsonValueKind.String)
return string.Equals(item.GetString(), match, StringComparison.OrdinalIgnoreCase);
return string.Equals(item.GetString(), reference, StringComparison.OrdinalIgnoreCase);
if (item.ValueKind != JsonValueKind.Object) return false;
foreach (var key in new[] { "Id", "id", "Url", "url", "Value", "value" })
var referenceMatches = false;
foreach (var key in new[] { "Reference", "reference", "Url", "url", "Id", "id", "Value", "value" })
{
if (item.TryGetProperty(key, out var prop) &&
prop.ValueKind == JsonValueKind.String &&
string.Equals(prop.GetString(), match, StringComparison.OrdinalIgnoreCase))
return true;
string.Equals(prop.GetString(), reference, StringComparison.OrdinalIgnoreCase))
{
referenceMatches = true;
break;
}
}
return false;
if (!referenceMatches) return false;
if (type is not int wanted) return true;
foreach (var key in new[] { "Type", "type" })
{
if (!item.TryGetProperty(key, out var prop)) continue;
if (prop.ValueKind == JsonValueKind.Number && prop.TryGetInt32(out var n)) return n == wanted;
if (prop.ValueKind == JsonValueKind.String && int.TryParse(prop.GetString(), out n)) return n == wanted;
}
// Untyped legacy row — reference alone is enough.
return true;
}
/// <summary><c>rooms/{id}/comments</c> + <c>voice_chat_encryption</c>
/// — fields not persisted in the 2020 RoomEntity. Owner-gated
/// acknowledgement so the watch's settings panel doesn't surface
/// a "save failed" error; populated when the columns land.</summary>
/// <summary><c>rooms/{id}/comments</c> (form key <c>disable</c>, Boolean —
/// NLDBPDCNNCF/EKLEDLOPLJB:015/:020) and
/// <c>rooms/{id}/voice_chat_encryption</c> (form key
/// <c>encryptVoiceChat</c>, Boolean — NLDBPDCNNCF/NDKDDENPHJP:015/:020).
/// Both are PUT (verb byte 3 at NLDBPDCNNCF.txt:6879 and :7033) and both
/// are issued under the roomserver/ prefix by the 2023 in-room settings
/// panel — the missing twins made them 404 there.
///
/// The VALUES still aren't persisted: RoomEntity has no
/// DisableRoomComments / EncryptVoiceChat column, so RoomService.cs:1122
/// and RoomsController.cs:2130 hardcode both to false on the wire.
/// Adding those columns (plus a migration and the two detail builders)
/// is outside this file. Until then this stays an owner-gated
/// acknowledgement that answers with the full details object the call
/// site's reader [0x1884C1948] expects, so the panel doesn't report a
/// save failure on top of the toggle not sticking.</summary>
[HttpPost("rooms/{roomId:long}/comments")]
[HttpPut("rooms/{roomId:long}/comments")]
[HttpPost("roomserver/rooms/{roomId:long}/comments")]
[HttpPut("roomserver/rooms/{roomId:long}/comments")]
[HttpPost("rooms/{roomId:long}/voice_chat_encryption")]
[HttpPut("rooms/{roomId:long}/voice_chat_encryption")]
[HttpPost("roomserver/rooms/{roomId:long}/voice_chat_encryption")]
[HttpPut("roomserver/rooms/{roomId:long}/voice_chat_encryption")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public async Task<IActionResult> BareAck(long roomId) =>
await ApplyAndReturn(roomId, _ => { /* no-op: not modelled yet */ });
await ApplyAndReturn(roomId, _ => { /* value not persistable: no column */ });
/// <summary><c>rooms/{id}/modify</c> — bare-path equivalent
/// of <c>api/rooms/v2/modify</c>. Reuses <see cref="Modify"/> by
/// forwarding the body with RoomId pre-populated from the URL.</summary>
/// <summary>PUT <c>rooms/{id}/modify</c> — the room-settings panel's bulk
/// save (verb byte 3 = PUT at NLDBPDCNNCF.txt:10829, "modify room"), and
/// the 2023 client issues it under the roomserver/ prefix too.
///
/// The body is x-www-form-urlencoded with ELEVEN camelCase keys, emitted
/// in this order by NLDBPDCNNCF/MMMNLOBFFKO: <c>name</c> (string, :039),
/// <c>description</c> (string, :049), <c>accessibility</c> (Int32, boxed
/// at :059 / key :063), then eight Booleans — <c>supportsScreens</c>
/// (:076), <c>supportsWalkVR</c> (:089), <c>supportsTeleportVR</c>
/// (:102), <c>supportsJuniors</c> (:115), <c>cloningAllowed</c> (:128),
/// <c>disableMicAutoMute</c> (:141), <c>disableRoomComments</c> (:154)
/// and <c>encryptVoiceChat</c> (:167).
///
/// The old <c>[FromBody] ModifyRoomRequest</c> forwarder 415'd on that
/// form body before the handler ran, was missing four of the fields, and
/// answered <c>{Result, Room}</c> — but the call site attaches the full
/// details reader [0x1884C1948] (:10826), the same one clone uses, so
/// the response must be BuildRoomServerDetails.
///
/// disableRoomComments / encryptVoiceChat are deliberately NOT read here:
/// RoomEntity has no column for either, so there is nowhere to put them
/// (see <see cref="BareAck"/> above).</summary>
[HttpPost("rooms/{roomId:long}/modify")]
[HttpPut("rooms/{roomId:long}/modify")]
public async Task<IActionResult> BareModify(long roomId, [FromBody] ModifyRoomRequest req)
[HttpPost("roomserver/rooms/{roomId:long}/modify")]
[HttpPut("roomserver/rooms/{roomId:long}/modify")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public async Task<IActionResult> BareModify(long roomId)
{
req.RoomId = roomId;
return await Modify(req);
var payload = await ReadPayloadAsync();
var name = Value(payload, "name", "Name");
// RawValue for description: clearing it sends the key with "".
var description = RawValue(payload, "description", "Description");
var accessibility = IntValue(payload, "accessibility", "Accessibility");
var imageName = Value(payload, "imageName", "ImageName");
var screens = BoolValue(payload, "supportsScreens", "SupportsScreens");
var walkVR = BoolValue(payload, "supportsWalkVR", "SupportsWalkVR");
var teleportVR = BoolValue(payload, "supportsTeleportVR", "SupportsTeleportVR");
var juniors = BoolValue(payload, "supportsJuniors", "AllowsJuniors");
var cloning = BoolValue(payload, "cloningAllowed", "CloningAllowed");
var autoMute = BoolValue(payload, "disableMicAutoMute", "DisableMicAutoMute");
var mobile = BoolValue(payload, "supportsMobile", "SupportsMobile");
var vrLow = BoolValue(payload, "supportsVRLow", "SupportsVRLow");
var room = await RequireOwnedRoomAsync(roomId);
if (room is null) return NotFound();
if (!string.IsNullOrWhiteSpace(name)) room.Name = name.Trim();
if (description is not null) room.Description = description;
if (imageName is not null) room.ImageName = imageName;
if (accessibility is int a) room.Accessibility = Math.Clamp(a, 0, 2);
if (screens is bool s) room.SupportsScreens = s;
if (walkVR is bool w) room.SupportsWalkVR = w;
if (teleportVR is bool t) room.SupportsTeleportVR = t;
if (juniors is bool j) room.AllowsJuniors = j;
if (cloning is bool c) room.CloningAllowed = c;
if (autoMute is bool m) room.DisableMicAutoMute = m;
if (mobile is bool mo) room.SupportsMobile = mo;
if (vrLow is bool vl) room.SupportsVRLow = vl;
room.UpdatedAt = DateTime.UtcNow;
try
{
await db.SaveChangesAsync();
}
catch (DbUpdateException)
{
// Likely a unique-name collision.
return Conflict(new { Result = 2, Message = "name taken" });
}
return Ok(await BuildRoomDetailsAsync(room));
}
/// <summary>POST <c>rooms/{id}/clone</c> — bare-path alias of
@@ -1463,24 +1554,89 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase
}
// ── Bare-path bans aliases ─────────────────────────────────────────
public sealed class BareBanRequest { public long PlayerId { get; set; } public int BanType { get; set; } public DateTime? Until { get; set; } public string? Reason { get; set; } }
//
// Every one of these needs the roomserver/ twin: the 2023 in-room
// banned-players panel issues them under that prefix
// (NLDBPDCNNCF.txt:12901 GET, :13044 POST "ban players", :13191 POST
// "rooms/{0}/bans/import", :13353 DELETE "rooms/{0}/bans/{1}"), and a
// bare-only registration 404s there.
[HttpGet("rooms/{roomId:long}/bans")]
[HttpGet("roomserver/rooms/{roomId:long}/bans")]
public Task<IActionResult> BareBansList(long roomId) => RoomBansList(roomId);
/// <summary>POST <c>rooms/{id}/bans</c> — "ban players" (verb byte 2 =
/// POST at NLDBPDCNNCF.txt:13060). The client sends
/// x-www-form-urlencoded with ONE <c>banMask</c> (boxed Int32) plus a
/// REPEATED <c>id</c> key — one per account being banned
/// (NLDBPDCNNCF/DMMILEJMMCC:024 and :034; the `id` binder is the
/// list-valued overload, fed from the
/// <c>IReadOnlyList&lt;Int32&gt;</c> parameter of the issuing method at
/// NLDBPDCNNCF.txt:12920). The old <c>[FromBody] BareBanRequest</c>
/// rejected the form body with 415 before the handler ran, and named
/// PlayerId/BanType — keys the client never sends — so in-room bans
/// never persisted. No response reader is attached at the call site
/// (plain BDBHCEGKGDC dispatch, :13065), so the ack shape is free.
/// </summary>
[HttpPost("rooms/{roomId:long}/bans")]
public Task<IActionResult> BareBan(long roomId, [FromBody] BareBanRequest req) =>
BanFromRoom(new BanFromRoomRequest
[HttpPost("roomserver/rooms/{roomId:long}/bans")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public async Task<IActionResult> BareBan(long roomId)
{
var room = await RequireOwnedRoomAsync(roomId);
if (room is null) return NotFound();
var payload = await ReadPayloadAsync();
// banMask IS the BanType enum on the wire. Default 1 (Permanent):
// the in-room panel's only ban action is a permanent ban.
var banType = IntValue(payload, "banMask", "banType", "type", "BanType") ?? 1;
var reason = RawValue(payload, "reason", "Reason") ?? string.Empty;
DateTime? until = DateTime.TryParse(Value(payload, "until", "Until"), out var parsedUntil)
? (DateTime?)parsedUntil.ToUniversalTime()
: null;
// "id" is the 2023 key; the others are 2020.12 / admin spellings. The
// payload map is case-insensitive, so PascalCase variants fall out.
var ids = Values(payload, "id", "playerId", "accountId")
.Select(v => long.TryParse(v, out var n) ? n : 0L)
.Where(n => n > 0)
.Distinct()
.ToList();
if (ids.Count == 0) return BadRequest(new { error = "missing_player" });
var existing = await db.RoomBans
.Where(b => b.RoomId == roomId && ids.Contains(b.BannedPlayerId))
.ToListAsync();
foreach (var id in ids)
{
RoomId = roomId,
PlayerId = req.PlayerId,
BanType = req.BanType,
Until = req.Until,
Reason = req.Reason,
});
// Re-banning an already-banned player updates the row rather than
// stacking duplicates — the panel re-sends the whole selection.
var row = existing.FirstOrDefault(b => b.BannedPlayerId == id);
if (row is null)
{
db.RoomBans.Add(new RoomBanEntity
{
RoomId = roomId,
BannedPlayerId = id,
BannedByPlayerId = Me,
BanType = banType,
Until = until,
Reason = reason.Length > 512 ? reason[..512] : reason,
});
}
else
{
row.BanType = banType;
row.Until = until;
if (reason.Length > 0) row.Reason = reason.Length > 512 ? reason[..512] : reason;
}
}
await db.SaveChangesAsync();
return Ok(new { Result = 0, Banned = ids.Count });
}
[HttpDelete("rooms/{roomId:long}/bans/{playerId:long}")]
[HttpDelete("roomserver/rooms/{roomId:long}/bans/{playerId:long}")]
public async Task<IActionResult> BareUnban(long roomId, long playerId)
{
var room = await RequireOwnedRoomAsync(roomId);
@@ -1494,11 +1650,59 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase
return Ok(new { Removed = rows.Count });
}
/// <summary>POST <c>rooms/{id}/bans/import</c> — "import ban list"
/// (verb byte 2 = POST at NLDBPDCNNCF.txt:13207). The client names a
/// SOURCE ROOM whose bans should be copied in — a single Int64 form key
/// <c>sourceRoomId</c> (NLDBPDCNNCF/KGDNOLMFENE:015 boxes System.Int64,
/// :020 is the key) — NOT an explicit ban list. The old
/// <c>[FromBody] ImportRoomBansRequest</c> therefore 415'd on the form
/// body AND modelled the wrong contract. The JSON ban-list shape stays
/// available on the legacy <c>api/rooms/v1/importroombans</c>.</summary>
[HttpPost("rooms/{roomId:long}/bans/import")]
public Task<IActionResult> BareBansImport(long roomId, [FromBody] ImportRoomBansRequest req)
[HttpPost("roomserver/rooms/{roomId:long}/bans/import")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public async Task<IActionResult> BareBansImport(long roomId)
{
req.RoomId = roomId;
return ImportRoomBans(req);
var target = await RequireOwnedRoomAsync(roomId);
if (target is null) return NotFound();
var payload = await ReadPayloadAsync();
// Deliberately NOT aliased to "roomId" — that name means the TARGET
// room elsewhere in this surface and would silently self-import.
var sourceRoomId = LongValue(payload, "sourceRoomId", "SourceRoomId") ?? 0;
if (sourceRoomId <= 0) return BadRequest(new { error = "missing_source_room" });
if (sourceRoomId == roomId) return Ok(new { Imported = 0 });
// Owner-gate BOTH rooms — importing bulk-reads the source room's ban
// list, which is owner-only information (see RoomBansList).
var source = await RequireOwnedRoomAsync(sourceRoomId);
if (source is null) return Forbid();
var sourceBans = await db.RoomBans
.Where(b => b.RoomId == sourceRoomId)
.ToListAsync();
var seen = new HashSet<long>(await db.RoomBans
.Where(b => b.RoomId == roomId)
.Select(b => b.BannedPlayerId)
.ToListAsync());
var imported = 0;
foreach (var b in sourceBans)
{
if (!seen.Add(b.BannedPlayerId)) continue;
db.RoomBans.Add(new RoomBanEntity
{
RoomId = roomId,
BannedPlayerId = b.BannedPlayerId,
BannedByPlayerId = Me,
BanType = b.BanType,
Until = b.Until,
Reason = b.Reason,
});
imported++;
}
if (imported > 0) await db.SaveChangesAsync();
return Ok(new { Imported = imported });
}
private async Task<IActionResult> ApplyAndReturn(long roomId, Action<RoomEntity> mutator)
@@ -1509,6 +1713,33 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase
room.UpdatedAt = DateTime.UtcNow;
try { await db.SaveChangesAsync(); }
catch (DbUpdateException) { return Conflict(new { Result = 2 }); }
return Ok(Services.RoomService.ToWireRoom(room));
return Ok(await BuildRoomDetailsAsync(room));
}
/// <summary>The response body EVERY bare-path room mutation must return.
///
/// The 2023 roomserver client dispatches all of them through the same
/// typed helper (<c>0x1835E50A0</c>) with the same response-reader token
/// <c>[0x1884C1948]</c> — see NLDBPDCNNCF.txt:5366 (description),
/// :5691 (tags), :6006 (accessibility), :6169 (restrictions),
/// :6335 (cloning), :6710 (automute), :6876 (comments), :7195 (min_level),
/// :7360 (allow_new_users), :7859 (promo_images), :8178 (promo_external),
/// :8516 (loadscreen), :10826 (modify). That is the SAME token the clone
/// call site uses (:4815), which the repo already established is the full
/// FGCPNAACHIK details object built by BuildRoomServerDetails (see
/// <see cref="BareClone"/>). The slim RoomService.ToWireRoom has no
/// SubRooms/Roles/Tags/LoadScreens/PromoImages/DataBlob, so the strict
/// reader's dispose-walk NRE'd on the missing nested lists and every
/// mutation reported failure even though it had persisted.</summary>
private async Task<object> BuildRoomDetailsAsync(RoomEntity room)
{
var scenes = await db.RoomScenes
.Where(s => s.RoomId == room.Id)
.OrderBy(s => s.OrderIndex)
.ToListAsync();
var roles = await db.RoomRoles
.Where(r => r.RoomId == room.Id)
.ToListAsync();
return RoomsController.BuildRoomServerDetails(room, scenes, roles: roles);
}
}
@@ -40,6 +40,16 @@ public class StorefrontsBuyController(
public int StorefrontType { get; set; }
public int PurchasableItemId { get; set; }
public int CurrencyType { get; set; } = 2;
/// <summary>The price the tile displayed, sent as a stale-price guard.
/// The field was missing here, so buyTier/buyElite had nothing to
/// validate against and fell back to a flat cost. Verified on the
/// 2023-03-21 build: <c>Nullable&lt;Int64&gt;</c> at +32 in
/// <c>RecNet.Runtime/DCFKEFHJAGC_NestedType_RequestPurchaseItemDTO.txt:123-137</c>,
/// wire key <c>"RequestedPrice"</c> in the generated JSON contract
/// <c>RecNet.Runtime/FPLGJGDJIJG.txt:534</c>.</summary>
public long? RequestedPrice { get; set; }
public long? CouponConsumablePlayerMappingId { get; set; }
public GiftDto? Gift { get; set; }
}
@@ -185,36 +195,75 @@ public class StorefrontsBuyController(
isGift ? null : gift.Id));
}
/// <summary>POST <c>api/storefronts/v2/buyTier</c> — season-pass "skip a
/// tier" button. The tier is an ordinary StoreItems row on the
/// <c>season:{n}</c> storefront (see StorefrontsController.Season), so its
/// real price is charged rather than a flat 1 token, and the purchase is
/// recorded as a <c>seasonTier:*</c> progress row so the season page can
/// count how many tiers the player bought.</summary>
[HttpPost("api/storefronts/v2/buyTier")]
public async Task<IActionResult> BuyTier([FromBody] RequestPurchaseItemDto req)
{
// Season-pass tier: no per-item table, so we just deduct a
// small fixed cost (1 token) per tier and ack. A full
// season-pass system would have a SeasonProgressEntity.
if (req is null) return BadRequest("missing body");
var pid = Me;
const int TierCost = (int)StoreService.StoreItemPrice;
var item = await db.StoreItems
.FirstOrDefaultAsync(s => s.Id == req.PurchasableItemId && s.IsActive);
// Tiers that aren't seeded as StoreItems still cost the catalogue
// default so the button isn't free on a bare install.
var cost = Math.Max(0, item?.Price ?? StoreService.StoreItemPrice);
var balance = await level.GetBalanceAsync(pid, req.CurrencyType);
if (balance < TierCost) return Ok(BalanceUpdateResponse(balance, req.CurrencyType));
var newBalance = await level.GrantCurrencyAsync(pid, req.CurrencyType, -TierCost,
$"buyTier:{req.PurchasableItemId}");
// Stale-price guard: the client sends the price its tile showed.
if (req.RequestedPrice is long requested && requested != cost)
return Ok(BalanceUpdateResponse(balance, req.CurrencyType));
if (balance < cost) return Ok(BalanceUpdateResponse(balance, req.CurrencyType));
var newBalance = cost == 0
? balance
: await level.GrantCurrencyAsync(pid, req.CurrencyType, -cost,
$"buyTier:{req.PurchasableItemId}");
await RecordSeasonProgressAsync(pid,
$"seasonTier:{req.StorefrontType}:{req.PurchasableItemId}");
return Ok(BalanceUpdateResponse(newBalance, req.CurrencyType));
}
/// <summary>POST <c>api/storefronts/v2/buyElite</c> — season "elite
/// upgrade" (the paid premium track). There is no real-money flow on a
/// private server, so it is priced in tokens off the same StoreItems row
/// the season storefront advertised. The flag is persisted as a
/// <c>seasonElite:*</c> progress row.</summary>
[HttpPost("api/storefronts/v2/buyElite")]
public async Task<IActionResult> BuyElite([FromBody] RequestPurchaseItemDto req)
{
// Elite-tier purchase (paid premium track). Without real
// money flow on a private server we just ack.
if (req is null) return BadRequest("missing body");
var pid = Me;
var eliteKey = $"seasonElite:{req.StorefrontType}";
var balance = await level.GetBalanceAsync(pid, req.CurrencyType);
return Ok(BalanceUpdateResponse(balance, req.CurrencyType));
// Already upgraded — ack without charging again so a replayed tap
// can't drain the wallet.
if (await db.ObjectiveProgress.AnyAsync(o => o.PlayerId == pid && o.Key == eliteKey))
return Ok(BalanceUpdateResponse(balance, req.CurrencyType));
var item = await db.StoreItems
.FirstOrDefaultAsync(s => s.Id == req.PurchasableItemId && s.IsActive);
var cost = Math.Max(0, item?.Price ?? StoreService.StoreItemPrice);
if (req.RequestedPrice is long requested && requested != cost)
return Ok(BalanceUpdateResponse(balance, req.CurrencyType));
if (balance < cost) return Ok(BalanceUpdateResponse(balance, req.CurrencyType));
var newBalance = cost == 0
? balance
: await level.GrantCurrencyAsync(pid, req.CurrencyType, -cost,
$"buyElite:{req.PurchasableItemId}");
await RecordSeasonProgressAsync(pid, eliteKey);
return Ok(BalanceUpdateResponse(newBalance, req.CurrencyType));
}
/// <summary>GET <c>api/storefronts/v1/buyRoomKey</c> — purchase a
/// persisted room key. The 2020.12 client sends roomKeyId and the
/// price it displayed so we reject stale-price purchases.</summary>
[HttpGet("api/storefronts/v1/buyRoomKey")]
[HttpGet("api/storefronts/v1/PurchaseRoomKeyWithCurrency")]
public async Task<IActionResult> BuyRoomKey(
[FromQuery] long roomKeyId,
[FromQuery] int requestedPrice)
@@ -252,93 +301,369 @@ public class StorefrontsBuyController(
return Ok(RoomKeyPurchaseResponse(RoomKeyStatus.Success, key, newBalance));
}
public sealed class PurchaseRoomKeyWithCurrencyRequest
{
public long RoomKeyId { get; set; }
public long RequestedPrice { get; set; }
public Guid RequestedPurchaseCurrencyId { get; set; }
}
/// <summary>POST <c>api/storefronts/v1/PurchaseRoomKeyWithCurrency</c> —
/// buying a room key with a ROOM currency instead of tokens.
///
/// This was only registered as a <c>[HttpGet]</c> alias of BuyRoomKey, so
/// every attempt 405'd. The 2023-03-21 client issues it with verb 2 (POST —
/// <c>RecNet.Runtime/DCFKEFHJAGC.txt:7426</c>) and three form fields,
/// <c>RoomKeyId</c> (Int64, :7439), <c>RequestedPrice</c> (Int64, :7451) and
/// <c>RequestedPurchaseCurrencyId</c> (Guid, :7464); the DTO signature is
/// <c>INBGDNMFJBP(Int64, Int64, Guid)</c> at :7206.
///
/// The response is <c>RoomKeyPurchaseWithCurrencyResponseDTO</c>, whose
/// generated contract has exactly two members — <c>Balance</c> and
/// <c>RoomKeyResponse</c> (<c>RecNet.Runtime/EDMKBBPGJAB.txt:203/222</c>) —
/// where Balance is the ROOM-currency balance row
/// {AccountId, CurrencyId, Balance, ModifiedAt}
/// (<c>RecNet.Runtime/DPNLANKOHON.txt:331-414</c>), NOT the token
/// BalanceUpdateResponse that the GET alias returned.</summary>
[HttpPost("api/storefronts/v1/PurchaseRoomKeyWithCurrency")]
public async Task<IActionResult> PurchaseRoomKeyWithCurrency(
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))]
PurchaseRoomKeyWithCurrencyRequest req)
{
var pid = Me;
var currency = await db.RoomCurrencies.FirstOrDefaultAsync(
c => c.PublicId == req.RequestedPurchaseCurrencyId && !c.IsDeleted);
var key = await db.RoomKeys.FirstOrDefaultAsync(
k => k.Id == req.RoomKeyId && !k.IsDeleted);
RoomCurrencyBalanceEntity? row = null;
if (currency is not null)
{
var currencyRowId = currency.Id;
row = await db.RoomCurrencyBalances.FirstOrDefaultAsync(
b => b.PlayerId == pid && b.RoomCurrencyId == currencyRowId);
}
if (currency is null)
return Ok(RoomKeyCurrencyPurchaseResponse(
RoomKeyStatus.InvalidParameters, key, pid, req.RequestedPurchaseCurrencyId, null));
if (key is null)
return Ok(RoomKeyCurrencyPurchaseResponse(
RoomKeyStatus.DoesNotExist, null, pid, currency.PublicId, row));
if (key.CreatorPlayerId == pid)
return Ok(RoomKeyCurrencyPurchaseResponse(
RoomKeyStatus.PlayerAlreadyOwns, key, pid, currency.PublicId, row));
var roomKeyRowId = key.Id;
if (await db.RoomKeyPurchases.AnyAsync(p => p.RoomKeyId == roomKeyRowId && p.PlayerId == pid))
return Ok(RoomKeyCurrencyPurchaseResponse(
RoomKeyStatus.PlayerAlreadyOwns, key, pid, currency.PublicId, row));
if (req.RequestedPrice != key.Price)
return Ok(RoomKeyCurrencyPurchaseResponse(
RoomKeyStatus.InvalidParameters, key, pid, currency.PublicId, row));
var balance = row?.Balance ?? 0;
if (balance < key.Price)
return Ok(RoomKeyCurrencyPurchaseResponse(
RoomKeyStatus.PurchaseFailed, key, pid, currency.PublicId, row));
if (row is null)
{
row = new RoomCurrencyBalanceEntity
{
PlayerId = pid,
RoomCurrencyId = currency.Id,
Balance = 0,
};
db.RoomCurrencyBalances.Add(row);
}
row.Balance = Math.Max(0, row.Balance - key.Price);
row.UpdatedAt = DateTime.UtcNow;
db.RoomKeyPurchases.Add(new RoomKeyPurchaseEntity
{
RoomKeyId = key.Id,
PlayerId = pid,
PaidPrice = key.Price,
});
await db.SaveChangesAsync();
return Ok(RoomKeyCurrencyPurchaseResponse(
RoomKeyStatus.Success, key, pid, currency.PublicId, row));
}
/// <summary>GET <c>api/storefronts/v2/buyInvention</c> — MakerPen store
/// purchase of a paid invention. Verb is 0/GET
/// (<c>RecNet.Runtime/DCFKEFHJAGC.txt:6772</c>) with query fields
/// <c>inventionId</c> (Int64, :6785) and <c>requestedPrice</c> (Int32,
/// :6797) — the latter was being ignored.
///
/// The response type is <c>InventionPurchaseResponseDTO</c>, whose contract
/// is {<c>InventionResponse</c>, <c>BalanceUpdateResponse</c>}
/// (<c>RecNet.Runtime/MAHHAAEGEEP.txt:215/242</c>). The handler used to
/// return the bare token-balance wrapper, so <c>InventionResponse</c> was
/// absent and the client got Status/Invention/InventionVersion = null —
/// it could never confirm the purchase or spawn what it just bought.
/// InventionResponse itself is {Status, Invention, InventionVersion}
/// (<c>RecNet.Runtime/GMBGBPNMGBA.txt:255/274/290</c>) — the same triple
/// InventionsController already returns from addversion.</summary>
[HttpGet("api/storefronts/v2/buyInvention")]
[HttpPost("api/storefronts/v2/buyInvention")]
[HttpGet("api/storefronts/v2/buyInvention")]
public async Task<IActionResult> BuyInvention([FromQuery] long inventionId, [FromQuery] int currencyType = 2)
public async Task<IActionResult> BuyInvention(
[FromQuery] long inventionId,
[FromQuery] long? requestedPrice = null,
[FromQuery] int currencyType = 2)
{
var pid = Me;
var inv = await db.Inventions.FirstOrDefaultAsync(i => i.Id == inventionId && !i.IsDeleted && i.IsPublished);
if (inv is null) return NotFound();
var price = Math.Max(0, inv.Price);
var balance = await level.GetBalanceAsync(pid, currencyType);
if (balance < price) return Ok(BalanceUpdateResponse(balance, currencyType));
var inv = await db.Inventions.FirstOrDefaultAsync(
i => i.Id == inventionId && !i.IsDeleted && i.IsPublished);
if (inv is null)
return Ok(InventionPurchaseResponse(
InventionStatusDoesNotExist, null, null, balance, currencyType));
var version = await db.InventionVersions
.Where(v => v.InventionId == inv.Id)
.OrderByDescending(v => v.VersionNumber)
.FirstOrDefaultAsync();
var price = Math.Max(0, inv.Price);
// Stale-price guard — the MakerPen tile sends the price it rendered.
if (requestedPrice is long asked && asked != price)
return Ok(InventionPurchaseResponse(
InventionStatusInvalidParameters, inv, version, balance, currencyType));
var key = $"invention_purchase:{inv.Id}";
var alreadyOwns = inv.CreatorPlayerId == pid
|| await db.ObjectiveProgress.AnyAsync(o => o.PlayerId == pid && o.Key == key);
if (alreadyOwns)
{
// Deliberately Success rather than PlayerAlreadyOwns (=19): the
// MakerPen re-issues this call on every spawn of a paid creation,
// and a non-zero status blocks the spawn.
return Ok(InventionPurchaseResponse(
InventionStatusSuccess, inv, version, balance, currencyType));
}
if (balance < price)
return Ok(InventionPurchaseResponse(
InventionStatusPurchaseFailed, inv, version, balance, currencyType));
var newBalance = price == 0
? balance
: await level.GrantCurrencyAsync(pid, currencyType, -price, $"buyInvention:{inv.Id}");
var key = $"invention_purchase:{inv.Id}";
if (!await db.ObjectiveProgress.AnyAsync(o => o.PlayerId == pid && o.Key == key))
db.ObjectiveProgress.Add(new ObjectiveProgressEntity
{
PlayerId = pid,
Key = key,
IsCompleted = true,
ClearedAt = DateTime.UtcNow,
});
await db.SaveChangesAsync();
if (price > 0 && inv.CreatorPlayerId != pid)
await level.GrantCurrencyAsync(inv.CreatorPlayerId, currencyType, price,
$"sellInvention:{inv.Id}");
return Ok(InventionPurchaseResponse(
InventionStatusSuccess, inv, version, newBalance, currencyType));
}
/// <summary>Body of <c>buyForFreeGiftButton</c>. Unlike its siblings this
/// route does NOT use form fields: the client serialises a
/// <c>RequestPurchaseForFreeGiftButtonDTO</c> and posts it as the whole
/// body (<c>RecNet.Runtime/DCFKEFHJAGC.txt:5910-5924</c>, verb 2 at :5912).
/// Field offsets +16/+24/+40/+44/+48 in
/// <c>DCFKEFHJAGC_NestedType_RequestPurchaseForFreeGiftButtonDTO.txt</c>,
/// wire keys PurchasableItemId / CouponConsumablePlayerMappingId / Count /
/// CreatorPlayerId / RequestedPrice in <c>RecNet.Runtime/IAPMDDFENDD.txt:395-502</c>.
/// </summary>
public sealed class FreeGiftButtonRequest
{
public int PurchasableItemId { get; set; }
public long? CouponConsumablePlayerMappingId { get; set; }
public int Count { get; set; } = 1;
public int CreatorPlayerId { get; set; }
public long? RequestedPrice { get; set; }
}
/// <summary>POST <c>api/storefronts/v1/buyForFreeGiftButton</c> — the
/// in-room "free gift button" gadget handing an item to whoever pressed it.
///
/// The handler bound <c>[FromQuery] currencyType/price</c> while the client
/// sends everything in the JSON body, so the requested item id never
/// arrived and a hardcoded 25-token "FreeGiftButton" package was inserted
/// instead — the gadget paid out the wrong reward on every press. Now the
/// body is read and the requested StoreItems row is granted directly.
///
/// The response is the plain <c>BalanceResponseDTO</c>
/// (<c>DCFKEFHJAGC.txt:6008/6055</c>) whose contract is exactly
/// {Balance, CurrencyType, Platform} — <c>RecNet.Runtime/PECGEJAAMHB.txt:255/274/298</c>.
/// There is no gift-package member, so nothing can be handed back through a
/// gift box here; the item has to land in the inventory server-side.</summary>
[HttpPost("api/storefronts/v1/buyForFreeGiftButton")]
[HttpGet("api/storefronts/v1/buyForFreeGiftButton")]
public async Task<IActionResult> BuyForFreeGiftButton(
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))]
FreeGiftButtonRequest req,
[FromQuery] int currencyType = 2)
{
var pid = Me;
var balance = await level.GetBalanceAsync(pid, currencyType);
var item = await db.StoreItems
.FirstOrDefaultAsync(s => s.Id == req.PurchasableItemId && s.IsActive);
if (item is null) return Ok(BalanceResponse(balance, currencyType));
// RequestedPrice is what the gadget quoted, and it is the total for the
// press — the gadget is normally free, so the item's catalogue price is
// deliberately NOT charged here. CouponConsumablePlayerMappingId is
// carried by the DTO but there is no coupon-mapping table to redeem it
// against yet, so it is accepted and ignored.
var price = Math.Max(0, req.RequestedPrice ?? 0);
if (balance < price) return Ok(BalanceResponse(balance, currencyType));
var newBalance = price == 0
? balance
: await level.GrantCurrencyAsync(pid, currencyType, -price,
$"freeGiftButton:{item.Slug}");
await GrantPurchasedItemAsync(pid, item, Math.Clamp(req.Count, 1, 50));
return Ok(BalanceResponse(newBalance, currencyType));
}
/// <summary>Form body of <c>buyProgressionEventXpBoost</c>, verified at
/// <c>RecNet.Runtime/DCFKEFHJAGC.txt:12431</c> (progressionEventId, Int64),
/// :12444 (purchasableXpBoostId, Guid), :12456 (requestedPrice, Int32) and
/// :12468 (expectedXp, Int32).</summary>
public sealed class ProgressionEventXpBoostRequest
{
public long ProgressionEventId { get; set; }
public Guid PurchasableXpBoostId { get; set; }
public int RequestedPrice { get; set; }
public int ExpectedXp { get; set; }
}
/// <summary>POST <c>api/storefronts/v1/buyProgressionEventXpBoost</c>.
///
/// Two defects: the handler bound <c>[FromQuery] currencyType/price</c> so
/// none of the four form fields arrived (nothing charged, no XP granted),
/// and it reused the shared BalanceUpdateResponse whose
/// <c>BalanceUpdates[].Data</c> is a LIST. This route's response is
/// <c>BalanceUpdateResponseDTO`1&lt;PurchasedProgressionEventXpBoostDTO&gt;</c>
/// (signature at <c>DCFKEFHJAGC.txt:12118</c>) and that family's inner Data
/// is a SINGLE object — <c>get_Data</c> returns <c>DataTypeDTO</c>, not
/// <c>List&lt;DataTypeDTO&gt;</c>, in
/// <c>DCFKEFHJAGC_NestedType_BalanceUpdateResponseDTO`1_NestedType_DEJIAFEKFCK.txt:83</c>
/// (contrast the Purchase* variant at
/// <c>..._PurchaseBalanceUpdateResponseDTO`1_NestedType_PKKNEGNNABE.txt:83</c>,
/// which IS a List). An array there makes the reader throw.
/// The payload object is {Xp:Int32} — <c>RecNet.Runtime/NNEJHCKLOHL.txt:139</c>.</summary>
[HttpPost("api/storefronts/v1/buyProgressionEventXpBoost")]
[HttpGet("api/storefronts/v1/buyProgressionEventXpBoost")]
public async Task<IActionResult> BuyProgressionEventXpBoost(
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))]
ProgressionEventXpBoostRequest req,
[FromQuery] int currencyType = 2)
{
var pid = Me;
var price = Math.Max(0, req.RequestedPrice);
var balance = await level.GetBalanceAsync(pid, currencyType);
if (balance < price) return Ok(XpBoostResponse(balance, currencyType, 0));
var newBalance = price == 0
? balance
: await level.GrantCurrencyAsync(pid, currencyType, -price,
$"progressionEventXpBoost:{req.ProgressionEventId}");
// The client tells us how much XP the boost it just paid for is worth;
// clamped so a spoofed field can't mint arbitrary levels.
var xp = Math.Clamp(req.ExpectedXp, 0, 100_000);
if (xp > 0)
await level.AwardXpAsync(pid, xp, $"progressionEventXpBoost:{req.ProgressionEventId}");
// Upsert: (PlayerId, Key) is unique, and a boost can legitimately be
// bought more than once — a blind Add threw
// "UNIQUE constraint failed" and turned the second purchase into a 500
// AFTER the currency had already been taken.
var progressKey = $"progressionEvent:{req.ProgressionEventId}:xpBoost:{req.PurchasableXpBoostId:N}";
var progress = await db.ObjectiveProgress
.FirstOrDefaultAsync(o => o.PlayerId == pid && o.Key == progressKey);
if (progress is null)
{
db.ObjectiveProgress.Add(new ObjectiveProgressEntity
{
PlayerId = pid,
Key = key,
Key = progressKey,
IsCompleted = true,
ClearedAt = DateTime.UtcNow,
});
await db.SaveChangesAsync();
}
return Ok(BalanceUpdateResponse(newBalance, currencyType, null, null));
}
[HttpPost("api/storefronts/v1/buyForFreeGiftButton")]
[HttpGet("api/storefronts/v1/buyForFreeGiftButton")]
public async Task<IActionResult> BuyForFreeGiftButton([FromQuery] int currencyType = 2, [FromQuery] int price = 0)
{
var pid = Me;
var balance = await level.GetBalanceAsync(pid, currencyType);
if (balance < price) return Ok(BalanceUpdateResponse(balance, currencyType));
var newBalance = price == 0
? balance
: await level.GrantCurrencyAsync(pid, currencyType, -price, "freeGiftButton");
db.GiftPackages.Add(new GiftPackageEntity
else
{
RecipientPlayerId = pid,
CurrencyType = 2,
Currency = 25,
Level = 1,
PackageVariant = "FreeGiftButton",
PackageMaterial = string.Empty,
IsValid = true,
SupportsCurrentPlatform = true,
});
progress.IsCompleted = true;
progress.ClearedAt = DateTime.UtcNow;
}
await db.SaveChangesAsync();
return Ok(BalanceUpdateResponse(newBalance, currencyType));
return Ok(XpBoostResponse(newBalance, currencyType, xp));
}
[HttpPost("api/storefronts/v1/buyProgressionEventXpBoost")]
[HttpGet("api/storefronts/v1/buyProgressionEventXpBoost")]
public async Task<IActionResult> BuyProgressionEventXpBoost([FromQuery] int currencyType = 2, [FromQuery] int price = 0)
/// <summary>Form body of <c>buyPurchaseReminder</c>, verified at
/// <c>RecNet.Runtime/DCFKEFHJAGC.txt:10203</c> (purchaseReminderId, Int32)
/// and :10215 (requestedPrice, Int64).</summary>
public sealed class PurchaseReminderRequest
{
public int PurchaseReminderId { get; set; }
public long RequestedPrice { get; set; }
}
/// <summary>POST <c>api/storefronts/v1/buyPurchaseReminder</c> — buying the
/// item a "you were looking at this" reminder popup advertises.
///
/// Was a stub returning <c>{Success:true}</c>; the client deserialises
/// <c>JNPGKDJJPPM</c> = {Balance:Int64, CurrencyType, BalanceType, Data:
/// List&lt;GiftPackage&gt;} (signature at <c>DCFKEFHJAGC.txt:9928</c>, keys at
/// <c>RecNet.Runtime/OLIHBGPDPHF.txt:319/338/362/386</c>), so every field
/// defaulted and the wallet read 0. Note this DTO really does spell its
/// third member <c>BalanceType</c>, unlike the BalanceUpdateResponse family
/// which renames the same property to <c>Platform</c>.
///
/// There is no reminder catalogue on a private server — the offer feed
/// (<c>reminder/currentTokenBundles/v2</c>) is genuinely empty — so the
/// reminder id is resolved as the StoreItems row it points at. Unknown ids
/// charge nothing and return an empty Data list.</summary>
[HttpPost("api/storefronts/v1/buyPurchaseReminder")]
[HttpGet("api/storefronts/v1/buyPurchaseReminder")]
public async Task<IActionResult> BuyPurchaseReminder(
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))]
PurchaseReminderRequest req,
[FromQuery] int currencyType = 2)
{
var pid = Me;
var balance = await level.GetBalanceAsync(pid, currencyType);
if (balance < price) return Ok(BalanceUpdateResponse(balance, currencyType));
var newBalance = price == 0
var item = await db.StoreItems
.FirstOrDefaultAsync(s => s.Id == req.PurchaseReminderId && s.IsActive);
if (item is null)
return Ok(PurchaseReminderResponse(balance, currencyType, null, null));
// Stale-price guard; 0 means the popup didn't quote a price.
if (req.RequestedPrice > 0 && req.RequestedPrice != item.Price)
return Ok(PurchaseReminderResponse(balance, currencyType, null, null));
if (balance < item.Price)
return Ok(PurchaseReminderResponse(balance, currencyType, null, null));
var newBalance = item.Price == 0
? balance
: await level.GrantCurrencyAsync(pid, currencyType, -price, "progressionEventXpBoost");
: await level.GrantCurrencyAsync(pid, currencyType, -item.Price,
$"buyPurchaseReminder:{item.Slug}");
var giftId = await GrantWithGiftBoxAsync(pid, item, currencyType);
db.ObjectiveProgress.Add(new ObjectiveProgressEntity
{
PlayerId = pid,
Key = $"progressionEvent:xpBoost:{Guid.NewGuid():N}",
Key = $"purchaseReminder:{req.PurchaseReminderId}",
IsCompleted = true,
ClearedAt = DateTime.UtcNow,
});
await db.SaveChangesAsync();
return Ok(BalanceUpdateResponse(newBalance, currencyType));
}
[HttpPost("api/storefronts/v1/buyPurchaseReminder")]
[HttpGet("api/storefronts/v1/buyPurchaseReminder")]
public async Task<IActionResult> BuyPurchaseReminder()
{
db.ObjectiveProgress.Add(new ObjectiveProgressEntity
{
PlayerId = Me,
Key = $"purchaseReminder:{Guid.NewGuid():N}",
IsCompleted = true,
ClearedAt = DateTime.UtcNow,
});
await db.SaveChangesAsync();
return Ok(new { Success = true });
return Ok(PurchaseReminderResponse(newBalance, currencyType, item.Slug, giftId));
}
// ── Helpers ──────────────────────────────────────────────────────────
@@ -438,6 +763,53 @@ public class StorefrontsBuyController(
// GiftPackageEntity.Id. The previous code used
// DateTime.UtcNow.Ticks as a fake id.
var giftId = giftPackageId ?? DateTime.UtcNow.Ticks;
return new
{
// BalanceResponseDTO required trio.
Balance = balance,
CurrencyType = currencyType,
// The 2023-03-21 build renames the base DTO's third member to
// "Platform" on the whole BalanceUpdateResponse family — its
// generated contract table lists exactly BalanceUpdates / Balance /
// CurrencyType / Platform and has NO "BalanceType"
// (RecNet.Runtime/KILGEFOENFN.txt:319/346/362/386, identical in
// BBEFAELEANL/IBAPHBCOBHL/ALGKCAKCFAJ/AIEJFLGKIGN). Emitting only
// BalanceType left Platform defaulted to 0 on the client. Both are
// shipped: the 2020.12 watch reads BalanceType and unknown members
// are ignored on both readers.
Platform = 0,
BalanceType = 0,
// PurchaseBalanceUpdateResponseDTO.BalanceUpdates =
// List<BalanceUpdateDTO<GiftPackage>>. Note the Data member here is
// a LIST (…_PurchaseBalanceUpdateResponseDTO`1_NestedType_PKKNEGNNABE.txt:83)
// — the plain BalanceUpdateResponseDTO family uses a single object.
BalanceUpdates = new[]
{
new
{
UpdateResponse = 0, // UpdateResponseTypes.Success
Data = grantedSlug is null
? Array.Empty<object>()
: new object[] { GiftPackageWire(giftId, currencyType, grantedSlug) },
},
},
};
}
/// <summary>One <c>FFFIMAGLKEG/FHMABOHAEED</c> gift package.
///
/// REQUIRED via Util.GetKey — KeyNotFoundException on miss. Verified keys at
/// NLEKGNENMCO_NestedType_LOCNECLOHCA.txt:792-946 and re-confirmed against
/// the 2023-03-21 contract table RecNet.Runtime/FKANCKLCCDI.txt:1051-1374:
/// Id, Message, FromPlayerId, AvatarItemType, AvatarItemDesc,
/// ConsumableItemDesc, EquipmentPrefabName, EquipmentModificationGuid,
/// Platform, PlatformsToSpawnOn, BalanceType, CurrencyType, Currency, Xp,
/// GiftRarity, GiftContext. Missing any → "Failed to download gifts:
/// Malformed Response" (and on a purchase path, the buy times out because
/// the response never deserializes).</summary>
private static object GiftPackageWire(long giftId, int currencyType, string? grantedSlug)
{
// Pull the GUID off the slug for wardrobe-* purchases so the
// watch's GiftPackage receives the AvatarItem GUID and can
// render the unlock animation. dorknet-* slugs leave the desc
@@ -460,17 +832,8 @@ public class StorefrontsBuyController(
}
}
var giftPackage = new
return new
{
// REQUIRED via Util.GetKey — KeyNotFoundException on miss.
// Verified keys at NLEKGNENMCO_NestedType_LOCNECLOHCA.txt:792-946:
// Id, Message, FromPlayerId, AvatarItemType, AvatarItemDesc,
// ConsumableItemDesc, EquipmentPrefabName,
// EquipmentModificationGuid, Platform, PlatformsToSpawnOn,
// BalanceType, CurrencyType, Currency, Xp, GiftRarity,
// GiftContext. Missing any → "Failed to download gifts:
// Malformed Response" (and on a purchase path, the buy
// times out because the response never deserializes).
Id = giftId,
Platform = 0,
PlatformsToSpawnOn = -1,
@@ -493,28 +856,142 @@ public class StorefrontsBuyController(
Consumed = false,
ErrorMessage = string.Empty,
};
}
/// <summary>Bare <c>BalanceResponseDTO</c> — {Balance, CurrencyType,
/// Platform} and nothing else, per the generated contract at
/// <c>RecNet.Runtime/PECGEJAAMHB.txt:255/274/298</c>. BalanceType is kept
/// alongside Platform for the 2020.12 watch, which reads that spelling.
/// </summary>
private static object BalanceResponse(long balance, int currencyType) => new
{
Balance = balance,
CurrencyType = currencyType,
Platform = 0,
BalanceType = 0,
};
/// <summary><c>BalanceUpdateResponseDTO`1&lt;PurchasedProgressionEventXpBoostDTO&gt;</c>.
/// Data is a SINGLE object here, not a list — see the BuyProgressionEventXpBoost
/// remarks for the ISIL citation.</summary>
private static object XpBoostResponse(long balance, int currencyType, int xp) => new
{
Balance = balance,
CurrencyType = currencyType,
Platform = 0,
BalanceType = 0,
BalanceUpdates = new[]
{
new
{
UpdateResponse = 0, // UpdateResponseTypes.Success
Data = new { Xp = xp },
},
},
};
/// <summary><c>JNPGKDJJPPM</c> — the buyPurchaseReminder response.
/// {Balance, CurrencyType, BalanceType, Data:List&lt;GiftPackage&gt;}
/// (<c>RecNet.Runtime/OLIHBGPDPHF.txt:319/338/362/386</c>). "Platform" is
/// emitted as an inert alias because this one DTO is the odd one out that
/// really does use "BalanceType".</summary>
private static object PurchaseReminderResponse(long balance, int currencyType,
string? grantedSlug, long? giftPackageId) => new
{
Balance = balance,
CurrencyType = currencyType,
BalanceType = 0,
Platform = 0,
Data = grantedSlug is null
? Array.Empty<object>()
: new[] { GiftPackageWire(giftPackageId ?? DateTime.UtcNow.Ticks, currencyType, grantedSlug) },
};
// InventionResponse.Status values, from the name-preserved enum
// ENOCILAAKGN in the 2023.06.21 dump
// (Recnet-old/dist/RecRoom-2023.06.21-steam/il2cppdump/dump.cs:1268793-1268840).
private const int InventionStatusSuccess = 0;
private const int InventionStatusInvalidParameters = 1;
private const int InventionStatusDoesNotExist = 7;
private const int InventionStatusPurchaseFailed = 30;
private static object InventionPurchaseResponse(int status,
InventionEntity? inv, InventionVersionEntity? version,
long balance, int currencyType)
{
object? inventionWire = inv is null ? null : InventionWire(inv);
object? versionWire = version is null ? null : InventionVersionWire(version);
return new
{
// BalanceResponseDTO required trio.
Balance = balance,
CurrencyType = currencyType,
BalanceType = 0,
// PurchaseBalanceUpdateResponseDTO.BalanceUpdates =
// List<BalanceUpdateDTO<GiftPackage>>.
BalanceUpdates = new[]
InventionResponse = new
{
new
{
UpdateResponse = 0, // UpdateResponseTypes.Success
Data = grantedSlug is null
? Array.Empty<object>()
: new object[] { giftPackage },
},
Status = status,
Invention = inventionWire,
InventionVersion = versionWire,
},
// InventionPurchaseResponseDTO/EPFIAEMPKJK is
// BalanceUpdateResponseDTO`1<Invention> — it extends
// BalanceResponseDTO (BalanceUpdates at +32) and its inner Data is
// an Invention, not a balance delta, so the list is left empty and
// the client takes the balance off the outer trio.
BalanceUpdateResponse = new
{
Balance = balance,
CurrencyType = currencyType,
Platform = 0,
BalanceType = 0,
BalanceUpdates = Array.Empty<object>(),
},
};
}
/// <summary>Local copy of InventionsController's invention wire shape —
/// that builder is private to its own controller, and the buyInvention
/// response has to carry the same object. Keep the two in sync; the field
/// list is documented there (RecNet.Runtime/IOEPPCKGBFL.txt:1447-1939 is the
/// 2023-03-21 key table).</summary>
private static object InventionWire(InventionEntity i) => new
{
InventionId = i.Id,
ReplicationId = string.IsNullOrEmpty(i.ReplicationId)
? Guid.Empty.ToString("D") : i.ReplicationId,
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,
};
/// <summary>Local copy of InventionsController's version wire shape.
/// The 2023-03-21 reader also looks for ChipsCost, CloudVariablesCost and
/// BlobHash (RecNet.Runtime/OLPHKLCPFEF.txt:786/810/858); none has a column
/// on InventionVersionEntity, so they are omitted rather than emitted as
/// permanent zeros/nulls.</summary>
private static object InventionVersionWire(InventionVersionEntity v) => new
{
v.InventionId,
ReplicationId = string.IsNullOrEmpty(v.ReplicationId)
? Guid.Empty.ToString("D") : v.ReplicationId,
v.VersionNumber,
v.InstantiationCost,
v.LightsCost,
v.BlobName,
};
private static object RoomKeyPurchaseResponse(RoomKeyStatus status, RoomKeyEntity? key, long balance) => new
{
RoomKeyResponse = RoomKeysController.RoomKeyResponse(status, key),
@@ -522,8 +999,111 @@ public class StorefrontsBuyController(
{
Balance = balance,
CurrencyType = 2,
Platform = 0,
BalanceType = 0,
BalanceUpdates = Array.Empty<object>(),
},
};
/// <summary><c>RoomKeyPurchaseWithCurrencyResponseDTO</c> — {Balance,
/// RoomKeyResponse} where Balance is the ROOM-currency balance row
/// (<c>RecNet.Runtime/EDMKBBPGJAB.txt:203/222</c>, member shape at
/// <c>DPNLANKOHON.txt:331-414</c>), matching what
/// RoomCurrenciesController already emits for the same row.</summary>
private static object RoomKeyCurrencyPurchaseResponse(
RoomKeyStatus status, RoomKeyEntity? key, long playerId, Guid currencyId,
RoomCurrencyBalanceEntity? row) => new
{
Balance = new
{
AccountId = (int)playerId,
CurrencyId = currencyId,
Balance = row?.Balance ?? 0L,
ModifiedAt = row is null
? DateTime.UtcNow
: DateTime.SpecifyKind(row.UpdatedAt, DateTimeKind.Utc),
},
RoomKeyResponse = RoomKeysController.RoomKeyResponse(status, key),
};
/// <summary>Grants a purchased StoreItems row. Wardrobe unlocks are a set,
/// so a multi-count press still yields one entry; everything else stacks in
/// PlayerInventory.</summary>
private async Task GrantPurchasedItemAsync(long pid, StoreItemEntity item, int count = 1)
{
if (StoreService.TryGetAvatarItemPayload(item.Slug, out _, out var avatarItemDesc))
{
await GrantWardrobeAsync(pid, StoreService.InventoryAvatarItemDesc(avatarItemDesc));
return;
}
for (var n = 0; n < Math.Max(1, count); n++)
await GrantInventoryAsync(pid, item.Slug);
}
/// <summary>Grants <paramref name="item"/> and inserts the matching
/// gift-box row, mirroring BuyItem: the watch pops a gift box after the
/// purchase and POSTs the package id to /api/avatar/v2/gifts/consume/, so
/// the id in the response has to be a real GiftPackageEntity.</summary>
private async Task<long> GrantWithGiftBoxAsync(long pid, StoreItemEntity item, int currencyType)
{
await GrantPurchasedItemAsync(pid, item);
StoreService.TryGetAvatarItemPayload(item.Slug, out var avatarItemType, out var avatarItemDesc);
StoreService.TryGetConsumableItemDesc(item.Slug, out var consumableItemDesc);
StoreService.TryGetEquipmentPayload(item.Slug, out var equipmentPrefabName, out var equipmentModificationGuid);
var inventoryAvatarDesc = StoreService.InventoryAvatarItemDesc(avatarItemDesc);
if (StoreService.TryGetHairDyePayload(item.Slug, out var hairDyeConsumableDesc, out var hairDyeColorGuid))
{
avatarItemType = 1;
inventoryAvatarDesc = hairDyeColorGuid;
consumableItemDesc = hairDyeConsumableDesc;
}
// Same 4-part "{guid},,," desc BuyItem ships — the bare guid makes the
// client's AvatarItem parser miss on unlock.
var giftWireDesc = string.IsNullOrEmpty(avatarItemDesc) ? inventoryAvatarDesc : avatarItemDesc;
var gift = new GiftPackageEntity
{
RecipientPlayerId = pid,
AvatarItemType = string.IsNullOrEmpty(giftWireDesc) ? null : avatarItemType,
AvatarItemDescOrHairDyeDesc = giftWireDesc,
ConsumableItemDesc = consumableItemDesc,
EquipmentPrefabName = equipmentPrefabName,
EquipmentModificationGuid = equipmentModificationGuid,
CurrencyType = currencyType,
Currency = 0,
Xp = 0,
Level = 1,
GiftContext = 0,
GiftRarity = 0,
Message = string.Empty,
Platform = -1,
PackageVariant = "Standard",
PackageMaterial = string.Empty,
Consumed = false,
IsValid = true,
SupportsCurrentPlatform = true,
};
db.GiftPackages.Add(gift);
await db.SaveChangesAsync();
return gift.Id;
}
/// <summary>Idempotently records a season-pass purchase. There is no
/// SeasonProgressEntity, so tier/elite state lives on ObjectiveProgress
/// under <c>seasonTier:{storefrontType}:{purchasableItemId}</c> and
/// <c>seasonElite:{storefrontType}</c> — the same table the season
/// objectives feed already uses.</summary>
private async Task RecordSeasonProgressAsync(long pid, string key)
{
if (await db.ObjectiveProgress.AnyAsync(o => o.PlayerId == pid && o.Key == key)) return;
db.ObjectiveProgress.Add(new ObjectiveProgressEntity
{
PlayerId = pid,
Key = key,
IsCompleted = true,
ClearedAt = DateTime.UtcNow,
});
await db.SaveChangesAsync();
}
}