feat: Enhance UgcPurchasablesController to handle bulk requests with object arrays

- Updated Bulk method to parse incoming JSON object arrays for UGC item IDs.
- Introduced ReadBulkIdsAsync to handle both object arrays and flat GUID lists.
- Added synthetic placeholder responses for missing UGC items to prevent client errors.

feat: Implement MatchmakeRoom and MatchmakeSubRoom endpoints in GoToController

- Added MatchmakeRoom and MatchmakeSubRoom methods to facilitate joining public and sub-rooms by numeric ID.
- Enhanced ApplySubRoomMutation to ensure proper state transitions for sub-rooms.
- Improved logging for matchmaking actions.

fix: Refactor PlayerSettingsController to handle database save exceptions

- Introduced SaveSettingsChangesAsync to manage DbUpdateException for busy SQLite scenarios.
- Updated methods to call SaveSettingsChangesAsync instead of direct SaveChangesAsync.

chore: Update URL coverage documentation for new matchmaking endpoints

- Documented new MatchmakeRoom and MatchmakeSubRoom endpoints in url-coverage.md.

chore: Add default room data file for room loading

- Included default_room.room to serve as a fallback for missing room data.

fix: Improve SQLite concurrency handling in DatabaseBootstrap

- Configured SQLite to use WAL mode and set a busy timeout for better concurrency.

chore: Create SqliteErrors utility for handling SQLite exceptions

- Added SqliteErrors class to encapsulate logic for detecting busy SQLite exceptions.

refactor: Update RoomDataBlobService to serve default room data

- Modified GetDefaultBlob to load and serve a default room blob with proper role data.
This commit is contained in:
Alexa
2026-06-23 20:54:29 +03:00
parent 27e5e0064c
commit 02f7bc386d
18 changed files with 1327 additions and 170 deletions
@@ -151,9 +151,9 @@ public class PlayerEventsController(
[AllowAnonymous]
[HttpPost("api/playerevents/v1/bulk")]
[HttpGet("api/playerevents/v1/bulk")]
public async Task<ActionResult> Bulk([FromBody] EventBulkRequest? body)
public async Task<ActionResult> Bulk()
{
var ids = await ReadEventIdsAsync(body);
var ids = await ReadEventIdsAsync();
if (ids.Count == 0) return Ok(Array.Empty<object>());
var rows = await db.PlayerEvents
.Where(e => ids.Contains(e.Id))
@@ -614,29 +614,73 @@ public class PlayerEventsController(
private async Task<PlayerEventEntity?> GetOwnedEventAsync(long eventId)
=> await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == eventId && e.CreatorPlayerId == Me);
private async Task<List<long>> ReadEventIdsAsync(EventBulkRequest? body)
private async Task<List<long>> ReadEventIdsAsync()
{
var ids = new List<long>();
if (body?.PlayerEventIds is { Count: > 0 }) ids.AddRange(body.PlayerEventIds);
if (body?.EventIds is { Count: > 0 }) ids.AddRange(body.EventIds);
if (body?.Ids is { Count: > 0 }) ids.AddRange(body.Ids);
foreach (var value in Request.Query.SelectMany(q => q.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);
AddDelimitedIds(ids, value);
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
foreach (var key in new[] { "playerEventIds", "PlayerEventIds", "eventIds", "EventIds", "ids", "Ids" })
foreach (var value in form[key])
foreach (var part in (value ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
if (long.TryParse(part, out var id) && id > 0) ids.Add(id);
AddDelimitedIds(ids, value);
}
else if ((Request.ContentLength ?? 0) > 0
&& Request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true)
{
try
{
using var doc = await JsonDocument.ParseAsync(Request.Body);
if (doc.RootElement.ValueKind == JsonValueKind.Object)
{
foreach (var key in new[] { "playerEventIds", "PlayerEventIds", "eventIds", "EventIds", "ids", "Ids" })
{
if (doc.RootElement.TryGetProperty(key, out var prop))
AddJsonIds(ids, prop);
}
}
else
{
AddJsonIds(ids, doc.RootElement);
}
}
catch (JsonException)
{
}
}
return ids.Distinct().Take(200).ToList();
}
private static void AddDelimitedIds(List<long> ids, string? value)
{
foreach (var part in (value ?? string.Empty)
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
if (long.TryParse(part, out var id) && id > 0) ids.Add(id);
}
}
private static void AddJsonIds(List<long> ids, JsonElement element)
{
switch (element.ValueKind)
{
case JsonValueKind.Array:
foreach (var item in element.EnumerateArray())
AddJsonIds(ids, item);
break;
case JsonValueKind.Number:
if (element.TryGetInt64(out var id) && id > 0) ids.Add(id);
break;
case JsonValueKind.String:
AddDelimitedIds(ids, element.GetString());
break;
}
}
private async Task<string?> ReadStringFieldAsync(params string[] names)
{
var fields = await ReadRequestFieldsAsync();
@@ -23,7 +23,7 @@ namespace DorkNet.Server.Controllers.API.PlayerReporting;
/// </summary>
[ApiController]
[Authorize]
public class ReportsController(DorkNetDbContext db) : ControllerBase
public class ReportsController(DorkNetDbContext db, ILogger<ReportsController> logger) : ControllerBase
{
[HttpGet("api/PlayerReporting/v1/voteToKickReasons")]
[AllowAnonymous]
@@ -215,7 +215,15 @@ public class ReportsController(DorkNetDbContext db) : ControllerBase
GameSessionId = gameSessionId ?? 0,
Category = "hile",
});
await db.SaveChangesAsync();
try
{
await db.SaveChangesAsync();
}
catch (DbUpdateException ex) when (SqliteErrors.IsBusy(ex))
{
db.ChangeTracker.Clear();
logger.LogWarning(ex, "[hile] dropping low-priority hile heartbeat because sqlite is busy");
}
return HileResult();
}
@@ -74,6 +74,38 @@ public class ProgressionEventsController(DorkNetDbContext db, ServerSettingsServ
[AllowAnonymous]
public IActionResult Active() => Ok(CurrentProgressionEventId());
/// <summary>
/// The current player's progress record for a given progression event.
/// The 2023 client fetches this during InitialRoomLoad
/// (<c>api/progressionEvents/record/{id}</c>, where <c>{id}</c> is the
/// <c>yyyyMMdd</c> event id). A 404 here faults the room-load coroutine
/// with a NullReferenceException and traps the client in a dorm
/// matchmaking loop, so we always return a non-null record.
/// </summary>
[HttpGet("api/progressionEvents/record/{progressionEventId:long}")]
[Authorize]
public async Task<IActionResult> Record(long progressionEventId)
{
var me = this.RequireCurrentPlayerId();
// Count this player's completed progression-event objectives so the
// record reflects real progress rather than a flat zero.
var completed = await db.ObjectiveProgress
.CountAsync(o => o.PlayerId == me
&& o.IsCompleted
&& o.Key.StartsWith("progressionEvent:"));
return Ok(new
{
ProgressionEventId = progressionEventId,
Xp = 0,
ClaimedRewardIndex = completed > 0 ? completed - 1 : -1,
PurchasedXpBoostCount = 0,
DailyBoostGameplayMinutes = 0,
XpBoostExpiresAt = (DateTime?)null,
});
}
private static long CurrentProgressionEventId()
{
var (start, _) = CurrentWeekWindow();
@@ -1184,32 +1184,16 @@ public class RoomsController(
// no DeserializeRoomDataBlobCoroutine, the master init
// proceeds with default-zero permissions.
//
// • Saved dorm / customisable rooms: DataBlobName is the
// latest uploaded blob name (RoomEntity.CurrentDataBlobName,
// written by StorageController on /upload). Unsaved dorms
// deliberately use "" so the baked DormRoom scene loads
// without deserializing the synthetic all-perms blob. The
// role-only default blob made December wait forever on
// StandaloneShapeContainer big-data during first-dorm entry.
// • AG-Original rooms: DataBlobName="" → completed-promise
// short-circuit → no download → no leaked stale-blob
// behaviour → master flow proceeds → spawn fires. Maker
// Pen stays locked, which matches public-server semantics
// (you can't edit the rec center).
//
// Heuristic: "customisable" iff it's the dorm OR a user-created
// room (CreatorPlayerId != 1, the seeded system account that
// owns AG-Originals like the rec center). User clones inherit
// the all-perms default blob until their first save replaces
// it via StorageController; the rec center / paintball / etc.
// serve "" so the persistence flow short-circuits.
// If a room has a saved blob, use it. Otherwise point at
// room_<id>_v1.dat, which misses S3 and is served by
// RoomDataBlobService.GetDefaultBlob. That default is a captured
// PersistedRoomData (v38); the older synthetic role-only blob and
// empty DataBlobName path both leave the 2023 room-permissions
// runtime without data it dereferences during spawn.
var roleList = roles ?? Array.Empty<RoomRoleEntity>();
var editableRro = room.IsAGRoom && roleList.Any(r => r.Accepted);
var customisable = room.IsDormRoom || room.CreatorPlayerId != 1 || editableRro;
var dataBlobName = !string.IsNullOrEmpty(room.CurrentDataBlobName)
? room.CurrentDataBlobName
: room.IsDormRoom ? ""
: customisable ? $"room_{room.Id}_v1.dat" : "";
: $"room_{room.Id}_v1.dat";
// Multi-scene rooms (imported via tools/import-room.ps1) carry one
// RoomSceneEntity row per sub-room. Emit the full Scenes[] array
@@ -1231,8 +1215,9 @@ public class RoomsController(
DataBlobName = !string.IsNullOrWhiteSpace(overrideDataBlobName) &&
(overrideSceneId == s.OrderIndex || sceneRows.Count == 1)
? overrideDataBlobName
: room.IsDormRoom ? dataBlobName
: s.DataBlobName,
: !string.IsNullOrWhiteSpace(s.DataBlobName)
? s.DataBlobName
: dataBlobName,
MaxPlayers = s.MaxPlayers,
CanMatchmakeInto = s.CanMatchmakeInto,
DataModifiedAt = (s.DataModifiedAt == default ? DateTime.UtcNow : s.DataModifiedAt)
@@ -1322,12 +1307,12 @@ public class RoomsController(
IReadOnlyList<RoomRoleEntity>? roles = null)
{
var roleList = roles ?? Array.Empty<RoomRoleEntity>();
var editableRro = room.IsAGRoom && roleList.Any(r => r.Accepted);
var customisable = room.IsDormRoom || room.CreatorPlayerId != 1 || editableRro;
// 2023's room-permissions runtime needs a real PersistedRoomData
// source during spawn. If no saved blob exists, use the captured
// default via the synthetic room_<id>_v1.dat CDN path.
var dataBlobName = !string.IsNullOrEmpty(room.CurrentDataBlobName)
? room.CurrentDataBlobName
: room.IsDormRoom ? ""
: customisable ? $"room_{room.Id}_v1.dat" : "";
: $"room_{room.Id}_v1.dat";
var updatedAt = (room.UpdatedAt == default ? DateTime.UtcNow : room.UpdatedAt)
.ToString("yyyy-MM-ddTHH:mm:ssZ");
@@ -1339,8 +1324,9 @@ public class RoomsController(
Name = s.Name,
DataBlob = !string.IsNullOrWhiteSpace(overrideDataBlobName) && sceneRows.Count == 1
? overrideDataBlobName
: room.IsDormRoom ? dataBlobName
: s.DataBlobName,
: !string.IsNullOrWhiteSpace(s.DataBlobName)
? s.DataBlobName
: dataBlobName,
DataSavedAt = (s.DataModifiedAt == default ? DateTime.UtcNow : s.DataModifiedAt)
.ToString("yyyy-MM-ddTHH:mm:ssZ"),
IsSandbox = s.IsSandbox,
@@ -1364,27 +1350,11 @@ public class RoomsController(
},
};
static int WireRole(int role) => role switch
{
0 => 30, // CoOwner
1 => 20, // Moderator
2 => 10, // Host
_ => 0,
};
var wireRoles = new List<object>
{
new { AccountId = room.CreatorPlayerId, Role = 255, InvitedRole = 0 },
BuildRoomAccountRoleWire(room.CreatorPlayerId, 255),
};
wireRoles.AddRange(roleList.Select(r =>
{
var role = WireRole(r.Role);
return (object)new
{
AccountId = r.PlayerId,
Role = r.Accepted ? role : 0,
InvitedRole = r.Accepted ? 0 : role,
};
}));
wireRoles.AddRange(roleList.Select(BuildRoomRoleGrantWire));
var tags = string.IsNullOrEmpty(room.TagsCsv)
? Array.Empty<object>()
@@ -2710,9 +2680,8 @@ public class RoomsController(
// ── Bare-path roles ────────────────────────────────────────────────
/// <summary>GET <c>rooms/{id}/roles</c> — accepted role grants on
/// the room. Owner is implicit (CreatorPlayerId) and surfaces under
/// Role=30; co-owner/mod/host rows come from RoomRoles.</summary>
/// <summary>GET <c>rooms/{id}/roles</c> — account role DTOs consumed by
/// 2023's EFHPLDPNGIM deserializer.</summary>
[HttpGet("rooms/{roomId:long}/roles")]
public async Task<IActionResult> RolesList(long roomId)
{
@@ -2721,15 +2690,9 @@ public class RoomsController(
var rows = await db.RoomRoles.Where(r => r.RoomId == roomId).ToListAsync();
var list = new List<object>(rows.Count + 1)
{
new { RoomId = roomId, PlayerId = (int)room.CreatorPlayerId, Role = 30 /*Owner*/, Accepted = true },
BuildRoomAccountRoleWire(room.CreatorPlayerId, 255),
};
list.AddRange(rows.Select(r => (object)new
{
RoomId = roomId,
PlayerId = (int)r.PlayerId,
Role = r.Role,
Accepted = r.Accepted,
}));
list.AddRange(rows.Select(BuildRoomRoleGrantWire));
return Ok(list);
}
@@ -2739,11 +2702,11 @@ public class RoomsController(
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound();
if (room.CreatorPlayerId == playerId)
return Ok(new { RoomId = roomId, PlayerId = (int)playerId, Role = 30, Accepted = true });
return Ok(BuildRoomAccountRoleWire(playerId, 255));
var row = await db.RoomRoles
.FirstOrDefaultAsync(r => r.RoomId == roomId && r.PlayerId == playerId);
if (row is null) return NotFound();
return Ok(new { RoomId = roomId, PlayerId = (int)playerId, Role = row.Role, Accepted = row.Accepted });
return Ok(BuildRoomRoleGrantWire(row));
}
public sealed class GrantRoleRequest { public int? Role { get; set; } }
@@ -2911,6 +2874,44 @@ public class RoomsController(
return roomRole >= 0;
}
private static object BuildRoomRoleGrantWire(RoomRoleEntity role)
=> BuildRoomAccountRoleWire(
role.PlayerId,
ToClientRoomRole(role.Role),
role.Accepted,
role.GrantedByPlayerId);
private static object BuildRoomAccountRoleWire(
long accountId,
int role,
bool accepted = true,
long? lastChangedByAccountId = null)
=> new
{
AccountId = ToClientAccountId(accountId),
Role = accepted ? role : 0,
LastChangedByAccountId = ToClientAccountId(lastChangedByAccountId),
InvitedRole = accepted ? 0 : role,
};
private static int ToClientRoomRole(int roomRole) => roomRole switch
{
0 => 30, // CoOwner
1 => 20, // Moderator
2 => 10, // Host
_ => 0,
};
private static int ToClientAccountId(long accountId)
=> accountId > int.MaxValue
? int.MaxValue
: accountId < int.MinValue
? int.MinValue
: (int)accountId;
private static int? ToClientAccountId(long? accountId)
=> accountId.HasValue ? ToClientAccountId(accountId.Value) : null;
private async Task<object> BuildRoomServerDetailsWithRolesAsync(long roomId)
{
var room = await db.Rooms.FirstAsync(r => r.Id == roomId);
@@ -100,15 +100,132 @@ public class UgcPurchasablesController(DorkNetDbContext db) : ControllerBase
[AllowAnonymous]
public async Task<IActionResult> Bulk()
{
var fields = await ReadFieldsAsync();
var ids = ReadGuidList(fields, "ids", "Ids", "itemIds", "ItemIds", "ugcPurchasableIds", "UgcPurchasableIds");
if (ids.Count == 0) return Ok(Array.Empty<object>());
// The 2023 client posts {"RoomId":N,"Ids":[{"itemType":1,
// "itemId":"<guid>"}]} — Ids is an array of OBJECTS, not flat guid
// strings. The generic field-flattener collapses each object to its
// JSON text, so the old ReadGuidList parsed ZERO guids and this
// endpoint always returned [] — the client's UGC resolver then
// re-queried the same ids forever (the growing-list flood in the
// server log), the referenced items never resolved, and the online
// player/avatar spawn null-reffed on an unresolved custom item
// (reportjoinresult FailedToSpawnPlayer → infinite dorm bounce).
var requested = await ReadBulkIdsAsync();
if (requested.Count == 0) return Ok(Array.Empty<object>());
var ids = requested.Select(r => r.Id).ToList();
var rows = await db.UgcPurchasables
.Where(i => !i.IsDeleted && ids.Contains(i.PublicId))
.ToListAsync();
return Ok(rows.Select(ToWire));
var byId = rows.ToDictionary(r => r.PublicId);
// Return an entry for EVERY requested id, in request order. Items not
// in our DB (custom UGC the player owns from real Rec Room that we
// never ingested) get a synthetic placeholder so the client treats
// the reference as resolved instead of spinning.
var result = requested
.Select(r => byId.TryGetValue(r.Id, out var row) ? ToWire(row) : SyntheticWire(r.Id, r.ItemType))
.ToList();
return Ok(result);
}
/// <summary>Parse the bulk request's item ids. Handles the 2023 client's
/// JSON object-array shape (<c>Ids:[{itemType,itemId}]</c>) as well as
/// flat guid lists in the body/form/query for older callers.</summary>
private async Task<List<(Guid Id, int ItemType)>> ReadBulkIdsAsync()
{
var list = new List<(Guid, int)>();
var seen = new HashSet<Guid>();
void Add(Guid id, int itemType)
{
if (id != Guid.Empty && seen.Add(id)) list.Add((id, itemType));
}
if ((Request.ContentLength ?? 0) > 0
&& Request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true)
{
try
{
using var doc = await JsonDocument.ParseAsync(Request.Body);
var root = doc.RootElement;
if (root.ValueKind == JsonValueKind.Object
&& (TryGetPropertyCI(root, "Ids", out var idsEl)
|| TryGetPropertyCI(root, "itemIds", out idsEl)
|| TryGetPropertyCI(root, "ugcPurchasableIds", out idsEl))
&& idsEl.ValueKind == JsonValueKind.Array)
{
foreach (var el in idsEl.EnumerateArray())
{
if (el.ValueKind == JsonValueKind.String && Guid.TryParse(el.GetString(), out var flat))
{
Add(flat, 0);
}
else if (el.ValueKind == JsonValueKind.Object)
{
var itemType = TryGetPropertyCI(el, "itemType", out var it) && it.TryGetInt32(out var n) ? n : 0;
if ((TryGetPropertyCI(el, "itemId", out var idEl)
|| TryGetPropertyCI(el, "id", out idEl)
|| TryGetPropertyCI(el, "ugcPurchasableId", out idEl))
&& Guid.TryParse(idEl.GetString(), out var guid))
{
Add(guid, itemType);
}
}
}
}
}
catch (JsonException)
{
}
}
// Fallback: flat guid lists in form/query (older callers).
if (list.Count == 0)
{
var fields = await ReadFieldsAsync();
foreach (var id in ReadGuidList(fields, "ids", "Ids", "itemIds", "ItemIds", "ugcPurchasableIds", "UgcPurchasableIds"))
Add(id, 0);
}
return list.Take(200).ToList();
}
private static bool TryGetPropertyCI(JsonElement obj, string name, out JsonElement value)
{
foreach (var prop in obj.EnumerateObject())
{
if (string.Equals(prop.Name, name, StringComparison.OrdinalIgnoreCase))
{
value = prop.Value;
return true;
}
}
value = default;
return false;
}
/// <summary>Minimal placeholder for a requested ugc item we don't have a
/// row for. Same wire shape as <see cref="ToWire"/> so the client's
/// deserializer resolves the reference; zero price / empty metadata.</summary>
private static object SyntheticWire(Guid id, int itemType) => new
{
UgcPurchasableId = id,
PurchasableItemId = id,
Id = id,
InternalId = 0L,
RoomId = 0L,
CreatorPlayerId = 0,
Name = string.Empty,
Description = string.Empty,
ImageName = string.Empty,
Price = 0,
CurrencyType = 2,
ItemType = itemType,
IsFeatured = false,
SortOrder = 0,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
};
private async Task<UgcPurchasableEntity?> FindItemAsync(Dictionary<string, string> fields)
{
var publicId = ReadGuid(fields, "ugcPurchasableId", "UgcPurchasableId", "itemId", "ItemId", "id", "Id");
@@ -215,19 +215,7 @@ public class GoToController(
// (roomInstanceId differs → reset state machine) plus "new
// photon match" (photonRoomId differs → leave/rejoin →
// SceneLoaded events that ultimately dismiss the dialog).
resp.RoomInstance.RoomInstanceId = resp.RoomInstance.RoomId * 100_000L + subId;
resp.RoomInstance.PhotonRoomId = $"{resp.RoomInstance.PhotonRoomId}_sub{subId}";
resp.RoomInstance.Name = subRoomName;
resp.RoomInstance.SubRoomId = subId;
if (subLocation is not null) resp.RoomInstance.Location = subLocation;
// dataBlob on the matchmaking response tells the client which
// PersistedRoomData blob to download for the entered scene.
// RoomInstance.Deserialize at RVA 0x114DAB0 reads it via the
// string-with-default reader (call 0x180EE6210), so empty is
// technically legal — but if it's empty the watch falls back
// to the parent room's RoomDataBlobName, which is the lobby's
// blob, so the sub-scene visually never changes.
if (!string.IsNullOrEmpty(subDataBlob)) resp.RoomInstance.DataBlob = subDataBlob;
ApplySubRoomMutation(resp, subId, subRoomName, subLocation, subDataBlob);
// Private/new-instance mutation goes AFTER sub-room mutation so the
// PhotonRoomId we register includes the _sub{N} suffix as its
@@ -240,6 +228,135 @@ public class GoToController(
return Ok(resp);
}
/// <summary>Mutate <paramref name="resp"/> in place so it represents
/// the given sub-scene of its parent room. Both PhotonRoomId AND
/// RoomInstanceId must differ from the lobby's or the watch's
/// transition state machine never fires:
/// • Same photon + same roomInstanceId → the new dataBlob downloads
/// but the watch never transitions; "Going to X" hangs forever.
/// • Different photon + same roomInstanceId → the watch sees the id
/// unchanged, treats the response as a no-op presence update and
/// skips the join flow entirely.
/// Folding subId into RoomInstanceId AND forking PhotonRoomId gives
/// the watch both signals it needs ("new instance" + "new photon
/// match"). dataBlob tells the client which PersistedRoomData to
/// download for the scene (RoomInstance.Deserialize, RVA 0x114DAB0,
/// reads it with a string-with-default reader so empty is legal) —
/// but an empty blob makes the watch fall back to the parent room's
/// lobby blob, so the sub-scene never changes visually. Only override
/// location/blob when we actually resolved one.</summary>
private static void ApplySubRoomMutation(
MatchmakingResponseDto resp, long subId, string subRoomName,
string? subLocation, string? subDataBlob)
{
resp.RoomInstance.RoomInstanceId = resp.RoomInstance.RoomId * 100_000L + subId;
resp.RoomInstance.PhotonRoomId = $"{resp.RoomInstance.PhotonRoomId}_sub{subId}";
resp.RoomInstance.Name = subRoomName;
resp.RoomInstance.SubRoomId = subId;
if (subLocation is not null) resp.RoomInstance.Location = subLocation;
if (!string.IsNullOrEmpty(subDataBlob)) resp.RoomInstance.DataBlob = subDataBlob;
}
/// <summary>POST <c>match.*/matchmake/room/{roomId}</c> — the 2023
/// client's primary "join this public room" call
/// (<c>RecNet.Matchmaking</c>, format string <c>"matchmake/room/{0}"</c>,
/// same sender <c>HHJFFJEHLOB</c> as the working <c>matchmake/dorm</c>,
/// so it's a POST returning <see cref="MatchmakingResponseDto"/>).
/// It navigates by NUMERIC roomId straight from the room tile, NOT by
/// name like the older <c>/goto/room/{name}</c> path. With no route
/// here every public-room join 404s and the watch bounces the player
/// back to their dorm ("Matchmaking request failed
/// (matchmake/room/N): HTTP Error 404"). We resolve the id to its
/// room and reuse <see cref="BuildResponseAsync"/> so dorm/private/
/// dataBlob/JoinMode handling stays identical to <see cref="GoToRoom"/>.
/// GET is accepted too as cheap insurance against a verb mismatch.</summary>
[HttpPost("/matchmake/room/{roomId:long}")]
[HttpGet("/matchmake/room/{roomId:long}")]
public async Task<ActionResult<MatchmakingResponseDto>> MatchmakeRoom(
long roomId,
[FromForm(Name = "CreatePrivateInstance")] bool createPrivateInstance = false,
[FromForm(Name = "JoinMode")] int joinMode = 0,
[FromForm(Name = "AdditionalPlayerIds")] List<long>? additionalPlayerIds = null)
{
var room = await rooms.GetByIdAsync(roomId);
if (room is null)
{
logger.LogWarning("[matchmake-room] unknown roomId={RoomId} — returning RoomDoesNotExist", roomId);
// 4 = RoomDoesNotExist (CreateModifyRoomStatus); watch shows
// its standard "room not found" toast instead of softlocking.
return Ok(new MatchmakingResponseDto { ErrorCode = 4, RoomInstance = null });
}
var resp = await BuildResponseAsync(room.Name);
await ApplyNewInstanceAsync(resp, subRoomId: 0, createPrivateInstance, joinMode);
await RecordResponseAsync(resp);
await NotifyAdditionalPlayersAsync(additionalPlayerIds, resp);
logger.LogInformation(
"[matchmake-room] player={Player} roomId={RoomId} name={Name} instance={Instance} photon={Photon}",
this.CurrentPlayerId(), roomId, room.Name,
resp.RoomInstance?.RoomInstanceId, resp.RoomInstance?.PhotonRoomId);
return Ok(resp);
}
/// <summary>POST <c>match.*/matchmake/room/{roomId}/{subRoomId}</c> —
/// sub-room form (<c>RecNet.Matchmaking</c> "matchmake/room/{0}/{1}").
/// Unlike <see cref="GoToSubRoom"/> (which takes a sub-room NAME and
/// fuzzy-matches it) the sub-room arrives here as a numeric
/// <see cref="RoomSceneEntity.OrderIndex"/>, so we resolve the scene
/// row by OrderIndex directly. subRoomId 0 is the lobby/entry scene —
/// the base response already represents it, so we skip the
/// mutation.</summary>
[HttpPost("/matchmake/room/{roomId:long}/{subRoomId:long}")]
[HttpGet("/matchmake/room/{roomId:long}/{subRoomId:long}")]
public async Task<ActionResult<MatchmakingResponseDto>> MatchmakeSubRoom(
long roomId, long subRoomId,
[FromForm(Name = "CreatePrivateInstance")] bool createPrivateInstance = false,
[FromForm(Name = "JoinMode")] int joinMode = 0,
[FromForm(Name = "AdditionalPlayerIds")] List<long>? additionalPlayerIds = null)
{
var room = await rooms.GetByIdAsync(roomId);
if (room is null)
{
logger.LogWarning(
"[matchmake-room] unknown roomId={RoomId} (sub={Sub}) — returning RoomDoesNotExist",
roomId, subRoomId);
return Ok(new MatchmakingResponseDto { ErrorCode = 4, RoomInstance = null });
}
var resp = await BuildResponseAsync(room.Name);
if (subRoomId != 0)
{
var scene = await db.RoomScenes.AsNoTracking()
.Where(s => s.RoomId == resp.RoomInstance.RoomId && s.OrderIndex == subRoomId)
.Select(s => new { s.Name, s.RoomSceneLocationId, s.DataBlobName })
.FirstOrDefaultAsync();
if (scene is not null)
{
logger.LogInformation(
"[matchmake-subroom] {Room}/{Sub} → scene '{Name}' loc={Loc} blob={Blob}",
roomId, subRoomId, scene.Name, scene.RoomSceneLocationId, scene.DataBlobName);
ApplySubRoomMutation(resp, subRoomId, scene.Name, scene.RoomSceneLocationId, scene.DataBlobName);
}
else
{
logger.LogWarning(
"[matchmake-subroom] {Room}/{Sub} — no RoomScenes row with that OrderIndex; " +
"forking ids onto the lobby scene so the watch still transitions",
roomId, subRoomId);
// Reuse the lobby location/blob (sub-scene won't differ
// visually) but still fold subId in so the watch leaves
// the current match instead of softlocking.
ApplySubRoomMutation(resp, subRoomId, resp.RoomInstance.Name, null, null);
}
}
await ApplyNewInstanceAsync(resp, subRoomId: subRoomId, createPrivateInstance, joinMode);
await RecordResponseAsync(resp);
await NotifyAdditionalPlayersAsync(additionalPlayerIds, resp);
return Ok(resp);
}
/// <summary>Per-sub-room scene location overrides for baked
/// AG-Original rooms with multiple scenes. Each entry maps a
/// <c>"{room}/{subRoom}"</c> key (lowercase) to the
@@ -1172,10 +1289,7 @@ public class GoToController(
if (!string.IsNullOrWhiteSpace(room.CurrentDataBlobName)) return room.CurrentDataBlobName;
if (room.IsDormRoom) return string.Empty;
var customisable = room.CreatorPlayerId != 1;
return customisable ? $"room_{room.Id}_v1.dat" : string.Empty;
return $"room_{room.Id}_v1.dat";
}
/// <summary>
@@ -434,10 +434,7 @@ public class MatchPlayerController(
if (!string.IsNullOrWhiteSpace(room.CurrentDataBlobName)) return room.CurrentDataBlobName;
if (room.IsDormRoom) return string.Empty;
var customisable = room.CreatorPlayerId != 1;
return customisable ? $"room_{room.Id}_v1.dat" : string.Empty;
return $"room_{room.Id}_v1.dat";
}
}
@@ -14,7 +14,7 @@ namespace DorkNet.Server.Controllers.PlayerSettings;
/// </summary>
[ApiController]
[Authorize]
public class PlayerSettingsController(DorkNetDbContext db) : ControllerBase
public class PlayerSettingsController(DorkNetDbContext db, ILogger<PlayerSettingsController> logger) : ControllerBase
{
private long CurrentPlayerId => this.RequireCurrentPlayerId();
@@ -64,7 +64,7 @@ public class PlayerSettingsController(DorkNetDbContext db) : ControllerBase
foreach (var (key, value) in updates)
await UpsertSettingAsync(accountId, key, value, save: false);
await db.SaveChangesAsync();
await SaveSettingsChangesAsync();
return Ok();
}
@@ -78,7 +78,7 @@ public class PlayerSettingsController(DorkNetDbContext db) : ControllerBase
if (setting is null) return NotFound();
db.PlayerSettings.Remove(setting);
await db.SaveChangesAsync();
await SaveSettingsChangesAsync();
return Ok();
}
@@ -91,7 +91,20 @@ public class PlayerSettingsController(DorkNetDbContext db) : ControllerBase
else
existing.Value = value;
if (save) await db.SaveChangesAsync();
if (save) await SaveSettingsChangesAsync();
}
private async Task SaveSettingsChangesAsync()
{
try
{
await db.SaveChangesAsync();
}
catch (DbUpdateException ex) when (SqliteErrors.IsBusy(ex))
{
db.ChangeTracker.Clear();
logger.LogWarning(ex, "[playersettings] dropping low-priority setting write because sqlite is busy");
}
}
private async Task<(string? Key, string? Value)> ReadSettingKeyValueAsync()