Skip to content

Commit d94c066

Browse files
committed
Improve achievements reconnect, recovery flow, and UI guards
- Restored achievements operations after auth expiry and added UI safeguards during async icon loads. - Added SemaphoreSlim-based serialized recovery via EnsureValidProductUserIdAsync to refresh ProductUserId and cached achievements. - Implemented retry logic for EOS calls through ShouldRetryAfterReconnect, updating QueryPlayerAchievementsAsync and UnlockAchievementAsync to transparently retry on transient auth connect failures. - Reworked unlock flow to return a concrete PlayerAchievement and update _playerAchievements atomically on success, enhancing error reporting for invalid inputs. - Improved icon download and cache handling: added TryGetValue checks, URI validation, null-callback early returns, and synchronous return for cached data. - Added UI guard in UIAchievementsMenu.DisplayPlayerAchievement to show descriptive messages and disable controls when ProductUserId is null or invalid.
1 parent 6dff459 commit d94c066

3 files changed

Lines changed: 296 additions & 60 deletions

File tree

Assets/Scripts/StandardSamples/Services/AchievementsService.cs

Lines changed: 220 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ namespace PlayEveryWare.EpicOnlineServices.Samples
3535
using Epic.OnlineServices;
3636
using Epic.OnlineServices.Achievements;
3737
using System.Threading.Tasks;
38+
using System.Threading;
3839
using Debug = UnityEngine.Debug;
3940

4041
/// <summary>
@@ -59,6 +60,12 @@ public class AchievementsService : EOSService
5960
/// </summary>
6061
private ConcurrentDictionary<ProductUserId, List<PlayerAchievement>> _playerAchievements = new();
6162

63+
/// <summary>
64+
/// Serializes recovery attempts so multiple achievement requests do not
65+
/// try to reconnect Connect/Auth at the same time after expiration.
66+
/// </summary>
67+
private readonly SemaphoreSlim _connectRecoverySemaphore = new(1, 1);
68+
6269
#region Singleton Implementation
6370

6471
/// <summary>
@@ -130,11 +137,12 @@ protected override void Reset()
130137
/// </summary>
131138
protected async override Task InternalRefreshAsync()
132139
{
133-
if (!TryGetProductUserId(out ProductUserId productUser))
140+
ProductUserId productUserId = await EnsureValidProductUserIdAsync();
141+
if (productUserId == null || !productUserId.IsValid())
134142
{
135143
return;
136144
}
137-
ProductUserId productUserId = EOSManager.Instance.GetProductUserId();
145+
138146
_achievements = await QueryAchievementsAsync(productUserId);
139147

140148
// If the user is not in the list, then add it.
@@ -287,6 +295,17 @@ private List<DefinitionV2> GetCachedAchievements()
287295
/// </returns>
288296
private Task<List<PlayerAchievement>> QueryPlayerAchievementsAsync(ProductUserId productUserId)
289297
{
298+
return QueryPlayerAchievementsWithReconnectAsync(productUserId, false);
299+
}
300+
301+
private async Task<List<PlayerAchievement>> QueryPlayerAchievementsWithReconnectAsync(ProductUserId productUserId, bool retryingAfterReconnect)
302+
{
303+
productUserId = await EnsureValidProductUserIdAsync();
304+
if (productUserId == null || !productUserId.IsValid())
305+
{
306+
return new List<PlayerAchievement>();
307+
}
308+
290309
Log($"Begin query player achievements for {ProductUserIdToString(productUserId)}");
291310

292311
QueryPlayerAchievementsOptions options = new()
@@ -295,22 +314,29 @@ private Task<List<PlayerAchievement>> QueryPlayerAchievementsAsync(ProductUserId
295314
TargetUserId = productUserId
296315
};
297316

298-
TaskCompletionSource<List<PlayerAchievement>> tcs = new();
317+
TaskCompletionSource<Result> tcs = new();
299318

300319
GetEOSAchievementInterface().QueryPlayerAchievements(ref options, null, (ref OnQueryPlayerAchievementsCompleteCallbackInfo data) =>
301320
{
302-
if (data.ResultCode != Result.Success)
303-
{
304-
Log($"Error querying player achievements. Result code: {data.ResultCode}");
305-
tcs.SetResult(new List<PlayerAchievement>());
306-
}
307-
else
321+
tcs.TrySetResult(data.ResultCode);
322+
});
323+
324+
Result result = await tcs.Task;
325+
if (result == Result.Success)
326+
{
327+
return GetCachedPlayerAchievements(productUserId);
328+
}
329+
330+
if (!retryingAfterReconnect && ShouldRetryAfterReconnect(result))
331+
{
332+
ProductUserId recoveredProductUserId = await EnsureValidProductUserIdAsync(true);
333+
if (recoveredProductUserId != null && recoveredProductUserId.IsValid())
308334
{
309-
tcs.SetResult(GetCachedPlayerAchievements(productUserId));
335+
return await QueryPlayerAchievementsWithReconnectAsync(recoveredProductUserId, true);
310336
}
311-
});
337+
}
312338

313-
return tcs.Task;
339+
return new List<PlayerAchievement>();
314340
}
315341

316342
/// <summary>
@@ -361,6 +387,12 @@ public async Task<Texture2D> GetAchievementLockedIconTexture(string achievementI
361387
/// </returns>
362388
private async Task<Texture2D> GetAchievementIconTexture(string achievementId, Func<DefinitionV2, string> uriSelector)
363389
{
390+
if (string.IsNullOrWhiteSpace(achievementId))
391+
{
392+
Debug.LogWarning($"{nameof(AchievementsService)} {nameof(GetAchievementIconTexture)}: Cannot fetch achievement icon because the achievement id is null or empty.");
393+
return null;
394+
}
395+
364396
Texture2D textureFromBytes = null;
365397

366398
foreach (var achievementDef in _achievements)
@@ -369,10 +401,16 @@ private async Task<Texture2D> GetAchievementIconTexture(string achievementId, Fu
369401
continue;
370402

371403
var uri = uriSelector(achievementDef);
404+
if (string.IsNullOrWhiteSpace(uri))
405+
{
406+
Debug.LogWarning($"{nameof(AchievementsService)} {nameof(GetAchievementIconTexture)}: Achievement '{achievementId}' does not define an icon URL for the requested state.");
407+
break;
408+
}
409+
372410
byte[] iconBytes = null;
373411

374412
// Download the data
375-
if (!_downloadCache.ContainsKey(uri))
413+
if (!_downloadCache.TryGetValue(uri, out iconBytes))
376414
{
377415
TaskCompletionSource<byte[]> downloadTcs = new();
378416

@@ -395,11 +433,6 @@ private async Task<Texture2D> GetAchievementIconTexture(string achievementId, Fu
395433
_downloadCache[uri] = iconBytes;
396434
}
397435
}
398-
else
399-
{
400-
_downloadCache.TryGetValue(uri, out iconBytes);
401-
}
402-
403436

404437
if (null != iconBytes)
405438
{
@@ -502,6 +535,25 @@ public static uint GetAchievementsCount()
502535
/// </param>
503536
public Task<PlayerAchievement> UnlockAchievementAsync(string achievementId)
504537
{
538+
return UnlockAchievementInternalAsync(achievementId, false);
539+
}
540+
541+
private async Task<PlayerAchievement> UnlockAchievementInternalAsync(string achievementId, bool retryingAfterReconnect)
542+
{
543+
if (string.IsNullOrWhiteSpace(achievementId))
544+
{
545+
throw new ArgumentException("Achievement id must not be null or empty.", nameof(achievementId));
546+
}
547+
548+
if (_achievements.Count == 0)
549+
{
550+
ProductUserId refreshedProductUserId = await EnsureValidProductUserIdAsync();
551+
if (refreshedProductUserId != null && refreshedProductUserId.IsValid())
552+
{
553+
_achievements = await QueryAchievementsAsync(refreshedProductUserId);
554+
}
555+
}
556+
505557
DefinitionV2? definition = null;
506558
for (int i = 0; i < _achievements.Count; i++)
507559
{
@@ -515,61 +567,152 @@ public Task<PlayerAchievement> UnlockAchievementAsync(string achievementId)
515567

516568
if (!definition.HasValue)
517569
{
518-
return Task.FromException<PlayerAchievement>(new Exception($"Achievement definition not found for ID: {achievementId}"));
570+
ProductUserId refreshedProductUserId = await EnsureValidProductUserIdAsync();
571+
if (refreshedProductUserId != null && refreshedProductUserId.IsValid())
572+
{
573+
_achievements = await QueryAchievementsAsync(refreshedProductUserId);
574+
575+
for (int i = 0; i < _achievements.Count; i++)
576+
{
577+
DefinitionV2 otherDefinition = _achievements[i];
578+
if (otherDefinition.AchievementId == achievementId)
579+
{
580+
definition = otherDefinition;
581+
break;
582+
}
583+
}
584+
}
585+
}
586+
587+
if (!definition.HasValue)
588+
{
589+
throw new Exception($"Achievement definition not found for ID: {achievementId}");
590+
}
591+
592+
var localUserId = await EnsureValidProductUserIdAsync();
593+
if (localUserId == null || !localUserId.IsValid())
594+
{
595+
throw new InvalidOperationException("Cannot unlock achievements because the local ProductUserId is null or invalid and automatic Connect recovery did not succeed.");
519596
}
520597

521-
var localUserId = EOSManager.Instance.GetProductUserId();
522598
var eosAchievementOption = new UnlockAchievementsOptions
523599
{
524600
UserId = localUserId,
525601
AchievementIds = new Utf8String[] { definition.Value.AchievementId }
526602
};
527603

528-
var tcs = new TaskCompletionSource<PlayerAchievement>();
604+
var tcs = new TaskCompletionSource<Result>();
529605

530606
GetEOSAchievementInterface().UnlockAchievements(ref eosAchievementOption, null,
531607
(ref OnUnlockAchievementsCompleteCallbackInfo data) =>
532608
{
533-
if (data.ResultCode != Result.Success)
609+
tcs.TrySetResult(data.ResultCode);
610+
});
611+
612+
Result unlockResult = await tcs.Task;
613+
if (unlockResult != Result.Success)
614+
{
615+
if (!retryingAfterReconnect && ShouldRetryAfterReconnect(unlockResult))
616+
{
617+
await EnsureValidProductUserIdAsync(true);
618+
return await UnlockAchievementInternalAsync(achievementId, true);
619+
}
620+
621+
throw new Exception($"Could not unlock achievement. Error code: {Enum.GetName(typeof(Result), unlockResult)}");
622+
}
623+
624+
var achievement = new PlayerAchievement()
625+
{
626+
AchievementId = definition.Value.AchievementId,
627+
DisplayName = definition.Value.UnlockedDisplayName,
628+
Description = definition.Value.UnlockedDescription,
629+
Progress = 1.0,
630+
UnlockTime = DateTime.UtcNow,
631+
StatInfo = null
632+
};
633+
634+
_playerAchievements.AddOrUpdate(localUserId,
635+
new List<PlayerAchievement> { achievement },
636+
(id, list) =>
637+
{
638+
int index = list.FindIndex(a => a.AchievementId == achievement.AchievementId);
639+
if (index >= 0)
534640
{
535-
tcs.SetException(new Exception($"Could not unlock achievement. Error code: {Enum.GetName(typeof(Result), data.ResultCode)}"));
641+
list[index] = achievement;
536642
}
537643
else
538644
{
539-
var achievement = new PlayerAchievement()
540-
{
541-
AchievementId = definition.Value.AchievementId,
542-
DisplayName = definition.Value.UnlockedDisplayName,
543-
Description = definition.Value.UnlockedDescription,
544-
Progress = 1.0,
545-
UnlockTime = DateTime.UtcNow,
546-
StatInfo = null
547-
};
548-
549-
_playerAchievements.AddOrUpdate(localUserId,
550-
new List<PlayerAchievement> { achievement },
551-
(id, list) =>
552-
{
553-
int index = list.FindIndex(a => a.AchievementId == achievement.AchievementId);
554-
if (index >= 0)
555-
{
556-
list[index] = achievement;
557-
}
558-
else
559-
{
560-
list.Add(achievement);
561-
}
562-
return list;
563-
});
564-
565-
NotifyUpdated();
566-
567-
tcs.SetResult(achievement);
568-
645+
list.Add(achievement);
569646
}
647+
return list;
570648
});
571649

572-
return tcs.Task;
650+
NotifyUpdated();
651+
652+
return achievement;
653+
}
654+
655+
private static bool ShouldRetryAfterReconnect(Result result)
656+
{
657+
return result == Result.Canceled ||
658+
result == Result.InvalidAuth ||
659+
result == Result.AuthExpired ||
660+
result == Result.AuthExternalAuthExpired ||
661+
result == Result.ConnectAuthExpired;
662+
}
663+
664+
private async Task<ProductUserId> EnsureValidProductUserIdAsync(bool forceReconnect = false)
665+
{
666+
ProductUserId currentProductUserId = EOSManager.Instance.GetProductUserId();
667+
if (!forceReconnect && currentProductUserId != null && currentProductUserId.IsValid())
668+
{
669+
return currentProductUserId;
670+
}
671+
672+
EpicAccountId localUserId = EOSManager.Instance.GetLocalUserId();
673+
if (localUserId == null || !localUserId.IsValid())
674+
{
675+
return null;
676+
}
677+
678+
await _connectRecoverySemaphore.WaitAsync();
679+
try
680+
{
681+
currentProductUserId = EOSManager.Instance.GetProductUserId();
682+
if (!forceReconnect && currentProductUserId != null && currentProductUserId.IsValid())
683+
{
684+
return currentProductUserId;
685+
}
686+
687+
TaskCompletionSource<Result> reconnectTcs = new();
688+
EOSManager.Instance.StartConnectLoginWithEpicAccount(localUserId, callback =>
689+
{
690+
reconnectTcs.TrySetResult(callback.ResultCode);
691+
});
692+
693+
Result reconnectResult = await reconnectTcs.Task;
694+
if (reconnectResult != Result.Success)
695+
{
696+
return null;
697+
}
698+
699+
ProductUserId refreshedProductUserId = EOSManager.Instance.GetProductUserId();
700+
if (refreshedProductUserId == null || !refreshedProductUserId.IsValid())
701+
{
702+
return null;
703+
}
704+
705+
if (_achievements.Count == 0)
706+
{
707+
_achievements = await QueryAchievementsAsync(refreshedProductUserId);
708+
}
709+
710+
return refreshedProductUserId;
711+
}
712+
finally
713+
{
714+
_connectRecoverySemaphore.Release();
715+
}
573716
}
574717

575718
/// <summary>
@@ -636,8 +779,30 @@ protected struct DownloadDataCallback
636779
/// </param>
637780
private void GetAndCacheData(string uri, Action<DownloadDataCallback> callback)
638781
{
782+
if (callback == null)
783+
{
784+
Debug.LogWarning($"{nameof(AchievementsService)} {nameof(GetAndCacheData)}: Callback is null; aborting download.");
785+
return;
786+
}
787+
788+
if (string.IsNullOrWhiteSpace(uri))
789+
{
790+
Debug.LogWarning($"{nameof(AchievementsService)} {nameof(GetAndCacheData)}: URI is null or empty; skipping download.");
791+
callback(new DownloadDataCallback
792+
{
793+
data = null,
794+
result = UnityWebRequest.Result.ProtocolError
795+
});
796+
return;
797+
}
798+
639799
if (_downloadCache.ContainsKey(uri))
640800
{
801+
callback(new DownloadDataCallback
802+
{
803+
data = _downloadCache[uri],
804+
result = UnityWebRequest.Result.Success
805+
});
641806
return;
642807
}
643808

@@ -668,4 +833,4 @@ private void GetAndCacheData(string uri, Action<DownloadDataCallback> callback)
668833
};
669834
}
670835
}
671-
}
836+
}

0 commit comments

Comments
 (0)