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;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.PlayerEvents;
///
/// 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 RecNet.Runtime/CBKANFIOBCF
/// (route literals at CBKANFIOBCF.txt:161-4156 ). Three wire types drive
/// nearly every handler here:
/// * HPIOAGDJHDH (PlayerEvent) — 17 keys, formatter
/// HPMMKFGDAEC.txt:1139-1518 .
/// * MDCBEPJCJPO (PlayerEvent + Tags:[{Tag,Type}] ) — formatter
/// CENEMCMGDKG.txt:1191-1586 , tag entry keys
/// PCMLHLIBLNJ.txt:191-218 .
/// * PHHAKLPGNGC ({PlayerEvent,Result,TagModifyResult} ) —
/// formatter EEAAGIJHLOJ.txt:267-326 , TagModifyResult keys
/// ALJPHDEAHBK.txt:191-218 . Every v2 create/edit/delete/field
/// mutation deserialises this wrapper, so a flat event body leaves the
/// client's PlayerEvent null after a save.
///
[ApiController]
[Authorize]
public class PlayerEventsController(
DorkNetDbContext db,
NotificationService notifications,
PlayerPresenceService presence) : ControllerBase
{
private long Me => this.RequireCurrentPlayerId();
/// PlayerEventResponseType.NotGoing — the one RSVP state that
/// does not count towards AttendeeCount.
private const int NotGoingResponse = 2;
// ── Browse ───────────────────────────────────────────────────────────
/// GET api/playerevents/v2 (and v1 ) — upcoming
/// events list, a BARE JSON array of PlayerEvent.
///
/// The 2023 watch's Events tab reads the unversioned path: HIHEFBMPOGC
/// returns List<HPIOAGDJHDH> and dispatches with verb 0
/// (CBKANFIOBCF.txt:1305 , route literal :1304 ). Only
/// [HttpPost] was registered on api/playerevents/v1 , so the
/// whole tab 405'd.
[AllowAnonymous]
[HttpGet("api/playerevents/v2")]
[HttpGet("api/playerevents/v1")]
public async Task ListUpcoming(
[FromQuery] int skip = 0, [FromQuery] int take = 50)
{
skip = Math.Max(0, skip);
take = Math.Clamp(take, 1, 100);
var rows = await db.PlayerEvents
.Where(e => e.EndsAt > DateTime.UtcNow)
.OrderBy(e => e.StartsAt)
.Skip(skip)
.Take(take)
.ToListAsync();
return Ok(await ToWireManyAsync(rows));
}
/// GET api/playerevents/v1|v2/{id} . The client calls this
/// three ways — plain (NLEFILEAMIP → HPIOAGDJHDH) and with
/// ?includeDetails= / ?clubId= (HLJBCGMONEH / OAKKDBLNKLG →
/// MDCBEPJCJPO, query keys at
/// CBKANFIOBCF_NestedType_DEIBMMABHIK.txt:86,99 ). Both DTOs are the
/// same object, the detail one just adds Tags , and the Utf8Json
/// formatters skip unknown keys — so we always emit the superset and no
/// branch is needed.
[AllowAnonymous]
[HttpGet("api/playerevents/v2/{eventId:long}")]
[HttpGet("api/playerevents/v1/{eventId:long}")]
public async Task GetOne(long eventId)
{
var ev = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == eventId);
if (ev is null) return NotFound();
return Ok(await ToWireOneAsync(ev));
}
/// GET api/playerevents/v1/search . Query keys are
/// query , sort and scheduleFilter
/// (CBKANFIOBCF_NestedType_IONCOJJGHNJ.txt:99,113,126 ); the latter
/// two are enum ordinals (PJBLEKMMACM / BJBLPLKMLBE).
[AllowAnonymous]
[HttpGet("api/playerevents/v1/search")]
public async Task 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 = ApplyScheduleFilter(db.PlayerEvents.AsQueryable(), scheduleFilter);
if (needle.Length > 0)
{
rows = rows.Where(e =>
e.Title.ToLower().Contains(needle) ||
e.Description.ToLower().Contains(needle));
}
var result = await ApplySort(rows, sort)
.Skip(skip)
.Take(take)
.ToListAsync();
return Ok(await ToWireManyAsync(result));
}
/// GET api/playerevents/v1/searchlive — "happening now".
/// Separate handler from because the response element
/// type differs: FIKHCEPDIBF returns List<CLDDIKOJMAM> , which
/// is the PlayerEvent object plus PlayerCount (Int32) and
/// IsFull (Boolean) — formatter PDIFGFKAMBG.txt:1267,1294 .
/// Head-count comes from the room's live GameSessions rows (their RoomId is
/// the numeric room id as a string, see
/// Services/GameSessionService.cs:91-96 ).
[AllowAnonymous]
[HttpGet("api/playerevents/v1/searchlive")]
public async Task 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]
[HttpGet("api/playerevents/v1/room/{roomId:long}")]
public async Task ForRoom(long roomId)
{
var rows = await db.PlayerEvents
.Where(e => e.RoomId == roomId && e.EndsAt > DateTime.UtcNow)
.OrderBy(e => e.StartsAt)
.Take(100)
.ToListAsync();
return Ok(await ToWireManyAsync(rows));
}
/// GET api/playerevents/v1/club/{clubId} . PHNEDJAPIKI
/// returns IOKLNPFOLGI — an OBJECT
/// {Events:[PlayerEvent],ContinuationToken:String}
/// (GHBIGGJKPFI.txt:203-238 ), not a bare array, and passes
/// take / continuationToken query params
/// (CBKANFIOBCF_NestedType_PKDDOCBDBIM.txt:80,89 ). 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).
[AllowAnonymous]
[HttpGet("api/playerevents/v1/club/{clubId:long}")]
public async Task ForClub(
long clubId,
[FromQuery] int? take = null,
[FromQuery] string? continuationToken = null)
{
var pageSize = Math.Clamp(take ?? 50, 1, 100);
var skip = int.TryParse(continuationToken, out var parsed) && parsed > 0 ? parsed : 0;
// 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);
return Ok(new
{
Events = await ToWireManyAsync(rows),
ContinuationToken = hasMore ? (skip + pageSize).ToString() : string.Empty,
});
}
/// GET api/playerevents/v1/clubs — events for a specific
/// set of clubs. IEGLMOOPLED appends one repeated id query value per
/// club (CBKANFIOBCF_NestedType_LKJFABHPIAN.txt:59 ); ignoring them
/// returned every club's events and the watch could not bucket the list.
///
[AllowAnonymous]
[HttpGet("api/playerevents/v1/clubs")]
public async Task ClubEvents([FromQuery] int take = 100)
{
take = Math.Clamp(take, 1, 200);
var clubIds = new List();
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
{
public List? PlayerEventIds { get; set; }
public List? EventIds { get; set; }
public List? Ids { get; set; }
}
/// POST api/playerevents/v1/bulk — verb 2 at
/// CBKANFIOBCF.txt:660 ; the form field is Ids
/// (CBKANFIOBCF_NestedType_PIKPJPBHOAC.txt:64 ). GET is kept for the
/// 2020 client, which puts the ids in the query string.
[AllowAnonymous]
[HttpPost("api/playerevents/v1/bulk")]
[HttpGet("api/playerevents/v1/bulk")]
public async Task Bulk()
{
var ids = await ReadEventIdsAsync();
if (ids.Count == 0) return Ok(Array.Empty());
var rows = await db.PlayerEvents
.Where(e => ids.Contains(e.Id))
.ToListAsync();
return Ok(await ToWireManyAsync(rows));
}
/// GET api/playerevents/v1/tagfilters — AKCLLEJNFFD reads
/// three String lists: PinnedFilters , PopularFilters and
/// TrendingFilters (HMDONHHKKGA.txt:279-346 ). A missing key
/// leaves the list null and the chips UI NREs when it enumerates.
[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" },
});
/// GET api/playerevents/v1/all — the watch's
/// LocalPlayerEventInfo fetch (caller's created + RSVP'd events).
/// PCELKKHNPHJ is {Created:[PlayerEvent], Responses:[COAGAJPELCG]}
/// (ENJDEONHNMO.txt:191-218 ) where each response entry is
/// {PlayerEvent, PlayerEventResponse} (HJKLIIPPPEM.txt:215-258 )
/// — a flat response row leaves PlayerEvent null and the My Events
/// page NREs.
[AllowAnonymous]
[HttpGet("api/playerevents/v1/all")]
public async Task AllPlayerEvents()
{
var pid = this.CurrentPlayerId();
if (pid is not long me)
return Ok(new { Created = Array.Empty(), Responses = Array.Empty() });
return await AllForPlayerAsync(me);
}
[AllowAnonymous]
[HttpGet("api/playerevents/v1/all/{accountId:long}")]
public Task AllPlayerEventsForAccount(long accountId)
=> AllForPlayerAsync(accountId);
private async Task AllForPlayerAsync(long playerId)
{
var created = await db.PlayerEvents
.Where(e => e.CreatorPlayerId == playerId)
.OrderByDescending(e => e.StartsAt)
.Take(200)
.ToListAsync();
var responses = await db.PlayerEventResponses
.Where(r => r.PlayerId == playerId)
.OrderByDescending(r => r.CreatedAt)
.Take(200)
.ToListAsync();
// 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();
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(),
});
}
/// GET api/playerevents/v1/{eventId}/responses — the event
/// detail page's attendee list. DFABBIDLDPE dispatches with verb 0
/// (CBKANFIOBCF.txt:1699 , route literal :1689 ) and reads
/// List<JPIKCIGABBI> ; the path was registered POST-only, so
/// the list 405'd.
[AllowAnonymous]
[HttpGet("api/playerevents/v1/{eventId:long}/responses")]
public async Task ResponsesForEvent(long eventId)
{
var rows = await db.PlayerEventResponses
.Where(r => r.EventId == eventId)
.OrderBy(r => r.CreatedAt)
.Take(500)
.ToListAsync();
return Ok(rows.Select(ResponseWire).ToList());
}
// ── Event instance browser ───────────────────────────────────────────
/// GET event/{eventId}/instances — the "which instance of
/// this event are my friends in" browser behind the event card's join
/// button. Served from the MATCHMAKING-HOST root (no api/ prefix),
/// which is why it lives on an absolute route here even though the feature
/// is a player-event one.
///
/// Binary evidence: Matchmaking+<GetEventInstanceBrowser>b__0
/// (Matchmaking_NestedType_CBMHJMNIHNN.txt:14 ) formats the literal
/// "event/{0}/instances" at :296 with [rdi+16] — the
/// PlayerEventId off the HPIOAGDJHDH the lambda closes over — then news up
/// BNDIAONDFFF with rdx = 0 at :124 , i.e.
/// BestHTTP.HTTPMethods.Get . The declared return type is
/// FGLDKEJLAKB<List<PNDCMIMEJLD>> , so the body is a BARE
/// JSON ARRAY. PNDCMIMEJLD's getters (PNDCMIMEJLD.txt:3-197 ) are, in
/// order, Int64/Int64/Int64/Boolean/DateTime/List<Int32>/String/Int32/
/// Boolean/String = RoomInstanceId, RoomId, SubRoomId, IsFull, CreatedAt,
/// PlayerIds, SubroomName, PlayerCount, HasModPresent, HashedInstanceId —
/// byte-for-byte the SimpleRoomInstance the room/{id}/instances
/// browser already returns (Matchmaking.txt:14237 issues the room
/// variant through the identical BNDIAONDFFF/verb-0 sequence). The last
/// four are filled in locally on the watch and are not read back off the
/// wire, so we emit the same six camelCase keys
/// (Controllers/Match/MatchController.cs:545-553 ); the 2023 Utf8Json
/// formatters carry a camelCase variant per key, so both client
/// generations parse it.
///
/// The event itself only supplies the room to browse: an event is pinned to
/// one RoomId, plus the SubRoomId the host picked in the v2 create/edit body
/// (persisted in ). Sub-room hops allocate their
/// own instance ids and photon rooms, so when the event names a sub-room we
/// only surface instances sitting in it.
[AllowAnonymous]
[HttpGet("/event/{eventId:long}/instances")]
public async Task EventInstances(long eventId)
{
var evt = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == eventId);
if (evt is null) return NotFound();
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
var subRoomId = extras.SubRoomId is long sub && sub > 0 ? sub : (long?)null;
// Same sourcing as the room browser: presence rows are the live truth
// (one instance per distinct PhotonRoomId), and the caller's OWN private
// instances are merged in so the host can still see the instance they
// created before anyone has joined it. Another player's private
// PhotonRoomId is never listed.
var pid = this.CurrentPlayerId() ?? 0;
var privates = pid == 0
? new List()
: await db.PrivateInstances
.Where(p => p.RoomId == evt.RoomId && p.OwnerPlayerId == pid)
.ToListAsync();
var byKey = new Dictionary(StringComparer.Ordinal);
foreach (var (room, playerIds) in presence.EnumerateActiveInstances(evt.RoomId))
{
if (string.IsNullOrEmpty(room.PhotonRoomId)) continue;
if (subRoomId is long want && room.SubRoomId != want) continue;
byKey[room.PhotonRoomId] = new
{
roomInstanceId = room.RoomInstanceId,
roomId = room.RoomId,
subRoomId = room.SubRoomId,
// MaxCapacity is the per-instance cap the matchmaker handed
// out, so this is a real answer rather than a constant false.
isFull = room.MaxCapacity > 0 && playerIds.Count >= room.MaxCapacity,
// Presence rows are a TTL cache with no creation timestamp —
// there is no stored "instance opened at" for a live instance,
// so report the observation time. Private-instance rows below
// do carry a real CreatedAt and use it.
createdAt = DateTime.UtcNow,
playerIds = playerIds.Select(p => (int)p).ToArray(),
};
}
foreach (var p in privates)
{
if (string.IsNullOrEmpty(p.PhotonRoomId) || byKey.ContainsKey(p.PhotonRoomId)) continue;
if (subRoomId is long want && p.SubRoomId != want) continue;
byKey[p.PhotonRoomId] = new
{
roomInstanceId = p.Id,
roomId = p.RoomId,
subRoomId = p.SubRoomId,
isFull = false,
createdAt = DateTime.SpecifyKind(p.CreatedAt, DateTimeKind.Utc),
playerIds = Array.Empty(),
};
}
return Ok(byKey.Values.ToList());
}
// ── Wire shape ───────────────────────────────────────────────────────
/// The PlayerEvent fields the 2023 client carries that
/// has no column for. Adding columns needs
/// a migration in Data/Entities , so these live in the PlayerSettings
/// side-table: one JSON row per event under
/// playerevent:{id}:wire , plus a flat playerevent:{id}:club
/// 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.
public sealed class EventExtras
{
public long? SubRoomId { get; set; }
public long? ClubId { get; set; }
public string ImageName { get; set; } = string.Empty;
/// 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.
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 Tags { get; set; } = [];
}
private static string ExtrasKey(long eventId) => $"playerevent:{eventId}:wire";
private static string ClubKey(long eventId) => $"playerevent:{eventId}:club";
/// Wire shape verified against the client's Utf8Json formatter
/// HPMMKFGDAEC.txt:1139-1518 (and the details variant
/// CENEMCMGDKG.txt:1191-1586 ): PascalCase keys PlayerEventId,
/// CreatorPlayerId, RoomId, SubRoomId, ClubId, Name, Description,
/// ImageName, StartTime, EndTime, AttendeeCount, Accessibility,
/// IsMultiInstance, SupportMultiInstanceRoomChat,
/// DefaultBroadcastPermissions, CanRequestBroadcastPermissions,
/// BroadcastingRoomInstanceId — property types read off the getters in
/// HPIOAGDJHDH.txt . Server-side we store some of these under other
/// names (Title, StartsAt, EndsAt), so remap explicitly.
private static Dictionary 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(),
};
/// JPIKCIGABBI — {PlayerEventResponseId, PlayerEventId,
/// PlayerId(Int32), CreatedAt, Type} , keys at
/// DDDLCGDEPNH.txt:395-502 , types off the getters in
/// JPIKCIGABBI.txt .
private static object ResponseWire(PlayerEventResponseEntity r) => new
{
PlayerEventResponseId = r.Id,
PlayerEventId = r.EventId,
PlayerId = (int)r.PlayerId,
r.CreatedAt,
Type = r.Response,
};
/// PHHAKLPGNGC — the wrapper every v2 mutation deserialises.
///
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 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>> ToWireManyAsync(IReadOnlyList 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>> ToWireMapAsync(
IReadOnlyList rows)
{
var wire = await ToWireManyAsync(rows);
var map = new Dictionary>();
for (var i = 0; i < rows.Count; i++) map[rows[i].Id] = wire[i];
return map;
}
private async Task> ToWireOneAsync(PlayerEventEntity ev)
=> (await ToWireManyAsync(new[] { ev }))[0];
private async Task> LoadExtrasAsync(IReadOnlyCollection events)
{
var map = new Dictionary();
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(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> AttendeeCountsAsync(IReadOnlyCollection eventIds)
{
if (eventIds.Count == 0) return new Dictionary();
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 ────────────────────────────────────────────────────
/// '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.
private static IQueryable ApplyScheduleFilter(
IQueryable 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),
};
}
/// 'sort' (PJBLEKMMACM) — same UNVERIFIED-ordinal caveat as
/// ; 0/absent keeps soonest-first.
private static IQueryable ApplySort(
IQueryable rows, int? sort) => sort switch
{
1 => rows.OrderByDescending(e => e.CreatedAt),
2 => rows.OrderBy(e => e.EndsAt),
_ => rows.OrderBy(e => e.StartsAt),
};
/// Events attached to any of . 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.
private async Task> ClubEventsAsync(
IReadOnlyCollection clubIds, int skip, int take)
{
var explicitIds = await EventIdsForClubsAsync(clubIds);
IQueryable 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> EventIdsForClubsAsync(IReadOnlyCollection 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> ClubRoomIdsAsync(IReadOnlyCollection 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) ───────────────────────────────────────────────
/// POST api/playerevents/v2 — the 2023 create-event flow.
/// KKMMBCDGFFN posts BIEFKAOABMP as a RawJsonForm body (verb 2 at
/// CBKANFIOBCF.txt:2390 ) whose 14 keys are
/// RoomId, SubRoomId, ClubId, Name, Description, Tags, ImageName,
/// StartTime, EndTime, Accessibility, IsMultiInstance,
/// SupportMultiInstanceRoomChat, DefaultBroadcastPermissions,
/// CanRequestBroadcastPermissions (ACKNGEEHHAE.txt:935-1234 ),
/// and reads the PHHAKLPGNGC wrapper back. Only [HttpGet] was
/// registered on this path, so creating an event 405'd.
[HttpPost("api/playerevents/v2")]
public async Task 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));
}
/// POST api/playerevents/v2/{eventId} — edit-event save.
/// IOEJKIPJFOI posts the same BIEFKAOABMP body with verb 2
/// (CBKANFIOBCF.txt:2595 , route literal :2579 ); the path was
/// GET-only, so editing 405'd.
[HttpPost("api/playerevents/v2/{eventId:long}")]
public async Task 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));
}
/// Copies whatever subset of the BIEFKAOABMP body is present onto
/// . Absent keys are left alone so the same helper
/// serves both create and edit.
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);
[HttpPost("api/playerevents/v1/create")]
[HttpPost("api/playerevents/v1")]
public async Task Create([FromBody] CreateEventRequest req)
{
if (string.IsNullOrWhiteSpace(req.Title))
return BadRequest("missing_title");
var ev = new PlayerEventEntity
{
CreatorPlayerId = Me,
RoomId = req.RoomId,
Title = req.Title.Trim(),
Description = (req.Description ?? string.Empty).Trim(),
StartsAt = req.StartsAt,
EndsAt = req.EndsAt > req.StartsAt ? req.EndsAt : req.StartsAt.AddHours(1),
Capacity = Math.Max(0, req.Capacity),
};
db.PlayerEvents.Add(ev);
await db.SaveChangesAsync();
return Ok(await ToWireOneAsync(ev));
}
public sealed record RsvpRequest(int Response);
[HttpPost("api/playerevents/v1/{eventId:long}/rsvp")]
public async Task Rsvp(long eventId, [FromBody] RsvpRequest body)
{
if (body.Response < 0 || body.Response > 2)
return BadRequest("invalid_response");
var ev = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == eventId);
if (ev is null) return NotFound();
var row = await db.PlayerEventResponses
.FirstOrDefaultAsync(r => r.EventId == eventId && r.PlayerId == Me);
if (row is null)
{
row = new PlayerEventResponseEntity { EventId = eventId, PlayerId = Me };
db.PlayerEventResponses.Add(row);
}
row.Response = body.Response;
await db.SaveChangesAsync();
// Tell the creator someone RSVP'd. They can ignore but the
// watch will refresh their event detail card.
if (ev.CreatorPlayerId != Me)
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 });
}
/// POST api/playerevents/v1/{eventId}/responses —
/// alternate RSVP route the 2020 watch uses for the response-list
/// flow (older / sub-room view). Body is the same RsvpRequest
/// as v1/{id}/rsvp ; behaves identically. The 2023 client GETs this
/// path instead — see .
[HttpPost("api/playerevents/v1/{eventId:long}/responses")]
public Task RespondList(long eventId, [FromBody] RsvpRequest body)
=> Rsvp(eventId, body);
/// POST api/playerevents/v1/respond — 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
/// recroom-2020-client-response-contracts.md .
/// * 2023-03 posts a RawJsonForm body {PlayerEventId, Type}
/// (NKIFBKJALEJ, keys at GFHNJMMBHFD.txt:203-238 ; body build at
/// CBKANFIOBCF_NestedType_NFCEOKLHDAG.txt:81-112 ) and reads
/// CEKABGOIOAF, an OBJECT {Result:int}
/// (GJJGDENPOAP.txt:139-150 ).
/// Branch the response shape on the body encoding the caller actually
/// used — that is the only signal that distinguishes them.
[HttpPost("api/playerevents/v1/respond")]
public async Task RespondForm()
{
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)
db.PlayerEventResponses.Add(new PlayerEventResponseEntity { PlayerId = Me, EventId = evt, Response = response });
else
existing.Response = response;
await db.SaveChangesAsync();
return RespondResult(legacyForm, 0 /*Success*/);
}
/// POST api/playerevents/v1/deleteResponse — drop the
/// caller's RSVP row. Same two-generation split as
/// . The 2023 body is
/// JsonUtility.ToJson(RecNet.Events.DeleteResponseRequest)
/// (CBKANFIOBCF_NestedType_MDJMHFHPOBO.txt:65-78 ) whose single field
/// is name-preserved: public long PlayerEventId
/// (2023.06.21 dump.cs:1291301 ).
[HttpPost("api/playerevents/v1/deleteResponse")]
public async Task DeleteResponseForm()
{
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 RespondResult(legacyForm, 0 /*Success*/);
}
private IActionResult RespondResult(bool legacyForm, int code)
=> legacyForm ? Ok(code) : Ok(new { Result = code });
/// DELETE/POST api/playerevents/v2/delete/{id} — hard
/// delete an event the caller created. GJMCEDALKJO dispatches with verb 2
/// (CBKANFIOBCF.txt:4166 , route literal :4156 ) and reads the
/// PHHAKLPGNGC wrapper, so POST plus the wrapper body are both required;
/// DELETE stays for the 2020 client.
[HttpPost("api/playerevents/v2/delete/{id:long}")]
[HttpDelete("api/playerevents/v2/delete/{id:long}")]
public async Task 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(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 UpdateName(long eventId)
{
var evt = await GetOwnedEventAsync(eventId);
if (evt is null) return NotFound();
var value = await ReadStringFieldAsync("name", "Name", "value");
if (string.IsNullOrWhiteSpace(value)) return BadRequest("missing_name");
evt.Title = Clamp(value.Trim(), 128);
evt.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(await WrapperAsync(evt));
}
[HttpPost("api/playerevents/v2/{eventId:long}/description")]
[HttpPut("api/playerevents/v2/{eventId:long}/description")]
public async Task UpdateDescription(long eventId)
{
var evt = await GetOwnedEventAsync(eventId);
if (evt is null) return NotFound();
var value = await ReadStringFieldAsync("description", "Description", "value") ?? string.Empty;
evt.Description = Clamp(value.Trim(), 2000);
evt.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(await WrapperAsync(evt));
}
/// PUT api/playerevents/v2/{id}/room — FPPNHCMPAKK sends
/// roomId AND an optional subRoomId
/// (CBKANFIOBCF_NestedType_AKPPDNLCACI.txt:88,101 ); dropping the
/// latter meant the event never pointed at the sub-room the host picked.
///
[HttpPost("api/playerevents/v2/{eventId:long}/room")]
[HttpPut("api/playerevents/v2/{eventId:long}/room")]
public async Task UpdateRoom(long eventId)
{
var evt = await GetOwnedEventAsync(eventId);
if (evt is null) return NotFound();
var roomId = await ReadLongFieldAsync("roomId", "RoomId", "value");
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;
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")]
[HttpPut("api/playerevents/v2/{eventId:long}/time")]
public async Task UpdateTime(long eventId)
{
var evt = await GetOwnedEventAsync(eventId);
if (evt is null) return NotFound();
var start = await ReadDateFieldAsync("startTime", "StartTime", "startsAt", "StartsAt");
var end = await ReadDateFieldAsync("endTime", "EndTime", "endsAt", "EndsAt");
if (start is DateTime s) evt.StartsAt = s;
if (end is DateTime e) evt.EndsAt = e > evt.StartsAt ? e : evt.StartsAt.AddHours(1);
if (start is null && end is null) return BadRequest("missing_time");
evt.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(await WrapperAsync(evt));
}
[HttpPost("api/playerevents/v2/{eventId:long}/image")]
[HttpPut("api/playerevents/v2/{eventId:long}/image")]
public async Task UpdateImage(long eventId)
{
var evt = await GetOwnedEventAsync(eventId);
if (evt is null) return NotFound();
var value = await ReadStringFieldAsync("imageName", "ImageName", "value") ?? string.Empty;
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")]
[HttpPut("api/playerevents/v2/{eventId:long}/accessibility")]
public async Task UpdateAccessibility(long eventId)
{
var evt = await GetOwnedEventAsync(eventId);
if (evt is null) return NotFound();
var value = await ReadIntFieldAsync("accessibility", "Accessibility", "value");
if (value is not int accessibility) return BadRequest("missing_accessibility");
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
extras.Accessibility = accessibility;
await SaveExtrasAsync(evt, extras);
return Ok(await WrapperAsync(evt));
}
/// PUT api/playerevents/v2/{id}/tags . JHLMDMGELFD does NOT
/// send form fields — it serialises List<String> straight into
/// a RawJsonForm body, i.e. a BARE JSON ARRAY
/// (CBKANFIOBCF_NestedType_PNFGKCKCGEL.txt:63-81 ). The old field
/// reader only looked inside JSON objects, so every save stored an empty
/// string and silently cleared the tags. The wrapper's
/// TagModifyResult.Tags is what the client re-reads afterwards.
///
[HttpPost("api/playerevents/v2/{eventId:long}/tags")]
[HttpPut("api/playerevents/v2/{eventId:long}/tags")]
public async Task UpdateTags(long eventId)
{
var evt = await GetOwnedEventAsync(eventId);
if (evt is null) return NotFound();
var tags = await ReadStringListFieldAsync("tags", "Tags", "value") ?? new List();
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
extras.Tags = tags;
await SaveExtrasAsync(evt, extras);
return Ok(await WrapperAsync(evt));
}
/// PUT api/playerevents/v2/{id}/multiinstance . IEGDKKAKHBD
/// sends FOUR form fields — isMultiInstance ,
/// supportsMultiInstanceRoomChat , defaultBroadcastPermissions ,
/// canRequestBroadcastPermissions
/// (CBKANFIOBCF_NestedType_EONGJBMLEFO.txt:122-161 ). The handler used
/// to look for a field named multiInstance , 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.
[HttpPost("api/playerevents/v2/{eventId:long}/multiinstance")]
[HttpPut("api/playerevents/v2/{eventId:long}/multiinstance")]
public async Task UpdateMultiInstance(long eventId)
{
var evt = await GetOwnedEventAsync(eventId);
if (evt is null) return NotFound();
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));
}
/// PUT api/playerevents/v2/{id}/club . JLHJHGANFDM takes a
/// Nullable<Int64> and boxes it into the single form field
/// clubId (CBKANFIOBCF_NestedType_DJLDOCJBFPO.txt:60-71 ) — 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.
[HttpPost("api/playerevents/v2/{eventId:long}/club")]
[HttpPut("api/playerevents/v2/{eventId:long}/club")]
public async Task 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 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}")]
public async Task Delete(long eventId)
{
var ev = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == eventId);
if (ev is null) return NotFound();
if (ev.CreatorPlayerId != Me) return Forbid();
db.PlayerEvents.Remove(ev);
// Cascade-clean the responses; SQLite has no FK cascade
// configured here so do it manually.
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 ──────────────────────────────────────────────────────
public sealed class BulkInviteRequest
{
public long PlayerEventId { get; set; }
public List? InvitedPlayerIds { get; set; }
}
/// POST /api/playerevents/v1/bulkInvite . Request is
/// JsonUtility.ToJson(RecNet.Events.BulkInviteRequest) —
/// name-preserved fields PlayerEventId / InvitedPlayerIds
/// (2023.06.21 dump.cs:1288323-1288324 , body build at
/// CBKANFIOBCF_NestedType_MIKAHKKCIEE.txt:69-96 ). Response is
/// IDELKAJIOLI {FailedInvites, Result}
/// (GAODOHODBDE.txt:203-238 ) where each failed entry is
/// {InvitedPlayerId, Result} (MOJLGDKFECO.txt:203-238 ) — the
/// entries used to be {PlayerId, Error} and deserialised as all
/// defaults. Error paths return the same JSON shape rather than a
/// NotFound()/Forbid() body the client cannot parse.
[HttpPost("api/playerevents/v1/bulkInvite")]
public async Task BulkInvite([FromBody] BulkInviteRequest req)
{
if (req?.InvitedPlayerIds is null || req.InvitedPlayerIds.Count == 0)
return Ok(new { FailedInvites = Array.Empty(), Result = 0 });
var ev = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == req.PlayerEventId);
// 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(), Result = 1 });
if (ev.CreatorPlayerId != Me) return Ok(new { FailedInvites = Array.Empty(), Result = 1 });
var failed = new List();
var sender = Me;
var inviteText = $"You're invited to '{ev.Title}' starting {ev.StartsAt:u}.";
foreach (var rid in req.InvitedPlayerIds.Distinct())
{
var exists = await db.Players.AnyAsync(p => p.Id == rid);
if (!exists)
{
failed.Add(new { InvitedPlayerId = rid, Result = 1 });
continue;
}
db.Messages.Add(new MessageEntity
{
SenderPlayerId = sender,
RecipientPlayerId = rid,
Body = inviteText,
});
await notifications.NotifyAsync(rid,
PushNotificationId.PlayerEventCreated,
new { ev.Id, ev.Title, ev.StartsAt, From = sender });
}
await db.SaveChangesAsync();
return Ok(new { FailedInvites = failed, Result = 0 });
}
/// POST api/playerevents/v1/broadcast — set or clear the
/// room instance an event is being broadcast from. JBHPMONELAO posts
/// RecNet.Events.BroadcastRoomInstanceRequest
/// (CBKANFIOBCF_NestedType_EHHMGKFGGCC.txt:81 ) whose JSON keys are
/// PlayerEventId and the nullable BroadcastRoomInstanceId
/// (EOFEKNIHNNC.txt:215-258 ), 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.
[HttpPost("api/playerevents/v1/broadcast")]
public async Task Broadcast()
{
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));
}
private async Task NotifyAttendeesAsync(PlayerEventEntity evt, PushNotificationId id)
{
var recipients = await db.PlayerEventResponses
.Where(r => r.EventId == evt.Id && r.Response != NotGoingResponse)
.Select(r => r.PlayerId)
.Distinct()
.ToListAsync();
foreach (var playerId in recipients.Where(p => p != evt.CreatorPlayerId))
await notifications.NotifyAsync(playerId, id, new { PlayerEventId = evt.Id, evt.Title });
}
// ── Report ───────────────────────────────────────────────────────────
public sealed class PlayerEventReportRequest
{
public int ReportCategory { get; set; }
public long PlayerEventId { get; set; }
public string? Details { get; set; }
}
/// POST api/playerevents/v1/report — file a
/// moderation report against an event. Wire response is
/// KLAMKCBENEA{Success, Message} ; returning a bare
/// {Reported:true} throws on the strict deserialiser.
[HttpPost("api/playerevents/v1/report")]
public async Task Report([FromBody] PlayerEventReportRequest req)
{
var ev = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == req.PlayerEventId);
if (ev is null) return Ok(new { Success = false, Message = "no_such_event" });
var msg = (req.Details ?? string.Empty).Trim();
db.Reports.Add(new ReportEntity
{
ReporterPlayerId = Me,
TargetPlayerId = ev.CreatorPlayerId,
TargetEventId = req.PlayerEventId,
Category = req.ReportCategory,
Message = msg[..Math.Min(1000, msg.Length)],
});
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 GetOwnedEventAsync(long eventId)
=> await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == eventId && e.CreatorPlayerId == Me);
private async Task> ReadEventIdsAsync()
{
var ids = new List();
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 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 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;
}
/// 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 Tags ), and repeated /
/// comma-joined form values.
private async Task?> 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();
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? 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 ElementToStrings(JsonElement array)
{
var list = new List();
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 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 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 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 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;
}
/// Everything the request carries, read once and cached — the body
/// stream can only be consumed a single time.
private sealed class ParsedBody
{
public Dictionary Fields { get; } = new(StringComparer.OrdinalIgnoreCase);
public Dictionary> Repeated { get; } = new(StringComparer.OrdinalIgnoreCase);
/// Set when the whole body is a bare JSON array — the shape
/// the tags PUT sends.
public List? RootArray { get; set; }
}
private const string BodyItemKey = "__playerevent_body";
private async Task 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;
}
}