mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
Import december-2020-12-18 from private repo (scrubbed 2026-05-27)
Targets Rec Room 2020.12.18 client.
Re-import after adding:
- DorkNet.ClientMod/dorknet-clientmod.json.template — config schema
the launcher's manifest-driven patcher renders into UserData/.
- tools/build-patcher-zip.ps1 — produces the dorknet-clientpatch-{key}.zip
Release asset the launcher fetches.
- .github/workflows/release.yml — auto-publishes both Release assets
on v1-december-* tag push.
Scrubbed via maintainer-side scrub-for-public.ps1: same drop rules as
the prior import, plus build-patcher-zip.ps1 added to the tools/
allow-list so it survives the scrub.
This commit is contained in:
@@ -0,0 +1,227 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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}")]
|
||||
public async Task<ActionResult<Progression>> 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")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
|
||||
public async Task<ActionResult<List<Progression>>> GetProgressionBulk(
|
||||
[FromForm(Name = "Ids")] string? ids)
|
||||
{
|
||||
var idList = ParseIds(ids).Select(i => (long)i).ToList();
|
||||
if (idList.Count == 0) return Ok(new List<Progression>());
|
||||
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 Ok(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}")]
|
||||
public async Task<ActionResult<Reputation>> GetReputation(long accountId) =>
|
||||
Ok(await BuildReputationAsync(accountId));
|
||||
|
||||
[HttpPost("api/playerReputation/v1/bulk")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
|
||||
public async Task<ActionResult<List<Reputation>>> GetReputationBulk(
|
||||
[FromForm(Name = "Ids")] string? ids)
|
||||
{
|
||||
var idList = ParseIds(ids).Select(i => (long)i).ToList();
|
||||
var result = new List<Reputation>(idList.Count);
|
||||
foreach (var id in idList)
|
||||
result.Add(await BuildReputationAsync(id));
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>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.</summary>
|
||||
[HttpPost("api/playerReputation/v1/cheer/{playerId:long}")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult> 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();
|
||||
}
|
||||
|
||||
/// <summary>POST api/rooms/v1/cheer/{roomId} — cheer a room.
|
||||
/// Same idempotency rules as player cheers; bumps
|
||||
/// <see cref="RoomEntity.CheerCount"/> as a denormalised counter
|
||||
/// so the watch's "Hot" rooms feed doesn't have to JOIN-aggregate
|
||||
/// on every request.</summary>
|
||||
[HttpPost("api/rooms/v1/cheer/{roomId:long}")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult> 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<Reputation> 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,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// "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.
|
||||
/// </summary>
|
||||
private static IEnumerable<int> ParseIds(string? ids) =>
|
||||
string.IsNullOrWhiteSpace(ids)
|
||||
? Enumerable.Empty<int>()
|
||||
: ids.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(s => int.TryParse(s, out var v) ? v : 0)
|
||||
.Where(v => v != 0);
|
||||
|
||||
// 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<ModerationBlockDetail> GetModerationBlockDetails() =>
|
||||
Ok(new ModerationBlockDetail());
|
||||
}
|
||||
Reference in New Issue
Block a user