using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Models.Notification;
using DorkNet.Models.Players;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.PlayerState;
///
/// api.rec.net endpoints that return player-state objects whose Deserialize
/// methods do strict required-key reads via Util.GetKey<T>. The previous
/// catch-all returned {} or {"Success":true} for these, which crashed the
/// client with KeyNotFoundException at:
/// RecNet.Progression.Deserialize → "PlayerId", "Level", "XP"
/// RecNet.Reputation.Deserialize → "AccountId", "Noteriety" (sic), Cheer*, Sub*
/// RecNet.ModerationBlockDetail.Deserialize → "ReportCategory", "Duration", "GameSessionId", "Message"
///
/// Each endpoint here returns the minimum-viable shape (zeros / empty
/// strings) that satisfies every required key. Real values can be plugged
/// in later when persistence is added for these.
///
/// Specific routes here win over the api/PlayerReporting/{*path} and
/// api.rec.net catch-alls.
///
[ApiController]
public class PlayerStateController(
DorkNetDbContext db,
NotificationService notifications,
LevelService level) : ControllerBase
{
// RecNet.Progressions.GetProgressionById / GetMyProgression — real
// values from PlayerEntity.Level / .XP. Default 1/0 when the
// account hasn't been seen yet (synthetic ids passed in queries).
[HttpGet("api/players/v1/progression/{accountId:long}")]
[HttpGet("api/players/v2/progression/{accountId:long}")]
public async Task> GetProgression(long accountId)
{
var p = await db.Players
.Where(p => p.Id == accountId)
.Select(p => new { p.Id, p.Level, p.XP })
.FirstOrDefaultAsync();
return Ok(new Progression
{
PlayerId = (int)(p?.Id ?? accountId),
Level = p?.Level ?? 1,
XP = p?.XP ?? 0,
});
}
[HttpPost("api/players/v1/progression/bulk")]
[HttpPost("api/players/v2/progression/bulk")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
public async Task>> GetProgressionBulk(
[FromForm(Name = "Ids")] string? ids)
{
var idList = ParseIds(ids).Select(i => (long)i).ToList();
return Ok(await BuildProgressionBulkAsync(idList));
}
[HttpGet("api/players/v2/progression/bulk")]
public async Task>> GetProgressionBulkV2Get()
{
var idList = ParseQueryIds().ToList();
return Ok(await BuildProgressionBulkAsync(idList));
}
private async Task> BuildProgressionBulkAsync(IReadOnlyCollection idList)
{
if (idList.Count == 0) return new List();
var rows = await db.Players
.Where(p => idList.Contains(p.Id))
.Select(p => new Progression
{
PlayerId = (int)p.Id,
Level = p.Level,
XP = p.XP,
})
.ToListAsync();
// Synthesise zeros for any ids the watch asked about that
// don't exist in our DB — keeps array length stable.
var found = rows.Select(r => r.PlayerId).ToHashSet();
rows.AddRange(idList
.Where(id => !found.Contains((int)id))
.Select(id => new Progression { PlayerId = (int)id }));
return rows;
}
// RecNet.Reputations.GetReputationById — aggregates CheerEntity
// by category. Each Cheer* count is the sum of distinct cheers
// received from other players in that category.
[HttpGet("api/playerReputation/v1/{accountId:long}")]
[HttpGet("api/playerReputation/v2/{accountId:long}")]
public async Task> GetReputation(long accountId) =>
Ok(await BuildReputationAsync(accountId));
[HttpPost("api/playerReputation/v1/bulk")]
[HttpPost("api/playerReputation/v2/bulk")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
public async Task>> GetReputationBulk(
[FromForm(Name = "Ids")] string? ids)
{
var idList = ParseIds(ids).Select(i => (long)i).ToList();
return Ok(await BuildReputationBulkAsync(idList));
}
[HttpGet("api/playerReputation/v2/bulk")]
public async Task>> GetReputationBulkV2Get()
{
var idList = ParseQueryIds().ToList();
return Ok(await BuildReputationBulkAsync(idList));
}
private async Task> BuildReputationBulkAsync(IReadOnlyCollection idList)
{
var result = new List(idList.Count);
foreach (var id in idList)
result.Add(await BuildReputationAsync(id));
return result;
}
/// POST api/playerReputation/v1/cheer/{playerId} — cheer
/// another player. Idempotent on (caller, target, type) so
/// repeat-clicking the same category doesn't inflate the count.
/// Pushes a presence-update notification so the target's watch
/// refreshes their cheer total.
[HttpPost("api/playerReputation/v1/cheer/{playerId:long}")]
[Authorize]
public async Task CheerPlayer(long playerId, [FromQuery] int type = 0)
{
var me = this.RequireCurrentPlayerId();
if (me == playerId) return BadRequest("cannot_cheer_self");
var existing = await db.Cheers.FirstOrDefaultAsync(c =>
c.FromPlayerId == me && c.TargetPlayerId == playerId &&
c.TargetRoomId == 0 && c.Type == type);
if (existing is not null) return Ok(new { already_cheered = true });
db.Cheers.Add(new CheerEntity
{
FromPlayerId = me,
TargetPlayerId = playerId,
Type = type,
});
await db.SaveChangesAsync();
// Reward the cheered player with a small XP bump.
await level.AwardXpAsync(playerId, LevelService.CheerReceivedXp, $"cheer_from:{me}");
await notifications.NotifyAsync(playerId,
PushNotificationId.SubscriptionUpdateProfile,
new { Reason = "CheerReceived", From = me, Type = type });
return Ok();
}
/// POST api/rooms/v1/cheer/{roomId} — cheer a room.
/// Same idempotency rules as player cheers; bumps
/// as a denormalised counter
/// so the watch's "Hot" rooms feed doesn't have to JOIN-aggregate
/// on every request.
[HttpPost("api/rooms/v1/cheer/{roomId:long}")]
[Authorize]
public async Task CheerRoom(long roomId, [FromQuery] int type = 0)
{
var me = this.RequireCurrentPlayerId();
var existing = await db.Cheers.FirstOrDefaultAsync(c =>
c.FromPlayerId == me && c.TargetRoomId == roomId &&
c.TargetPlayerId == 0 && c.Type == type);
if (existing is not null) return Ok(new { already_cheered = true });
db.Cheers.Add(new CheerEntity
{
FromPlayerId = me,
TargetRoomId = roomId,
Type = type,
});
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is not null)
{
room.CheerCount += 1;
// Cheers feed the Hot-tab ranking — weight 5x a first
// visit so the "Trending" feed reflects what players
// actually like, not just what they passed through.
room.HotScore += 10.0;
}
await db.SaveChangesAsync();
return Ok();
}
private async Task BuildReputationAsync(long accountId)
{
var rows = await db.Cheers
.Where(c => c.TargetPlayerId == accountId)
.GroupBy(c => c.Type)
.Select(g => new { Type = g.Key, Count = g.Count() })
.ToListAsync();
int Count(int type) => rows.FirstOrDefault(r => r.Type == type)?.Count ?? 0;
var subscriberCount = await db.Subscriptions
.CountAsync(s => s.TargetPlayerId == accountId);
var subscribedCount = await db.Subscriptions
.CountAsync(s => s.SubscriberPlayerId == accountId);
// SelectedCheer is the pinned badge category the player chose
// via PlayerCheerController.SetSelectedCheer. Stored as a
// PlayerSettingEntity with key "SelectedCheer" and Value = the
// category int as string. Null when unset — UI just shows no
// pinned badge.
var selected = await db.PlayerSettings
.Where(s => s.PlayerId == accountId && s.Key == "SelectedCheer")
.Select(s => s.Value)
.FirstOrDefaultAsync();
int? selectedCheer = int.TryParse(selected, out var sc) ? sc : null;
return new Reputation
{
AccountId = (int)accountId,
Notoriety = rows.Sum(r => r.Count),
CheerGeneral = Count(0),
CheerHelpful = Count(1),
CheerGreatHost = Count(2),
CheerSportsman = Count(3),
CheerCreative = Count(4),
CheerCredit = Count(5),
SelectedCheer = selectedCheer,
SubscriberCount = subscriberCount,
SubscribedCount = subscribedCount,
};
}
///
/// "1,2,3" → [1, 2, 3]. The client formats Ids as a comma-separated
/// string when calling AddField on the form. Tolerant of empty input
/// and whitespace.
///
private static IEnumerable ParseIds(string? ids) =>
string.IsNullOrWhiteSpace(ids)
? Enumerable.Empty()
: ids.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(s => int.TryParse(s, out var v) ? v : 0)
.Where(v => v != 0);
private IEnumerable ParseQueryIds()
{
foreach (var (key, values) in Request.Query)
{
if (long.TryParse(key, out var keyId) && keyId > 0)
yield return keyId;
foreach (var value in values)
{
if (long.TryParse(value, out var valueId) && valueId > 0)
yield return valueId;
foreach (var part in value.ToString().Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
if (long.TryParse(part, out var partId) && partId > 0)
yield return partId;
}
}
}
// RecNet.PlayerReporting.GetModerationBlockDetails
// IMPORTANT: returns a single object, not an array. Defaults indicate
// "user is not currently blocked / kicked".
// ASP.NET Core route matching is case-insensitive, so a single attribute
// covers both "PlayerReporting" and "playerreporting" callers.
[HttpGet("api/PlayerReporting/v1/moderationBlockDetails")]
public ActionResult GetModerationBlockDetails() =>
Ok(new ModerationBlockDetail());
}