mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
Refactor configuration endpoints and update client installation scripts
- Removed Photon configuration fields from dorknet-clientmod.json.template. - Added new backtrace and Azure Speech configuration endpoints in ConfigController. - Updated response format for IsCreationEnabled and IsRenderingEnabled endpoints in CustomAvatarItemsController. - Enhanced InfluencerPartnerProgramController to include additional player properties. - Implemented RoomKeepsakes endpoint in KeepsakesController to retrieve keepsakes by room ID. - Modified ReportsController to standardize response for Hile reporting. - Added PlayerDataForMe and RoomsRequiring endpoints in RoomsController for enhanced player data retrieval. - Introduced new dorm matchmaking methods in MatchController for player session management. - Updated MatchPlayerController to handle exclusive login functionality. - Revised install scripts to reflect changes in Photon AppId handling and patching process. - Added new patch-client.ps1 script for applying static byte patches to the Rec Room client.
This commit is contained in:
@@ -21,6 +21,102 @@ public class ConfigController(ConfigService configService) : ControllerBase
|
||||
[HttpGet("amplitude")]
|
||||
public ActionResult<object> GetAmplitude() => Ok(new { AmplitudeKey = string.Empty });
|
||||
|
||||
/// <summary>
|
||||
/// GET <c>api/config/v1/backtrace</c> — 2023 boot downloads this before
|
||||
/// audio/core initialization. Returning 404 leaves the static backtrace
|
||||
/// config null and later crashes <c>ConfigSettings</c> consumers. Keep
|
||||
/// crash upload/deobfuscation disabled locally while preserving the
|
||||
/// object shape and readable config keys the client inspects.
|
||||
/// </summary>
|
||||
[HttpGet("backtrace")]
|
||||
public ActionResult<object> GetBacktrace(
|
||||
[FromQuery(Name = "platformType")] string? platformType,
|
||||
[FromQuery(Name = "allocate")] bool? allocate)
|
||||
{
|
||||
var baseUrl = $"{Request.Scheme}://{Request.Host}";
|
||||
var submissionUrl = $"{baseUrl}/backtrace/submit";
|
||||
var minidumpSubmissionUrl = $"{baseUrl}/backtrace/minidump";
|
||||
var config = new Dictionary<string, object>
|
||||
{
|
||||
["Backtrace.SampleRate"] = 0.0,
|
||||
["Backtrace.ReportBudget"] = 0,
|
||||
["Backtrace.deobfuscationRate"] = 0.0,
|
||||
["Backtrace.Deobfuscate"] = false,
|
||||
["Backtrace.DeobfuscationUrl"] = string.Empty,
|
||||
["Backtrace.Enabled"] = false,
|
||||
["Backtrace.PlatformType"] = platformType ?? string.Empty,
|
||||
["Backtrace.Allocate"] = allocate ?? false,
|
||||
};
|
||||
|
||||
return Ok(new Dictionary<string, object>
|
||||
{
|
||||
["MaxMessagesPerMinute"] = 0,
|
||||
["MaxReportsPerMinute"] = 0,
|
||||
["SampleRate"] = 0.0,
|
||||
["ReportBudget"] = 0,
|
||||
["ErrorReportBudget"] = 0,
|
||||
["WarningReportBudget"] = 0,
|
||||
["LogReportBudget"] = 0,
|
||||
["SubmissionUrl"] = submissionUrl,
|
||||
["MinidumpSubmissionUrl"] = minidumpSubmissionUrl,
|
||||
["ServerUrl"] = submissionUrl,
|
||||
["DatabasePath"] = "backtrace/database",
|
||||
["Config"] = config,
|
||||
["maxMessagesPerMinute"] = 0,
|
||||
["maxReportsPerMinute"] = 0,
|
||||
["sampleRate"] = 0.0,
|
||||
["reportBudget"] = 0,
|
||||
["errorReportBudget"] = 0,
|
||||
["warningReportBudget"] = 0,
|
||||
["logReportBudget"] = 0,
|
||||
["submissionUrl"] = submissionUrl,
|
||||
["minidumpSubmissionUrl"] = minidumpSubmissionUrl,
|
||||
["serverUrl"] = submissionUrl,
|
||||
["databasePath"] = "backtrace/database",
|
||||
["config"] = config,
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("azurespeech")]
|
||||
public IActionResult GetAzureSpeech()
|
||||
{
|
||||
return Ok(new Dictionary<string, object>
|
||||
{
|
||||
["Enabled"] = false,
|
||||
["SpeechRecognitionEnabled"] = false,
|
||||
["SpeechSynthesisEnabled"] = false,
|
||||
["Region"] = string.Empty,
|
||||
["SubscriptionKey"] = string.Empty,
|
||||
["TokenEndpoint"] = string.Empty,
|
||||
["SpeechRecognitionEndpoint"] = string.Empty,
|
||||
["SpeechSynthesisEndpoint"] = string.Empty,
|
||||
["enabled"] = false,
|
||||
["speechRecognitionEnabled"] = false,
|
||||
["speechSynthesisEnabled"] = false,
|
||||
["region"] = string.Empty,
|
||||
["subscriptionKey"] = string.Empty,
|
||||
["tokenEndpoint"] = string.Empty,
|
||||
["speechRecognitionEndpoint"] = string.Empty,
|
||||
["speechSynthesisEndpoint"] = string.Empty,
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("/voice/config")]
|
||||
public IActionResult GetVoiceConfig()
|
||||
{
|
||||
return Ok(new Dictionary<string, object>
|
||||
{
|
||||
["ToxMod.Enabled"] = false,
|
||||
["ToxMod.SampleRate"] = 0.0,
|
||||
["ToxMod.SessionRecordingEnabled"] = false,
|
||||
["ToxMod.Endpoint"] = string.Empty,
|
||||
["ToxMod.ApiKey"] = string.Empty,
|
||||
["Enabled"] = false,
|
||||
["enabled"] = false,
|
||||
["toxModEnabled"] = false,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>GET <c>api/config/v1/freegiftbutton</c> — the
|
||||
/// 2020.12 client deserializes this endpoint as a raw Boolean.
|
||||
/// This server does not run the timed free-gift campaign, so the
|
||||
|
||||
@@ -187,11 +187,11 @@ public class CustomAvatarItemsController(
|
||||
|
||||
[HttpGet("isCreationEnabled")]
|
||||
[AllowAnonymous]
|
||||
public IActionResult IsCreationEnabled() => Ok(new { IsCreationEnabled = true, Enabled = true });
|
||||
public IActionResult IsCreationEnabled() => Content("true", "application/json");
|
||||
|
||||
[HttpGet("isRenderingEnabled")]
|
||||
[AllowAnonymous]
|
||||
public IActionResult IsRenderingEnabled() => Ok(new { IsRenderingEnabled = true, Enabled = true });
|
||||
public IActionResult IsRenderingEnabled() => Content("true", "application/json");
|
||||
|
||||
[HttpGet("minPriceForPublicItem")]
|
||||
[AllowAnonymous]
|
||||
|
||||
+8
-1
@@ -95,8 +95,15 @@ public class InfluencerPartnerProgramController(DorkNetDbContext db) : Controlle
|
||||
private static object ToInfluencerWire(PlayerEntity player) => new
|
||||
{
|
||||
AccountId = (int)player.Id,
|
||||
RawUsername = player.Username,
|
||||
player.Username,
|
||||
DisplayName = player.DisplayName,
|
||||
DisplayName = player.DisplayName ?? player.Username,
|
||||
ProfileImage = player.ProfileImageName ?? string.Empty,
|
||||
TreatAsJunior = false,
|
||||
HasBirthday = true,
|
||||
Platforms = 1,
|
||||
IsInfluencer = true,
|
||||
SupportedInfluencerId = (int?)null,
|
||||
LocalSupportedInfluencerId = (int?)null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -46,6 +46,25 @@ public class KeepsakesController(DorkNetDbContext db, DomainConfig domain) : Con
|
||||
return Ok(rows.Select(ToWire));
|
||||
}
|
||||
|
||||
[HttpGet("rooms/{roomId:long}")]
|
||||
public async Task<IActionResult> RoomKeepsakes(long roomId)
|
||||
{
|
||||
var roomExists = await db.Rooms.AsNoTracking().AnyAsync(r => r.Id == roomId);
|
||||
if (!roomExists) return NotFound();
|
||||
|
||||
var roomIdText = roomId.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
var rows = await db.Keepsakes
|
||||
.Where(k => k.Category == "room"
|
||||
&& (k.EventKey == $"room:{roomIdText}"
|
||||
|| k.EventKey == $"room/{roomIdText}"
|
||||
|| k.EventKey == roomIdText
|
||||
|| EF.Functions.Like(k.EventKey, $"room:{roomIdText}:%")
|
||||
|| EF.Functions.Like(k.EventKey, $"room/{roomIdText}/%")))
|
||||
.OrderByDescending(k => k.EarnedAt)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToWire));
|
||||
}
|
||||
|
||||
[HttpGet("globalconfig")]
|
||||
[AllowAnonymous]
|
||||
public IActionResult GlobalConfig() => Ok(new
|
||||
|
||||
@@ -202,7 +202,7 @@ public class ReportsController(DorkNetDbContext db) : ControllerBase
|
||||
private async Task<IActionResult> SaveHileAsync(string? rawDetail, int? type, long? gameSessionId)
|
||||
{
|
||||
var pid = this.CurrentPlayerId();
|
||||
if (pid is not long me) return Ok(new { success = true });
|
||||
if (pid is not long me) return HileResult();
|
||||
var detail = (rawDetail ?? string.Empty).Trim();
|
||||
if (type is int hileType)
|
||||
detail = detail.Length == 0 ? $"Type={hileType}" : $"Type={hileType}; {detail}";
|
||||
@@ -216,6 +216,13 @@ public class ReportsController(DorkNetDbContext db) : ControllerBase
|
||||
Category = "hile",
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { success = true });
|
||||
return HileResult();
|
||||
}
|
||||
|
||||
private static IActionResult HileResult() => new ContentResult
|
||||
{
|
||||
Content = "true",
|
||||
ContentType = "application/json",
|
||||
StatusCode = StatusCodes.Status200OK,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1532,6 +1532,144 @@ public class RoomsController(
|
||||
[HttpGet("api/rooms/v2/myrecent")]
|
||||
public IActionResult MyOtherTabs() => Ok(Array.Empty<object>());
|
||||
|
||||
[HttpGet("rooms/{roomId:long}/playerdata/me")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> PlayerDataForMe(long roomId)
|
||||
{
|
||||
var pid = CurrentPlayerId;
|
||||
if (pid is null) return Unauthorized();
|
||||
|
||||
var room = await db.Rooms.AsNoTracking()
|
||||
.Where(r => r.Id == roomId)
|
||||
.Select(r => new { r.Id, r.VisitCount, r.CheerCount, r.FavoriteCount })
|
||||
.FirstOrDefaultAsync();
|
||||
if (room is null) return NotFound();
|
||||
|
||||
var favorited = await db.RoomBookmarks
|
||||
.AnyAsync(b => b.RoomId == roomId && b.PlayerId == pid.Value);
|
||||
var cheered = await db.Cheers
|
||||
.AnyAsync(c => c.TargetRoomId == roomId && c.FromPlayerId == pid.Value);
|
||||
var visit = await db.RoomVisits.AsNoTracking()
|
||||
.Where(v => v.RoomId == roomId && v.PlayerId == pid.Value)
|
||||
.Select(v => new { v.VisitCount, v.FirstVisitAt, v.LastVisitAt })
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
RoomId = room.Id,
|
||||
PlayerId = pid.Value,
|
||||
Favorite = favorited,
|
||||
Favorited = favorited,
|
||||
IsFavorite = favorited,
|
||||
Cheer = cheered,
|
||||
Cheered = cheered,
|
||||
IsCheered = cheered,
|
||||
IsBookmarked = favorited,
|
||||
IsCheering = cheered,
|
||||
VisitCount = visit?.VisitCount ?? 0,
|
||||
FirstVisitedAt = visit?.FirstVisitAt,
|
||||
LastVisitedAt = visit?.LastVisitAt,
|
||||
RoomVisitCount = room.VisitCount,
|
||||
RoomCheerCount = room.CheerCount,
|
||||
RoomFavoriteCount = room.FavoriteCount,
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("rooms/requiring/{restriction}")]
|
||||
[HttpGet("roomserver/rooms/requiring/{restriction}")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> RoomsRequiring(string restriction)
|
||||
{
|
||||
var key = (restriction ?? string.Empty).Trim().TrimStart('#').ToLowerInvariant();
|
||||
if (key.Length == 0) return Ok(new List<object>());
|
||||
|
||||
IQueryable<RoomEntity> query = db.Rooms.AsNoTracking()
|
||||
.Where(r => !r.HiddenFromBrowse && r.State == 0);
|
||||
|
||||
query = key switch
|
||||
{
|
||||
"developer" or "studio" or "rrstudio" => query.Where(r =>
|
||||
r.IsStudioRoom
|
||||
|| r.IsRoomLinkedToRecRoomStudio
|
||||
|| EF.Functions.Like(r.TagsCsv, "%developer%")
|
||||
|| EF.Functions.Like(r.TagsCsv, "%studio%")),
|
||||
"rrplus" or "recroomplus" => query.Where(r =>
|
||||
EF.Functions.Like(r.TagsCsv, "%rrplus%")
|
||||
|| EF.Functions.Like(r.TagsCsv, "%recroomplus%")),
|
||||
_ => query.Where(r => EF.Functions.Like(r.TagsCsv, $"%{key}%")),
|
||||
};
|
||||
|
||||
var rows = await query
|
||||
.OrderByDescending(r => r.HotScore)
|
||||
.ThenBy(r => r.Name)
|
||||
.Take(100)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(RoomService.ToWireRoom).ToList());
|
||||
}
|
||||
|
||||
[HttpGet("rooms/curated_playlists")]
|
||||
[HttpGet("roomserver/rooms/curated_playlists")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> CuratedPlaylistsCompat()
|
||||
{
|
||||
var curated = await playlists.CuratedAsync();
|
||||
return Ok(curated.Select(BuildPlaylistUnionEntry).ToList());
|
||||
}
|
||||
|
||||
[HttpGet("clubhousesearch/mostactivenow")]
|
||||
[HttpGet("roomserver/clubhousesearch/mostactivenow")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> MostActiveClubhouses()
|
||||
{
|
||||
var rows = await db.Clubs.AsNoTracking()
|
||||
.Where(c => c.State == 0 && c.ClubhouseRoomId != null)
|
||||
.GroupJoin(
|
||||
db.ClubMemberships.AsNoTracking(),
|
||||
c => c.Id,
|
||||
m => m.ClubId,
|
||||
(club, memberships) => new
|
||||
{
|
||||
Club = club,
|
||||
MemberCount = memberships.Count(),
|
||||
})
|
||||
.Join(
|
||||
db.Rooms.AsNoTracking(),
|
||||
c => c.Club.ClubhouseRoomId!.Value,
|
||||
r => r.Id,
|
||||
(c, room) => new
|
||||
{
|
||||
c.Club,
|
||||
c.MemberCount,
|
||||
Room = room,
|
||||
})
|
||||
.OrderByDescending(x => x.MemberCount)
|
||||
.ThenByDescending(x => x.Room.HotScore)
|
||||
.Take(50)
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(rows.Select(x => new
|
||||
{
|
||||
ClubId = x.Club.Id,
|
||||
x.Club.Name,
|
||||
x.Club.Description,
|
||||
MainImageName = x.Club.ImageName,
|
||||
ImageName = x.Club.ImageName,
|
||||
State = x.Club.State,
|
||||
CreatorAccountId = x.Club.CreatorPlayerId,
|
||||
Category = x.Club.Category,
|
||||
Visibility = x.Club.Visibility,
|
||||
Joinability = x.Club.Joinability,
|
||||
x.Club.AllowJuniors,
|
||||
MemberCount = x.MemberCount,
|
||||
x.Club.IsRRO,
|
||||
x.Club.ClubhouseRoomId,
|
||||
x.Club.ClubType,
|
||||
Room = RoomService.ToWireRoom(x.Room),
|
||||
RoomId = x.Room.Id,
|
||||
RoomName = x.Room.Name,
|
||||
}).ToList());
|
||||
}
|
||||
|
||||
// ── Bookmark mutations ───────────────────────────────────────────────
|
||||
|
||||
[HttpPost("api/rooms/v1/bookmark/{roomId:long}")]
|
||||
|
||||
@@ -100,6 +100,11 @@ public class CdnController(
|
||||
// String.Concat("/invention/", filename). Same byte-fetch flow as
|
||||
// /room/, routed to S3 via BlobRouter on the filename.
|
||||
[Route("/invention/{*path:minlength(1)}")]
|
||||
// 2023 builds a CDN URL as `/sigs/<signature-set-id>` during boot
|
||||
// file-hash verification. The original Rec Room CDN no longer
|
||||
// serves the embedded 2023 id, so keep this on a dedicated path and
|
||||
// fail soft instead of letting the request 404.
|
||||
[Route("/sigs/{*path:minlength(1)}")]
|
||||
// ASP.NET route templates treat `[` / `]` as token markers
|
||||
// (e.g. `[controller]`). To use them as literals inside a regex
|
||||
// constraint they must be doubled — so `[^/]` becomes `[[^/]]`.
|
||||
@@ -266,6 +271,17 @@ public class CdnController(
|
||||
// ── CDN serve path (cdn.*, storage.*, data.*, apex) ────────────────
|
||||
private async Task<IActionResult> ServeCdn(string? path)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(path) &&
|
||||
path.StartsWith("sigs/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
logger.LogWarning("[cdn] signature set MISS host={Host} path={Path} -> empty signature payload",
|
||||
Request.Host.Host, path);
|
||||
var payload = Array.Empty<byte>();
|
||||
Response.Headers.CacheControl = "public, max-age=60";
|
||||
signatures.AddContentSignature(Response, payload);
|
||||
return new FileContentResult(payload, "application/octet-stream");
|
||||
}
|
||||
|
||||
if (string.Equals(path, "config/LoadingScreenTipData", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Tips live in the LoadingScreenTips DB table; admins
|
||||
|
||||
@@ -17,10 +17,12 @@ namespace DorkNet.Server.Controllers.Match;
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class MatchController(
|
||||
IConfiguration config,
|
||||
GameSessionService sessionService,
|
||||
PlayerPresenceService presence,
|
||||
PrivateInstanceService privateInstances,
|
||||
JoinTimeoutService joinTimeouts,
|
||||
RoomService rooms,
|
||||
DorkNetDbContext db,
|
||||
ILogger<MatchController> logger) : ControllerBase
|
||||
{
|
||||
@@ -84,6 +86,138 @@ public class MatchController(
|
||||
return Ok();
|
||||
}
|
||||
|
||||
[HttpPost("/matchmake/none")]
|
||||
public async Task<IActionResult> MatchmakeNone()
|
||||
{
|
||||
var pid = this.CurrentPlayerId() ?? 0;
|
||||
if (pid == 0) return Unauthorized();
|
||||
|
||||
var current = presence.GetRoom(pid) ?? await BuildDormRoomInstanceAsync(pid);
|
||||
presence.SetRoom(pid, current);
|
||||
presence.MarkActive(pid);
|
||||
logger.LogInformation("[matchmake-none] player={Player}", pid);
|
||||
return Ok(new MatchmakingResponseDto
|
||||
{
|
||||
ErrorCode = 0,
|
||||
RoomInstance = current,
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("/matchmake/dorm")]
|
||||
public async Task<IActionResult> MatchmakeDorm()
|
||||
{
|
||||
var pid = this.CurrentPlayerId() ?? 0;
|
||||
if (pid == 0) return Unauthorized();
|
||||
|
||||
var room = await BuildDormRoomInstanceAsync(pid);
|
||||
presence.SetRoom(pid, room);
|
||||
presence.MarkActive(pid);
|
||||
await RecordVisitAsync(pid, room.RoomId);
|
||||
logger.LogInformation(
|
||||
"[matchmake-dorm] player={Player} room={Room} instance={Instance} photon={Photon}",
|
||||
pid, room.RoomId, room.RoomInstanceId, room.PhotonRoomId);
|
||||
return Ok(new MatchmakingResponseDto
|
||||
{
|
||||
ErrorCode = 0,
|
||||
RoomInstance = room,
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<RoomInstanceDto> BuildDormRoomInstanceAsync(long playerId)
|
||||
{
|
||||
var personal = await rooms.EnsurePersonalDormAsync(playerId);
|
||||
var dataBlob = await rooms.ResolveDormDataBlobNameAsync(playerId, personal.Id);
|
||||
if (string.IsNullOrWhiteSpace(dataBlob))
|
||||
dataBlob = personal.CurrentDataBlobName;
|
||||
|
||||
var photonRegion = (config["Photon:CloudRegion"] ?? "us").ToLowerInvariant();
|
||||
var photonRoomId = $"^dormroom_p{playerId}";
|
||||
var dormInstanceName = await BuildDormInstanceNameAsync(playerId);
|
||||
|
||||
var dorm = await privateInstances.EnsureForDormAsync(
|
||||
ownerPlayerId: playerId,
|
||||
roomId: personal.Id,
|
||||
subRoomId: 0,
|
||||
baseName: dormInstanceName,
|
||||
location: personal.LocationReplicationId,
|
||||
dataBlob: dataBlob ?? string.Empty,
|
||||
photonRegion: photonRegion,
|
||||
maxCapacity: personal.MaxCapacity,
|
||||
photonRoomId: photonRoomId);
|
||||
|
||||
return new RoomInstanceDto
|
||||
{
|
||||
RoomInstanceId = dorm.InstanceId,
|
||||
RoomId = personal.Id,
|
||||
SubRoomId = 0,
|
||||
Location = personal.LocationReplicationId,
|
||||
PhotonRegionId = photonRegion,
|
||||
PhotonRoomId = photonRoomId,
|
||||
Name = dormInstanceName,
|
||||
MaxCapacity = personal.MaxCapacity,
|
||||
IsFull = false,
|
||||
IsPrivate = true,
|
||||
IsInProgress = true,
|
||||
DataBlob = dataBlob ?? string.Empty,
|
||||
EventId = 0,
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<string> BuildDormInstanceNameAsync(long ownerPlayerId)
|
||||
{
|
||||
var player = await db.Players.AsNoTracking()
|
||||
.Where(p => p.Id == ownerPlayerId)
|
||||
.Select(p => new { p.DisplayName, p.Username })
|
||||
.FirstOrDefaultAsync();
|
||||
var name = !string.IsNullOrWhiteSpace(player?.DisplayName)
|
||||
? player!.DisplayName
|
||||
: !string.IsNullOrWhiteSpace(player?.Username)
|
||||
? player!.Username
|
||||
: $"Player{ownerPlayerId}";
|
||||
var cleaned = new string(name
|
||||
.Select(ch => char.IsLetterOrDigit(ch) || ch == '_' || ch == '-' ? ch : '_')
|
||||
.ToArray()).Trim('_');
|
||||
return string.IsNullOrWhiteSpace(cleaned) ? $"Player{ownerPlayerId}_dorm" : $"{cleaned}_dorm";
|
||||
}
|
||||
|
||||
private async Task RecordVisitAsync(long playerId, long roomId)
|
||||
{
|
||||
var visit = await db.RoomVisits
|
||||
.FirstOrDefaultAsync(v => v.RoomId == roomId && v.PlayerId == playerId);
|
||||
var isFirst = visit is null;
|
||||
if (isFirst)
|
||||
{
|
||||
visit = new RoomVisitEntity
|
||||
{
|
||||
RoomId = roomId,
|
||||
PlayerId = playerId,
|
||||
FirstVisitAt = DateTime.UtcNow,
|
||||
};
|
||||
db.RoomVisits.Add(visit);
|
||||
}
|
||||
visit!.LastVisitAt = DateTime.UtcNow;
|
||||
visit.VisitCount += 1;
|
||||
|
||||
if (isFirst)
|
||||
{
|
||||
await db.Rooms
|
||||
.Where(r => r.Id == roomId)
|
||||
.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(r => r.VisitCount, r => r.VisitCount + 1)
|
||||
.SetProperty(r => r.VisitorCount, r => r.VisitorCount + 1)
|
||||
.SetProperty(r => r.HotScore, r => r.HotScore + 2.0));
|
||||
}
|
||||
else
|
||||
{
|
||||
await db.Rooms
|
||||
.Where(r => r.Id == roomId)
|
||||
.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(r => r.VisitCount, r => r.VisitCount + 1)
|
||||
.SetProperty(r => r.HotScore, r => r.HotScore + 0.25));
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
|
||||
/// <summary>PUT <c>/roominstance/{id}/inprogress</c> — fired by
|
||||
/// <c>RecNet.Matchmaking.SetGameInProgress(bool inProgress)</c>
|
||||
|
||||
@@ -38,6 +38,25 @@ public class MatchPlayerController(
|
||||
[HttpPost("/player/login")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
|
||||
public async Task<IActionResult> PlayerLogin([FromForm(Name = "LoginLock")] string? loginLock)
|
||||
=> await RecordLoginLockAsync(loginLock, "player-login");
|
||||
|
||||
/// <summary>
|
||||
/// POST <c>/player/exclusivelogin</c> — newer clients use this instead
|
||||
/// of <c>/player/login</c> and include a
|
||||
/// <c>TakeOverExclusiveSession</c> form value. The lock semantics are
|
||||
/// otherwise the same: the latest LoginLock wins for this account.
|
||||
/// </summary>
|
||||
[HttpPost("/player/exclusivelogin")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
|
||||
public async Task<IActionResult> PlayerExclusiveLogin(
|
||||
[FromForm(Name = "LoginLock")] string? loginLock,
|
||||
[FromForm(Name = "TakeOverExclusiveSession")] bool? takeOverExclusiveSession)
|
||||
=> await RecordLoginLockAsync(loginLock, "player-exclusive-login", takeOverExclusiveSession);
|
||||
|
||||
private async Task<IActionResult> RecordLoginLockAsync(
|
||||
string? loginLock,
|
||||
string logScope,
|
||||
bool? takeOverExclusiveSession = null)
|
||||
{
|
||||
var playerId = TryGetCurrentPlayerId();
|
||||
if (playerId != 0 && !string.IsNullOrWhiteSpace(loginLock))
|
||||
@@ -46,8 +65,8 @@ public class MatchPlayerController(
|
||||
if (replaced)
|
||||
{
|
||||
log.LogWarning(
|
||||
"[player-login] concurrent session for player={PlayerId}; kicking old session(s) via ModerationKick push",
|
||||
playerId);
|
||||
"[{Scope}] concurrent session for player={PlayerId}; takeOver={TakeOver}; kicking old session(s) via ModerationKick push",
|
||||
logScope, playerId, takeOverExclusiveSession);
|
||||
// The new session hasn't opened its SignalR socket yet
|
||||
// (negotiate fires AFTER /player/login on the watch's
|
||||
// boot sequence — verified in the live trace), so the
|
||||
|
||||
Reference in New Issue
Block a user