| | 1 | | using Cysharp.Threading.Tasks; |
| | 2 | | using JetBrains.Annotations; |
| | 3 | | using Newtonsoft.Json; |
| | 4 | | using System; |
| | 5 | | using System.Collections.Generic; |
| | 6 | | using System.Linq; |
| | 7 | | using System.Threading; |
| | 8 | | using UnityEngine; |
| | 9 | |
|
| | 10 | | namespace DCLServices.WearablesCatalogService |
| | 11 | | { |
| | 12 | | /// <summary> |
| | 13 | | /// This service keeps the same logic of the old CatalogController (but managed with UniTasks instead of Promises) |
| | 14 | | /// so all the wearables requests will pass through kernel. |
| | 15 | | /// It will be deprecated once we move all the kernel's logic related to requesting wearables to Unity. |
| | 16 | | /// </summary> |
| | 17 | | [Obsolete("This service will be deprecated by LambdasWearablesCatalogService in the future.")] |
| | 18 | | public class WebInterfaceWearablesCatalogService : MonoBehaviour, IWearablesCatalogService |
| | 19 | | { |
| | 20 | | private const string OWNED_WEARABLES_CONTEXT = "OwnedWearables"; |
| | 21 | | private const string BASE_WEARABLES_CONTEXT = "BaseWearables"; |
| | 22 | | private const string THIRD_PARTY_WEARABLES_CONTEXT = "ThirdPartyWearables"; |
| | 23 | | private const float REQUESTS_TIME_OUT_SECONDS = 45; |
| | 24 | |
|
| 124 | 25 | | public static WebInterfaceWearablesCatalogService Instance { get; private set; } |
| 49 | 26 | | public BaseDictionary<string, WearableItem> WearablesCatalog { get; private set; } |
| | 27 | |
|
| | 28 | | private WearablesWebInterfaceBridge webInterfaceBridge; |
| | 29 | | private CancellationTokenSource serviceCts; |
| 9 | 30 | | private readonly Dictionary<string, int> wearablesInUseCounters = new (); |
| 9 | 31 | | private readonly Dictionary<string, UniTaskCompletionSource<WearableItem[]>> awaitingWearablesByContextTasks = n |
| 9 | 32 | | private readonly Dictionary<string, float> pendingWearablesByContextRequestedTimes = new (); |
| 9 | 33 | | private readonly Dictionary<string, UniTaskCompletionSource<WearableItem>> awaitingWearableTasks = new (); |
| 9 | 34 | | private readonly Dictionary<string, float> pendingWearableRequestedTimes = new (); |
| 9 | 35 | | private readonly List<string> pendingWearablesToRequest = new (); |
| | 36 | |
|
| | 37 | | private void Awake() |
| | 38 | | { |
| 9 | 39 | | Instance = this; |
| 9 | 40 | | } |
| | 41 | |
|
| | 42 | | public void Initialize() |
| | 43 | | { |
| 9 | 44 | | serviceCts = new CancellationTokenSource(); |
| | 45 | |
|
| | 46 | | try |
| | 47 | | { |
| | 48 | | // All the requests happened during the same frames interval are sent together |
| 9 | 49 | | CheckForSendingPendingRequestsAsync(serviceCts.Token).Forget(); |
| 9 | 50 | | CheckForRequestsTimeOutsAsync(serviceCts.Token).Forget(); |
| 9 | 51 | | CheckForRequestsByContextTimeOutsAsync(serviceCts.Token).Forget(); |
| 9 | 52 | | } |
| 0 | 53 | | catch (OperationCanceledException) { } |
| 9 | 54 | | } |
| | 55 | |
|
| | 56 | | public void Initialize( |
| | 57 | | WearablesWebInterfaceBridge wearablesWebInterfaceBridge, |
| | 58 | | BaseDictionary<string, WearableItem> wearablesCatalog) |
| | 59 | | { |
| 9 | 60 | | webInterfaceBridge = wearablesWebInterfaceBridge; |
| 9 | 61 | | WearablesCatalog = wearablesCatalog; |
| 9 | 62 | | Initialize(); |
| 9 | 63 | | } |
| | 64 | |
|
| | 65 | | public void Dispose() |
| | 66 | | { |
| 9 | 67 | | serviceCts?.Cancel(); |
| 9 | 68 | | serviceCts?.Dispose(); |
| 9 | 69 | | Destroy(this); |
| 9 | 70 | | } |
| | 71 | |
|
| | 72 | | public UniTask<WearableCollectionsAPIData.Collection[]> GetThirdPartyCollectionsAsync(CancellationToken cancella |
| 0 | 73 | | throw new NotImplementedException("Supported by LambdasWearablesCatalogService"); |
| | 74 | |
|
| | 75 | | public UniTask<(IReadOnlyList<WearableItem> wearables, int totalAmount)> RequestOwnedWearablesAsync(string userI |
| | 76 | | NftRarity rarity = NftRarity.None, |
| | 77 | | NftCollectionType collectionTypeMask = NftCollectionType.All, |
| | 78 | | ICollection<string> thirdPartyCollectionIds = null, string name = null, |
| | 79 | | (NftOrderByOperation type, bool directionAscendent)? orderBy = null) => |
| 0 | 80 | | throw new NotImplementedException("Supported by LambdasWearablesCatalogService"); |
| | 81 | |
|
| | 82 | | public async UniTask<(IReadOnlyList<WearableItem> wearables, int totalAmount)> RequestOwnedWearablesAsync(string |
| | 83 | | { |
| 2 | 84 | | var wearables = await RequestWearablesByContextAsync(userId, null, null, $"{OWNED_WEARABLES_CONTEXT}{userId} |
| 1 | 85 | | return (FilterWearablesByPage(wearables, pageNumber, pageSize), wearables.Count); |
| | 86 | |
|
| 1 | 87 | | } |
| | 88 | |
|
| | 89 | | public async UniTask RequestBaseWearablesAsync(CancellationToken ct) => |
| 2 | 90 | | await RequestWearablesByContextAsync(null, null, new[] { IWearablesCatalogService.BASE_WEARABLES_COLLECTION_ |
| | 91 | |
|
| | 92 | | public async UniTask<(IReadOnlyList<WearableItem> wearables, int totalAmount)> RequestThirdPartyWearablesByColle |
| | 93 | | { |
| 2 | 94 | | var wearables = await RequestWearablesByContextAsync(userId, null, new[] { collectionId }, $"{THIRD_PARTY_WE |
| 1 | 95 | | return (FilterWearablesByPage(wearables, pageNumber, pageSize), wearables.Count); |
| 1 | 96 | | } |
| | 97 | |
|
| | 98 | | public async UniTask<WearableItem> RequestWearableAsync(string wearableId, CancellationToken ct) |
| | 99 | | { |
| 3 | 100 | | if (WearablesCatalog.TryGetValue(wearableId, out WearableItem wearable)) |
| | 101 | | { |
| 1 | 102 | | if (wearablesInUseCounters.ContainsKey(wearableId)) |
| 1 | 103 | | wearablesInUseCounters[wearableId]++; |
| | 104 | |
|
| 1 | 105 | | return wearable; |
| | 106 | | } |
| | 107 | |
|
| 2 | 108 | | ct.ThrowIfCancellationRequested(); |
| | 109 | |
|
| | 110 | | UniTaskCompletionSource<WearableItem> taskResult; |
| | 111 | |
|
| 2 | 112 | | if (!awaitingWearableTasks.ContainsKey(wearableId)) |
| | 113 | | { |
| 2 | 114 | | taskResult = new UniTaskCompletionSource<WearableItem>(); |
| 2 | 115 | | awaitingWearableTasks.Add(wearableId, taskResult); |
| | 116 | |
|
| | 117 | | // We accumulate all the requests during the same frames interval to send them all together |
| 2 | 118 | | pendingWearablesToRequest.Add(wearableId); |
| | 119 | | } |
| | 120 | | else |
| 0 | 121 | | taskResult = awaitingWearableTasks[wearableId]; |
| | 122 | |
|
| 6 | 123 | | return await taskResult.Task.AttachExternalCancellation(ct); |
| 2 | 124 | | } |
| | 125 | |
|
| | 126 | | public UniTask<WearableItem> RequestWearableFromBuilderAsync(string wearableId, CancellationToken ct) => |
| 0 | 127 | | throw new NotImplementedException("Supported by LambdasWearablesCatalogService"); |
| | 128 | |
|
| | 129 | | public UniTask<IReadOnlyList<WearableItem>> RequestWearableCollection(IEnumerable<string> collectionIds, |
| | 130 | | CancellationToken cancellationToken, List<WearableItem> wearableBuffer = null) => |
| 0 | 131 | | throw new NotImplementedException("Supported by LambdasWearablesCatalogService"); |
| | 132 | |
|
| | 133 | | public UniTask<(IReadOnlyList<WearableItem> wearables, int totalAmount)> RequestWearableCollectionInBuilder( |
| | 134 | | IEnumerable<string> collectionIds, CancellationToken cancellationToken, |
| | 135 | | List<WearableItem> collectionBuffer = null, string nameFilter = null, int pageNumber = 1, int pageSize = 500 |
| 0 | 136 | | throw new NotImplementedException("Supported by LambdasWearablesCatalogService"); |
| | 137 | |
|
| | 138 | | private async UniTask<IReadOnlyList<WearableItem>> RequestWearablesByContextAsync( |
| | 139 | | string userId, |
| | 140 | | string[] wearableIds, |
| | 141 | | string[] collectionIds, |
| | 142 | | string context, |
| | 143 | | bool isThirdParty, |
| | 144 | | CancellationToken ct) |
| | 145 | | { |
| 8 | 146 | | ct.ThrowIfCancellationRequested(); |
| | 147 | |
|
| | 148 | | UniTaskCompletionSource<WearableItem[]> taskResult; |
| | 149 | |
|
| 8 | 150 | | if (!awaitingWearablesByContextTasks.ContainsKey(context)) |
| | 151 | | { |
| 8 | 152 | | taskResult = new UniTaskCompletionSource<WearableItem[]>(); |
| 8 | 153 | | awaitingWearablesByContextTasks.Add(context, taskResult); |
| | 154 | |
|
| 8 | 155 | | if (!pendingWearablesByContextRequestedTimes.ContainsKey(context)) |
| 8 | 156 | | pendingWearablesByContextRequestedTimes.Add(context, Time.realtimeSinceStartup); |
| | 157 | |
|
| 8 | 158 | | if (!isThirdParty) |
| 6 | 159 | | webInterfaceBridge.RequestWearables(userId, wearableIds, collectionIds, context); |
| | 160 | | else |
| 2 | 161 | | webInterfaceBridge.RequestThirdPartyWearables(userId, collectionIds[0], context); |
| | 162 | | } |
| | 163 | | else |
| 0 | 164 | | taskResult = awaitingWearablesByContextTasks[context]; |
| | 165 | |
|
| 10 | 166 | | var wearablesResult = await taskResult.Task.AttachExternalCancellation(ct); |
| 4 | 167 | | AddWearablesToCatalog(wearablesResult); |
| | 168 | |
|
| 4 | 169 | | return wearablesResult; |
| 4 | 170 | | } |
| | 171 | |
|
| | 172 | | [PublicAPI] |
| | 173 | | public void AddWearablesToCatalog(string payload) |
| | 174 | | { |
| 4 | 175 | | WearablesRequestResponse request = null; |
| | 176 | |
|
| | 177 | | try |
| | 178 | | { |
| | 179 | | // The new wearables paradigm is based on composing with optional field |
| | 180 | | // i.e. the emotes will have an emoteDataV0 property with some values. |
| | 181 | | // JsonUtility.FromJson doesn't allow null properties so we have to use Newtonsoft instead |
| 4 | 182 | | request = JsonConvert.DeserializeObject<WearablesRequestResponse>(payload); |
| 4 | 183 | | } |
| 0 | 184 | | catch (Exception e) { Debug.LogError($"Fail to parse wearables json {e}"); } |
| | 185 | |
|
| 4 | 186 | | if (request == null) |
| 0 | 187 | | return; |
| | 188 | |
|
| 4 | 189 | | if (!string.IsNullOrEmpty(request.context)) |
| 4 | 190 | | ResolvePendingWearablesByContext(request.context, request.wearables); |
| 4 | 191 | | } |
| | 192 | |
|
| | 193 | | [PublicAPI] |
| | 194 | | public void WearablesRequestFailed(string payload) |
| | 195 | | { |
| 4 | 196 | | WearablesRequestFailed requestFailedResponse = JsonUtility.FromJson<WearablesRequestFailed>(payload); |
| | 197 | |
|
| 4 | 198 | | if (requestFailedResponse.context == BASE_WEARABLES_CONTEXT || |
| | 199 | | requestFailedResponse.context.Contains(THIRD_PARTY_WEARABLES_CONTEXT) || |
| 3 | 200 | | requestFailedResponse.context.Contains(OWNED_WEARABLES_CONTEXT)) { ResolvePendingWearablesByContext(requ |
| | 201 | | else |
| | 202 | | { |
| 1 | 203 | | string[] failedWearablesIds = requestFailedResponse.context.Split(','); |
| | 204 | |
|
| 4 | 205 | | foreach (string failedWearableId in failedWearablesIds) |
| | 206 | | { |
| 1 | 207 | | ResolvePendingWearableById( |
| | 208 | | failedWearableId, |
| | 209 | | null, |
| | 210 | | $"The request for the wearable '{failedWearableId}' has failed: {requestFailedResponse.error}"); |
| | 211 | | } |
| | 212 | | } |
| 1 | 213 | | } |
| | 214 | |
|
| | 215 | | public void AddWearablesToCatalog(IEnumerable<WearableItem> wearableItems) |
| | 216 | | { |
| 32 | 217 | | foreach (WearableItem wearableItem in wearableItems) |
| | 218 | | { |
| 11 | 219 | | if (WearablesCatalog.ContainsKey(wearableItem.id)) |
| | 220 | | continue; |
| | 221 | |
|
| 11 | 222 | | wearableItem.SanitizeHidesLists(); |
| 11 | 223 | | WearablesCatalog.Add(wearableItem.id, wearableItem); |
| | 224 | |
|
| 11 | 225 | | if (!wearablesInUseCounters.ContainsKey(wearableItem.id)) |
| 11 | 226 | | wearablesInUseCounters.Add(wearableItem.id, 1); |
| | 227 | | } |
| 5 | 228 | | } |
| | 229 | |
|
| | 230 | | public void RemoveWearablesFromCatalog(IEnumerable<string> wearableIds) |
| | 231 | | { |
| 0 | 232 | | foreach (string wearableId in wearableIds) |
| 0 | 233 | | RemoveWearableFromCatalog(wearableId); |
| 0 | 234 | | } |
| | 235 | |
|
| | 236 | | public void RemoveWearableFromCatalog(string wearableId) |
| | 237 | | { |
| 0 | 238 | | WearablesCatalog.Remove(wearableId); |
| 0 | 239 | | wearablesInUseCounters.Remove(wearableId); |
| 0 | 240 | | } |
| | 241 | |
|
| | 242 | | public void RemoveWearablesInUse(IEnumerable<string> wearablesInUseToRemove) |
| | 243 | | { |
| 0 | 244 | | foreach (string wearableToRemove in wearablesInUseToRemove) |
| | 245 | | { |
| 0 | 246 | | if (!wearablesInUseCounters.ContainsKey(wearableToRemove)) |
| | 247 | | continue; |
| | 248 | |
|
| 0 | 249 | | wearablesInUseCounters[wearableToRemove]--; |
| | 250 | |
|
| 0 | 251 | | if (wearablesInUseCounters[wearableToRemove] > 0) |
| | 252 | | continue; |
| | 253 | |
|
| 0 | 254 | | WearablesCatalog.Remove(wearableToRemove); |
| 0 | 255 | | wearablesInUseCounters.Remove(wearableToRemove); |
| | 256 | | } |
| 0 | 257 | | } |
| | 258 | |
|
| | 259 | | public void AddEmbeddedWearablesToCatalog(IEnumerable<WearableItem> wearables) |
| | 260 | | { |
| 0 | 261 | | foreach (WearableItem wearableItem in wearables) |
| | 262 | | { |
| 0 | 263 | | WearablesCatalog[wearableItem.id] = wearableItem; |
| | 264 | |
|
| 0 | 265 | | if (wearablesInUseCounters.ContainsKey(wearableItem.id)) |
| 0 | 266 | | wearablesInUseCounters[wearableItem.id] = 10000; //A high value to ensure they are not removed |
| | 267 | | } |
| 0 | 268 | | } |
| | 269 | |
|
| | 270 | | public void Clear() |
| | 271 | | { |
| 0 | 272 | | WearablesCatalog.Clear(); |
| 0 | 273 | | wearablesInUseCounters.Clear(); |
| 0 | 274 | | pendingWearablesToRequest.Clear(); |
| 0 | 275 | | pendingWearableRequestedTimes.Clear(); |
| 0 | 276 | | pendingWearablesByContextRequestedTimes.Clear(); |
| | 277 | |
|
| 0 | 278 | | foreach (var awaitingTask in awaitingWearableTasks) |
| 0 | 279 | | awaitingTask.Value.TrySetCanceled(); |
| | 280 | |
|
| 0 | 281 | | awaitingWearableTasks.Clear(); |
| | 282 | |
|
| 0 | 283 | | foreach (var awaitingTask in awaitingWearablesByContextTasks) |
| 0 | 284 | | awaitingTask.Value.TrySetCanceled(); |
| | 285 | |
|
| 0 | 286 | | awaitingWearablesByContextTasks.Clear(); |
| 0 | 287 | | } |
| | 288 | |
|
| | 289 | | public bool IsValidWearable(string wearableId) |
| | 290 | | { |
| 0 | 291 | | if (!WearablesCatalog.TryGetValue(wearableId, out var wearable)) |
| 0 | 292 | | return false; |
| | 293 | |
|
| 0 | 294 | | return wearable != null; |
| | 295 | | } |
| | 296 | |
|
| | 297 | | private async UniTaskVoid CheckForSendingPendingRequestsAsync(CancellationToken ct) |
| | 298 | | { |
| 26 | 299 | | while (!ct.IsCancellationRequested) |
| | 300 | | { |
| 78 | 301 | | await UniTask.Yield(PlayerLoopTiming.PostLateUpdate, cancellationToken: ct); |
| | 302 | |
|
| 18 | 303 | | if (pendingWearablesToRequest.Count <= 0) |
| | 304 | | continue; |
| | 305 | |
|
| 2 | 306 | | string[] wearablesToRequest = pendingWearablesToRequest.ToArray(); |
| 2 | 307 | | pendingWearablesToRequest.Clear(); |
| | 308 | |
|
| 8 | 309 | | foreach (string wearablesToRequestId in wearablesToRequest) |
| | 310 | | { |
| 2 | 311 | | if (!pendingWearableRequestedTimes.ContainsKey(wearablesToRequestId)) |
| 2 | 312 | | pendingWearableRequestedTimes.Add(wearablesToRequestId, Time.realtimeSinceStartup); |
| | 313 | | } |
| | 314 | |
|
| 4 | 315 | | var requestedWearables = await RequestWearablesByContextAsync(null, wearablesToRequest, null, string.Joi |
| 1 | 316 | | List<string> wearablesNotFound = wearablesToRequest.ToList(); |
| | 317 | |
|
| 4 | 318 | | foreach (WearableItem wearable in requestedWearables) |
| | 319 | | { |
| 1 | 320 | | wearablesNotFound.Remove(wearable.id); |
| 1 | 321 | | ResolvePendingWearableById(wearable.id, wearable); |
| | 322 | | } |
| | 323 | |
|
| 2 | 324 | | foreach (string wearableNotFound in wearablesNotFound) |
| 0 | 325 | | ResolvePendingWearableById(wearableNotFound, null); |
| 1 | 326 | | } |
| 0 | 327 | | } |
| | 328 | |
|
| | 329 | | private async UniTaskVoid CheckForRequestsTimeOutsAsync(CancellationToken ct) |
| | 330 | | { |
| 27 | 331 | | while (!ct.IsCancellationRequested) |
| | 332 | | { |
| 81 | 333 | | await UniTask.Yield(PlayerLoopTiming.PostLateUpdate, cancellationToken: ct); |
| | 334 | |
|
| 18 | 335 | | if (pendingWearableRequestedTimes.Count <= 0) |
| | 336 | | continue; |
| | 337 | |
|
| 0 | 338 | | var expiredRequests = (from taskRequestedTime in pendingWearableRequestedTimes |
| 0 | 339 | | where Time.realtimeSinceStartup - taskRequestedTime.Value > REQUESTS_TIME_OUT_SECONDS |
| 0 | 340 | | select taskRequestedTime.Key).ToList(); |
| | 341 | |
|
| 0 | 342 | | foreach (string expiredRequestId in expiredRequests) |
| | 343 | | { |
| 0 | 344 | | pendingWearableRequestedTimes.Remove(expiredRequestId); |
| | 345 | |
|
| 0 | 346 | | ResolvePendingWearableById(expiredRequestId, null, |
| | 347 | | $"The request for the wearable '{expiredRequestId}' has exceed the set timeout!"); |
| | 348 | | } |
| | 349 | | } |
| 0 | 350 | | } |
| | 351 | |
|
| | 352 | | private async UniTaskVoid CheckForRequestsByContextTimeOutsAsync(CancellationToken ct) |
| | 353 | | { |
| 27 | 354 | | while (!ct.IsCancellationRequested) |
| | 355 | | { |
| 81 | 356 | | await UniTask.Yield(PlayerLoopTiming.PostLateUpdate, cancellationToken: ct); |
| | 357 | |
|
| 18 | 358 | | if (pendingWearablesByContextRequestedTimes.Count <= 0) |
| | 359 | | continue; |
| | 360 | |
|
| 1 | 361 | | var expiredRequests = (from promiseByContextRequestedTime in pendingWearablesByContextRequestedTimes |
| 1 | 362 | | where Time.realtimeSinceStartup - promiseByContextRequestedTime.Value > REQUESTS_TIME_OUT_SECONDS |
| 0 | 363 | | select promiseByContextRequestedTime.Key).ToList(); |
| | 364 | |
|
| 2 | 365 | | foreach (string expiredRequestToRemove in expiredRequests) |
| | 366 | | { |
| 0 | 367 | | pendingWearablesByContextRequestedTimes.Remove(expiredRequestToRemove); |
| | 368 | |
|
| 0 | 369 | | ResolvePendingWearablesByContext(expiredRequestToRemove, null, |
| | 370 | | $"The request for the wearable context '{expiredRequestToRemove}' has exceed the set timeout!"); |
| | 371 | | } |
| | 372 | | } |
| 0 | 373 | | } |
| | 374 | |
|
| | 375 | | private void ResolvePendingWearableById(string wearableId, WearableItem result, string errorMessage = "") |
| | 376 | | { |
| 2 | 377 | | if (!awaitingWearableTasks.TryGetValue(wearableId, out var task)) |
| 0 | 378 | | return; |
| | 379 | |
|
| 2 | 380 | | if (string.IsNullOrEmpty(errorMessage)) |
| 1 | 381 | | task.TrySetResult(result); |
| | 382 | | else |
| 1 | 383 | | task.TrySetException(new Exception(errorMessage)); |
| | 384 | |
|
| 2 | 385 | | awaitingWearableTasks.Remove(wearableId); |
| 2 | 386 | | pendingWearableRequestedTimes.Remove(wearableId); |
| 2 | 387 | | } |
| | 388 | |
|
| | 389 | | private void ResolvePendingWearablesByContext(string context, WearableItem[] newWearablesAddedIntoCatalog = null |
| | 390 | | { |
| 7 | 391 | | if (!awaitingWearablesByContextTasks.TryGetValue(context, out var task)) |
| 0 | 392 | | return; |
| | 393 | |
|
| 7 | 394 | | if (string.IsNullOrEmpty(errorMessage)) |
| 4 | 395 | | task.TrySetResult(newWearablesAddedIntoCatalog); |
| | 396 | | else |
| 3 | 397 | | task.TrySetException(new Exception(errorMessage)); |
| | 398 | |
|
| 7 | 399 | | awaitingWearablesByContextTasks.Remove(context); |
| 7 | 400 | | pendingWearablesByContextRequestedTimes.Remove(context); |
| 7 | 401 | | } |
| | 402 | |
|
| | 403 | | // As kernel doesn't have pagination available, we apply a "local" pagination over the returned results |
| | 404 | | private static IReadOnlyList<WearableItem> FilterWearablesByPage(IReadOnlyCollection<WearableItem> wearables, in |
| | 405 | | { |
| 2 | 406 | | int paginationIndex = pageNumber * pageSize; |
| 2 | 407 | | int skippedWearables = Math.Min(paginationIndex - pageSize, wearables.Count); |
| 2 | 408 | | int takenWearables = paginationIndex > wearables.Count ? paginationIndex - (paginationIndex - wearables.Coun |
| 2 | 409 | | return wearables |
| | 410 | | .Skip(skippedWearables) |
| | 411 | | .Take(skippedWearables < wearables.Count ? takenWearables : 0) |
| | 412 | | .ToArray(); |
| | 413 | | } |
| | 414 | | } |
| | 415 | | } |