diff --git a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs index 7b4c19c..2c7b005 100644 --- a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs +++ b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs @@ -1051,7 +1051,11 @@ public class RoomsController( EncryptVoiceChat = currentPresence.EncryptVoiceChat, }, isOnline = true, - appVersion = 20201210, + // 2023.03.21 client build version (baked "20230317"). MUST match + // the joining client's own version or it reports a version + // mismatch and refuses to join. Was a stale December-2020 value + // (20201210) carried over when this branch forked. + appVersion = 20230317, }; await notifications.NotifyAsync(playerId, PushNotificationId.SubscriptionUpdatePresence, presencePayload); } @@ -3196,8 +3200,17 @@ public class RoomsController( public string? Filename { get; set; } } + // The "Max Player Count in This Subroom" slider (SubroomModel. + // ChangeSubRoomMaxPlayers) POSTs here on the roomserver host. Like its + // siblings (accessibility/permissions/modify) the client prefixes the + // path with "roomserver/", so the bare-only routes 404'd and the slider's + // change silently never persisted — i.e. the limit couldn't be changed. + // The value flows back to the host via the subroom details MaxPlayers, + // which sets the Photon room cap. [HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/maxplayers")] [HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/maxplayers")] + [HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/maxplayers")] + [HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/maxplayers")] [Authorize] public Task SubRoomMaxPlayers(long roomId, long subRoomId, [FromBody] SubRoomIntRequest req) => MutateScene(roomId, subRoomId, s => { if (req.Value is int v) s.MaxPlayers = Math.Max(1, v); }); diff --git a/DorkNet.Server/Controllers/Commerce/CommerceCatalogController.cs b/DorkNet.Server/Controllers/Commerce/CommerceCatalogController.cs index e9a9830..00a9ce3 100644 --- a/DorkNet.Server/Controllers/Commerce/CommerceCatalogController.cs +++ b/DorkNet.Server/Controllers/Commerce/CommerceCatalogController.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Mvc; using DorkNet.Server.Services; +using DorkNet.Server.Controllers.API.Avatar.V4; namespace DorkNet.Server.Controllers.Commerce; @@ -85,6 +86,31 @@ public class CommerceCatalogController(StoreService store, DomainConfig domain) { var available = i.IsActive && (i.AvailableUntil == null || i.AvailableUntil > DateTime.UtcNow); var imageUrl = string.IsNullOrEmpty(i.ImageName) ? "" : $"https://cdn.{apex}/{i.ImageName}"; + + // Avatar-item linkage — THE thing that makes wardrobe items appear in + // the Shop's Clothing tab. The 2023 client does NOT categorize a SKU by + // any field on the SKU itself; instead StoreItemListModel resolves the + // SKU to the client's baked AvatarItem (by GUID) and reads that item's + // OutfitType to place it in a tab. With no AvatarItemId the resolve + // returns OutfitType = -1, the item matches no Clothing/Consumables + // filter, and the whole Shop renders empty even though all 1531 SKUs + // parsed fine. The GUID is the tail of the "wardrobe-{guid}" slug; ship + // it (plus the OutfitType) under the keys the client reads + // (AvatarItemId / AvatarItemType — verified vs 2023 ISIL store-item + // context types EMBCEDNHFLB / CLIANCNHNIE). Coloured variants + // (wardrobe-colored-*) resolve through a different descriptor path and + // are left alone here. + string? avatarItemId = null; + int? avatarItemType = null; + if (i.Slug.StartsWith("wardrobe-", StringComparison.Ordinal) + && !i.Slug.StartsWith("wardrobe-colored-", StringComparison.Ordinal)) + { + var guid = i.Slug["wardrobe-".Length..]; + avatarItemId = guid; + if (AvatarItemsController.TryGetOutfitType(guid, out var ot)) + avatarItemType = ot; + } + return new Dictionary { // Identity / naming — every alias the watch's strict @@ -116,6 +142,14 @@ public class CommerceCatalogController(StoreService store, DomainConfig domain) ["Slug"] = i.Slug, ["slug"] = i.Slug, + // Avatar-item join keys (see resolve block above). Emitted both at + // top level and inside Data so whichever the client reads, the + // SKU→AvatarItem resolve succeeds and the item lands in a tab. + ["AvatarItemId"] = avatarItemId, + ["avatarItemId"] = avatarItemId, + ["AvatarItemType"] = avatarItemType, + ["avatarItemType"] = avatarItemType, + ["Name"] = i.DisplayName, ["name"] = i.DisplayName, ["DisplayName"] = i.DisplayName, @@ -205,13 +239,21 @@ public class CommerceCatalogController(StoreService store, DomainConfig domain) // string / null sub-purchase are valid. ["Data"] = new Dictionary { - ["GiftDropIds"] = Array.Empty(), + ["AvatarItemId"] = avatarItemId, + ["AvatarItemType"] = avatarItemType, + // Link this SKU to its gift-drop store card (the EFFIEFEFHHB + // record `storefronts/v3/giftdropstore` builds, GiftDropId = + // this same id) so the client can resolve the card that carries + // the AvatarItemId join key. Empty here = SKU points at no card. + ["GiftDropIds"] = new[] { (int)(i.Id & 0x7fffffff) }, ["Message"] = string.Empty, ["SubscriptionPurchase"] = (object?)null, }, ["data"] = new Dictionary { - ["giftDropIds"] = Array.Empty(), + ["avatarItemId"] = avatarItemId, + ["avatarItemType"] = avatarItemType, + ["giftDropIds"] = new[] { (int)(i.Id & 0x7fffffff) }, ["message"] = string.Empty, ["subscriptionPurchase"] = (object?)null, }, diff --git a/DorkNet.Server/Controllers/Match/MatchPlayerController.cs b/DorkNet.Server/Controllers/Match/MatchPlayerController.cs index f4f09f3..acfb36c 100644 --- a/DorkNet.Server/Controllers/Match/MatchPlayerController.cs +++ b/DorkNet.Server/Controllers/Match/MatchPlayerController.cs @@ -504,5 +504,8 @@ public class PresenceDto public bool IsOnline { get; set; } = true; [JsonPropertyName("appVersion")] - public string AppVersion { get; set; } = "20201210"; + // 2023.03.21 client build version ("20230317"); must match the joining + // client's own version or it reports a version mismatch. Was a stale 2020 + // value (20201210) left over from the December branch this forked from. + public string AppVersion { get; set; } = "20230317"; } diff --git a/DorkNet.Server/Controllers/Match/PlayerPresenceController.cs b/DorkNet.Server/Controllers/Match/PlayerPresenceController.cs index 9811e00..c832966 100644 --- a/DorkNet.Server/Controllers/Match/PlayerPresenceController.cs +++ b/DorkNet.Server/Controllers/Match/PlayerPresenceController.cs @@ -179,5 +179,8 @@ public class PlayerPresenceDto public bool IsOnline { get; set; } = true; [JsonPropertyName("appVersion")] - public string AppVersion { get; set; } = "20201210"; + // 2023.03.21 client build version ("20230317"); must match the joining + // client's own version or it reports a version mismatch. Stale 2020 value + // (20201210) carried over from the December branch. + public string AppVersion { get; set; } = "20230317"; } diff --git a/DorkNet.Server/Services/GameSessionService.cs b/DorkNet.Server/Services/GameSessionService.cs index 71d2aab..0c9c4ea 100644 --- a/DorkNet.Server/Services/GameSessionService.cs +++ b/DorkNet.Server/Services/GameSessionService.cs @@ -64,7 +64,12 @@ public class GameSessionService(DorkNetDbContext db) // in, so we save once with a placeholder, then patch and save // again. Two SaveChangesAsync calls is cheap at our QPS. PhotonRoomName = "pending", - MaxCapacity = 8, + // Track the room's "Max Player Count in This Subroom" slider + // instead of a hardcoded 8, so matchmade joiners fill the room to + // the host's chosen limit rather than fragmenting into a new + // instance every 8 players. (Existing sessions keep whatever cap + // they were created with.) + MaxCapacity = await ResolveRoomMaxCapacityAsync(effectiveRoom), PlayerCount = 1, CreatedAt = DateTime.UtcNow, }; @@ -75,6 +80,28 @@ public class GameSessionService(DorkNetDbContext db) return ToDto(session); } + // Default session cap when we can't resolve a room-specific value (system + // rooms like the dorm use a GUID roomId that doesn't map to a RoomEntity). + private const int DefaultMaxCapacity = 8; + + /// Resolve a new session's capacity from the room's entry + /// subroom (lowest OrderIndex) + /// — the value the in-game "Max Player Count in This Subroom" slider + /// writes. Matchmade sessions land in the entry subroom, so its cap is the + /// right ceiling. GameSession roomIds are the room's numeric Id as a + /// string for user rooms; non-numeric (dorm GUID, etc.) → default. Clamped + /// to a sane Photon ceiling. + private async Task ResolveRoomMaxCapacityAsync(string roomId) + { + if (!long.TryParse(roomId, out var roomLongId)) return DefaultMaxCapacity; + var max = await db.RoomScenes + .Where(s => s.RoomId == roomLongId) + .OrderBy(s => s.OrderIndex) + .Select(s => (int?)s.MaxPlayers) + .FirstOrDefaultAsync(); + return max is int v && v > 0 ? Math.Clamp(v, 1, 80) : DefaultMaxCapacity; + } + public async Task GetByIdAsync(long id) { var s = await db.GameSessions.AsNoTracking() diff --git a/DorkNet.Server/Services/NotificationService.cs b/DorkNet.Server/Services/NotificationService.cs index 95543b6..0fdfe73 100644 --- a/DorkNet.Server/Services/NotificationService.cs +++ b/DorkNet.Server/Services/NotificationService.cs @@ -297,7 +297,10 @@ public class NotificationService( }, // 2020.12 PlayerPresence.Deserialize requires these. ["isOnline"] = redacted is not null, - ["appVersion"] = 20201210, + // 2023.03.21 client build version ("20230317"); must match the + // joining client's own version or it reports a version mismatch. + // Stale 2020 value (20201210) carried over from the December branch. + ["appVersion"] = 20230317, }; var envelope = new System.Text.Json.Nodes.JsonObject diff --git a/DorkNet.Server/Services/StoreService.cs b/DorkNet.Server/Services/StoreService.cs index 47068ca..d7b31b5 100644 --- a/DorkNet.Server/Services/StoreService.cs +++ b/DorkNet.Server/Services/StoreService.cs @@ -968,6 +968,15 @@ public class StoreService(DorkNetDbContext db, LevelService level, IConfiguratio Tooltip = i.Description, AvatarItemDesc = avatarItemDesc, AvatarItemDescOrHairDyeDesc = avatarItemDesc, + // AvatarItemId is the field the 2023 client's StoreItemListModel + // actually reads (store-item +0x38) to resolve the card to a baked + // AvatarItem and read its OutfitType for tab placement — verified + // vs 2023 ISIL (EMBCEDNHFLB/CLIANCNHNIE, AvatarItem.GIJPCPDKHPO). + // AvatarItemDesc alone is NOT the join key, so clothing cards + // resolved to OutfitType=-1 and the Shop rendered empty. Ship the + // same {guid},,, descriptor (GIJPCPDKHPO accepts bare {guid} or the + // composite and falls back to parsing it to the immutable Guid). + AvatarItemId = avatarItemDesc, AvatarItemType = string.IsNullOrEmpty(avatarItemDesc) ? (int?)null : avatarItemType, ConsumableItemDesc = consumableItemDesc, EquipmentPrefabName = equipmentPrefabName,