diff --git a/.gitmodules b/.gitmodules index 3accfbc0..20e8455c 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,3 @@ [submodule "staticdata"] path = staticdata - url = https://github.com/Earth-Restored/Solace.StaticData + url = https://github.com/LNLenost/ViennaDotNet.StaticData.git diff --git a/src/Solace.ApiServer/Controllers/EarthApi/BoostsController.cs b/src/Solace.ApiServer/Controllers/EarthApi/BoostsController.cs index 8cd571a8..93fc15e3 100644 --- a/src/Solace.ApiServer/Controllers/EarthApi/BoostsController.cs +++ b/src/Solace.ApiServer/Controllers/EarthApi/BoostsController.cs @@ -5,6 +5,7 @@ using Serilog; using System.Diagnostics; using System.Security.Claims; +using System.Globalization; using Solace.ApiServer.Exceptions; using Solace.ApiServer.Utils; using Solace.Common.Utils; @@ -36,6 +37,11 @@ private sealed record ActiveBoostInfo( Catalog.ItemsCatalogR.Item.BoostInfoR BoostInfo ); + private sealed record ActiveMiniFigInfo( + Boosts.ActiveMiniFig ActiveMiniFig, + Catalog.NFCBoostsCatalogR.MiniFig MiniFig + ); + [HttpGet("boosts")] public async Task> GetBoosts(CancellationToken cancellationToken) { @@ -60,8 +66,36 @@ public async Task> GetBoosts(Cancellation if (PruneBoostsAndUpdateProfile(boosts, profile, requestStartedOn, _catalog.ItemsCatalog)) { - await _earthDB.SaveChangesAsync(cancellationToken); - results.Profile = profile.Version; + results = await new EarthDB.Query(true) + .Get("boosts", playerId, typeof(Boosts)) + .Get("profile", playerId, typeof(Profile)) + .Then(results1 => + { + // I know this is ugly, we're making changes to the database in response to a GET request, but if we don't then the client won't correctly update the player health bar in the UI + + Boosts boosts = results1.Get("boosts"); + Profile profile = results1.Get("profile"); + + bool profileChanged = PruneBoostsAndUpdateProfile(boosts, profile, requestStartedOn, catalog.ItemsCatalog); + bool miniFigsChanged = boosts.PruneMiniFigs(requestStartedOn).Length > 0; + if (!profileChanged && !miniFigsChanged) + { + return new EarthDB.Query(false) + .Extra("boosts", boosts); + } + + var updateQuery = new EarthDB.Query(true) + .Update("boosts", playerId, boosts) + .Extra("boosts", boosts); + + if (profileChanged) + { + updateQuery.Update("profile", playerId, profile); + } + + return updateQuery; + }) + .ExecuteAsync(earthDB, cancellation); } Types.Boost.Boosts.Potion?[] potions = [.. boosts.ActiveBoosts.Select(activeBoost => @@ -135,32 +169,157 @@ public async Task> GetBoosts(Cancellation scenarioBoosts["death"] = [.. triggeredOnDeathBoosts]; } - BoostUtils.StatModiferValues statModiferValues = BoostUtils.GetActiveStatModifiers(boosts, requestStartedOn, _catalog.ItemsCatalog); + // The 0.33 client is fragile around partially implemented Boost Mini state. + // Keep NFC activation accepted, but do not surface minifig records/effects in the boosts menu yet. + Types.Boost.Boosts.MiniFig?[] miniFigs = new Types.Boost.Boosts.MiniFig?[5]; + Dictionary miniFigRecords = []; + + BoostUtils.StatModiferValues statModiferValues = BoostUtils.GetActiveStatModifiers(boosts, requestStartedOn, catalog.ItemsCatalog); + int tappableInteractionRadiusExtraMeters = statModiferValues.TappableInteractionRadiusExtraMeters; + int experiencePointRate = 0; + int itemExperiencePointRates = 0; + int attackMultiplier = statModiferValues.AttackMultiplier; + int defenseMultiplier = statModiferValues.DefenseMultiplier; + int miningSpeedMultiplier = statModiferValues.MiningSpeedMultiplier; + int maxPlayerHealthMultiplier = statModiferValues.MaxPlayerHealthMultiplier; + int craftingSpeedMultiplier = statModiferValues.CraftingSpeedMultiplier; + int smeltingSpeedMultiplier = statModiferValues.SmeltingSpeedMultiplier; + int foodMultiplier = statModiferValues.FoodMultiplier; var boostsResponse = new Types.Boost.Boosts( potions, - new Types.Boost.Boosts.MiniFig[5], + miniFigs, [.. activeEffects], scenarioBoosts, new Types.Boost.Boosts.StatusEffectsR( - statModiferValues.TappableInteractionRadiusExtraMeters > 0 ? statModiferValues.TappableInteractionRadiusExtraMeters + 70 : null, - null, - null, - statModiferValues.AttackMultiplier > 0 ? statModiferValues.AttackMultiplier + 100 : null, - statModiferValues.DefenseMultiplier > 0 ? statModiferValues.DefenseMultiplier + 100 : null, - statModiferValues.MiningSpeedMultiplier > 0 ? statModiferValues.MiningSpeedMultiplier + 100 : null, - statModiferValues.MaxPlayerHealthMultiplier > 0 ? 20 * statModiferValues.MaxPlayerHealthMultiplier / 100 + 20 : 20, - statModiferValues.CraftingSpeedMultiplier > 0 ? statModiferValues.CraftingSpeedMultiplier / 100 + 1 : null, - statModiferValues.SmeltingSpeedMultiplier > 0 ? statModiferValues.SmeltingSpeedMultiplier / 100 + 1 : null, - statModiferValues.FoodMultiplier > 0 ? (statModiferValues.FoodMultiplier + 100) / 100f : null + tappableInteractionRadiusExtraMeters > 0 ? tappableInteractionRadiusExtraMeters + 70 : null, + experiencePointRate > 0 ? experiencePointRate + 100 : null, + itemExperiencePointRates > 0 ? itemExperiencePointRates + 100 : null, + attackMultiplier > 0 ? attackMultiplier + 100 : null, + defenseMultiplier > 0 ? defenseMultiplier + 100 : null, + miningSpeedMultiplier > 0 ? miningSpeedMultiplier + 100 : null, + maxPlayerHealthMultiplier > 0 ? 20 * maxPlayerHealthMultiplier / 100 + 20 : 20, + craftingSpeedMultiplier > 0 ? craftingSpeedMultiplier / 100 + 1 : null, + smeltingSpeedMultiplier > 0 ? smeltingSpeedMultiplier / 100 + 1 : null, + foodMultiplier > 0 ? (foodMultiplier + 100) / 100f : null ), - [], - activeBoostsWithInfo.Count != 0 ? TimeFormatter.FormatTime(activeBoostsWithInfo.Values.Select(activeBoostInfo => activeBoostInfo.ActiveBoost.StartTime + activeBoostInfo.ActiveBoost.Duration).Min()) : null + miniFigRecords, + activeBoostsWithInfo.Count != 0 + ? TimeFormatter.FormatTime( + activeBoostsWithInfo.Values + .Select(activeBoostInfo => activeBoostInfo.ActiveBoost.StartTime + activeBoostInfo.ActiveBoost.Duration) + .Min() + ) + : null ); return EarthJson(boostsResponse, new EarthApiResponse.UpdatesResponse(results)); } + [HttpGet("boosts/players/{playerId}/latest")] + public Task> GetLatestBoosts(string playerId, CancellationToken cancellation) + => GetBoosts(cancellation); + + [HttpPost("boosts/minifigs/{productId}/{id}/activate")] + public async Task> ActivateMiniFig(string productId, string id, CancellationToken cancellationToken) + { + string? playerId = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (string.IsNullOrEmpty(playerId)) + { + return TypedResults.BadRequest(); + } + + string? normalizedProductId = NormalizeMiniFigProductId(productId); + string? normalizedTagProductId = NormalizeMiniFigProductId(id); + Catalog.NFCBoostsCatalogR.MiniFig? miniFig = normalizedProductId is null ? null : catalog.NfcBoostsCatalog.GetMiniFig(normalizedProductId); + string resolvedProductId = normalizedProductId ?? productId; + string tagId = id; + if (miniFig is null) + { + Catalog.NFCBoostsCatalogR.MiniFig? swappedMiniFig = normalizedTagProductId is null ? null : catalog.NfcBoostsCatalog.GetMiniFig(normalizedTagProductId); + if (swappedMiniFig is not null) + { + miniFig = swappedMiniFig; + resolvedProductId = normalizedTagProductId!; + tagId = productId; + } + } + + if (miniFig is null) + { + miniFig = catalog.NfcBoostsCatalog.MiniFigs.FirstOrDefault(static candidate => !candidate.Deprecated); + if (miniFig is not null) + { + resolvedProductId = miniFig.Id; + tagId = $"{productId}:{id}"; + Log.Information("Unknown NFC minifig product {ProductId} tag {TagId}; using fallback product {FallbackProductId}", productId, id, resolvedProductId); + } + } + + if (miniFig is null || miniFig.Deprecated) + { + return TypedResults.BadRequest(); + } + + long requestStartedOn = HttpContext.GetTimestamp(); + long duration = GetMiniFigDuration(miniFig); + + try + { + EarthDB.Results results = await new EarthDB.Query(true) + .Get("boosts", playerId, typeof(Boosts)) + .Then(results1 => + { + Boosts boosts = results1.Get("boosts"); + boosts.PruneMiniFigs(requestStartedOn); + + int slot = -1; + for (int index = 0; index < boosts.ActiveMiniFigs.Length; index++) + { + Boosts.ActiveMiniFig? activeMiniFig = boosts.ActiveMiniFigs[index]; + if (activeMiniFig is not null && (activeMiniFig.TagId == tagId || activeMiniFig.ProductId == resolvedProductId)) + { + slot = index; + break; + } + } + + if (slot == -1) + { + for (int index = 0; index < boosts.ActiveMiniFigs.Length; index++) + { + if (boosts.ActiveMiniFigs[index] is null) + { + slot = index; + break; + } + } + } + + if (slot == -1) + { + return new EarthDB.Query(false); + } + + boosts.ActiveMiniFigs[slot] = new Boosts.ActiveMiniFig(U.RandomUuid().ToString(), resolvedProductId, tagId, requestStartedOn, duration); + boosts.MiniFigRecords[tagId] = boosts.MiniFigRecords.TryGetValue(tagId, out Boosts.MiniFigRecord? existingRecord) + ? existingRecord with { LastSeen = requestStartedOn, Activations = existingRecord.Activations + 1 } + : new Boosts.MiniFigRecord(resolvedProductId, tagId, requestStartedOn, 1); + + return new EarthDB.Query(true) + .Update("boosts", playerId, boosts) + .Then(ActivityLogUtils.AddEntry(playerId, new ActivityLog.BoostActivatedEntry(requestStartedOn, resolvedProductId))); + }) + .ExecuteAsync(earthDB, cancellationToken); + + return EarthJson(null, new EarthApiResponse.UpdatesResponse(results)); + } + catch (EarthDB.DatabaseException exception) + { + throw new ServerErrorException(exception); + } + } + [HttpPost("boosts/potions/{itemId}/activate")] public async Task> ActivateBoost(string itemId, CancellationToken cancellationToken) { @@ -266,6 +425,7 @@ public async Task> ActivateBoost(string i } [HttpDelete("boosts/{instanceId}")] + [HttpDelete("boosts/{instanceId}/deactivate")] public async Task> DeactivateBoost(string instanceId, CancellationToken cancellationToken) { if (!TryGetAccountId(out var accountId)) @@ -287,7 +447,82 @@ public async Task> DeactivateBoost(string if (PruneBoostsAndUpdateProfile(boosts, profile, requestStartedOn, _catalog.ItemsCatalog)) { - profileChanged = true; + EarthDB.Results results = await new EarthDB.Query(true) + .Get("boosts", playerId, typeof(Boosts)) + .Get("profile", playerId, typeof(Profile)) + .Then(results1 => + { + Boosts boosts = results1.Get("boosts"); + Profile profile = results1.Get("profile"); + bool profileChanged = false; + + if (PruneBoostsAndUpdateProfile(boosts, profile, requestStartedOn, catalog.ItemsCatalog)) + { + profileChanged = true; + } + + Boosts.ActiveBoost? activeBoost = boosts.Get(instanceId); + Boosts.ActiveMiniFig? activeMiniFig = boosts.GetMiniFig(instanceId); + if (activeBoost is null && activeMiniFig is null) + { + return new EarthDB.Query(false); + } + + Catalog.ItemsCatalogR.Item? item = activeBoost is null ? null : catalog.ItemsCatalog.GetItem(activeBoost.ItemId); + Catalog.NFCBoostsCatalogR.MiniFig? miniFig = activeMiniFig is null ? null : catalog.NfcBoostsCatalog.GetMiniFig(activeMiniFig.ProductId); + if (activeBoost is not null && (item is null || item.BoostInfo is null || !item.BoostInfo.CanBeRemoved)) + { + return new EarthDB.Query(false); + } + + if (activeMiniFig is not null && (miniFig is null || !miniFig.BoostMetadata.CanBeRemoved)) + { + return new EarthDB.Query(false); + } + + for (int index = 0; index < boosts.ActiveBoosts.Length; index++) + { + var boost = boosts.ActiveBoosts[index]; + + if (boost is not null && boost.InstanceId == instanceId) + { + boosts.ActiveBoosts[index] = null; + } + } + + for (int index = 0; index < boosts.ActiveMiniFigs.Length; index++) + { + var boost = boosts.ActiveMiniFigs[index]; + + if (boost is not null && boost.InstanceId == instanceId) + { + boosts.ActiveMiniFigs[index] = null; + } + } + + if (item?.BoostInfo?.Effects.Any(effect => effect.Type is Catalog.ItemsCatalogR.Item.BoostInfoR.Effect.TypeE.HEALTH) == true + || miniFig?.BoostMetadata.Effects.Any(effect => effect.Type == "MaximumPlayerHealth") == true) + { + profileChanged = true; + int maxPlayerHealth = BoostUtils.GetMaxPlayerHealth(boosts, requestStartedOn, catalog.ItemsCatalog); + if (profile.Health > maxPlayerHealth) + { + profile.Health = maxPlayerHealth; + } + } + + var updateQuery = new EarthDB.Query(true); + updateQuery.Update("boosts", playerId, boosts); + if (profileChanged) + { + updateQuery.Update("profile", playerId, profile); + } + + return updateQuery; + }) + .ExecuteAsync(earthDB, cancellationToken); + + return EarthJson(null, new EarthApiResponse.UpdatesResponse(results)); } var activeBoost = boosts.Get(instanceId); @@ -353,4 +588,86 @@ private static bool PruneBoostsAndUpdateProfile(BoostsEF boosts, ProfileEF profi return profileChanged; } + + private static long GetMiniFigDuration(Catalog.NFCBoostsCatalogR.MiniFig miniFig) + { + string? duration = miniFig.BoostMetadata.ActiveDuration + ?? miniFig.BoostMetadata.Effects.FirstOrDefault(effect => !string.IsNullOrEmpty(effect.Duration))?.Duration; + + return duration is null + ? 10 * 60 * 1000 + : TimeFormatter.ParseDuration(duration); + } + + private static string? NormalizeMiniFigProductId(string value) + { + if (catalog.NfcBoostsCatalog.GetMiniFig(value) is not null) + { + return value; + } + + string payload = value; + const string pidMattelMarker = "pid.mattel/"; + int markerIndex = payload.IndexOf(pidMattelMarker, StringComparison.OrdinalIgnoreCase); + if (markerIndex >= 0) + { + payload = payload[(markerIndex + pidMattelMarker.Length)..]; + } + else if (Uri.TryCreate(payload, UriKind.Absolute, out Uri? uri) && + uri.Host.Equals("pid.mattel", StringComparison.OrdinalIgnoreCase)) + { + payload = uri.AbsolutePath.TrimStart('/'); + } + + payload = Uri.UnescapeDataString(payload).Trim(); + int queryIndex = payload.IndexOfAny(['?', '#']); + if (queryIndex >= 0) + { + payload = payload[..queryIndex]; + } + + if (payload.Length == 0) + { + return null; + } + + try + { + byte[] decoded = Convert.FromBase64String(AddBase64Padding(payload.Replace('-', '+').Replace('_', '/'))); + if (decoded.Length < 6 || decoded[0] != 0x02 || decoded[1] != 0x00) + { + return null; + } + + uint boostId = ((uint)decoded[2] << 24) | + ((uint)decoded[3] << 16) | + ((uint)decoded[4] << 8) | + decoded[5]; + string productId = boostId.ToString(CultureInfo.InvariantCulture); + return catalog.NfcBoostsCatalog.GetMiniFig(productId) is null ? null : productId; + } + catch (FormatException) + { + return null; + } + } + + private static string AddBase64Padding(string value) + { + int remainder = value.Length % 4; + return remainder == 0 ? value : value.PadRight(value.Length + 4 - remainder, '='); + } + + private static Effect NfcBoostEffectToApiResponse(Catalog.NFCBoostsCatalogR.EffectR effect) + => new Effect( + effect.Type, + effect.Duration, + effect.Value is null ? null : (int)Math.Round(effect.Value.Value), + effect.Unit, + effect.Targets, + effect.Items, + effect.ItemScenarios, + effect.Activation, + effect.ModifiesType + ); } diff --git a/src/Solace.ApiServer/Controllers/EarthApi/BuildplatesController.cs b/src/Solace.ApiServer/Controllers/EarthApi/BuildplatesController.cs index b95fd068..9d96c21f 100644 --- a/src/Solace.ApiServer/Controllers/EarthApi/BuildplatesController.cs +++ b/src/Solace.ApiServer/Controllers/EarthApi/BuildplatesController.cs @@ -11,6 +11,7 @@ using Solace.ApiServer.Types.Buildplates; using Solace.ApiServer.Types.Common; using Solace.ApiServer.Types.Inventory; +using Solace.ApiServer.Types.Tappables; using Solace.ApiServer.Utils; using Solace.Common.Utils; using Solace.DB; @@ -29,20 +30,11 @@ namespace Solace.ApiServer.Controllers.EarthApi; [Route("1/api/v{version:apiVersion}")] internal sealed class BuildplatesController : SolaceControllerBase { - private readonly EarthDbContext _earthDB; - private readonly BuildplateInstancesManager _buildplateInstancesManager; - private readonly Catalog _catalog; - private readonly TappablesManager _tappablesManager; - private readonly ObjectStoreClient _objectStore; - - public BuildplatesController(EarthDbContext earthDB, BuildplateInstancesManager buildplateInstancesManager, StaticData.StaticData staticData, TappablesManager tappablesManager, ObjectStoreClient objectStore) - { - _earthDB = earthDB; - _buildplateInstancesManager = buildplateInstancesManager; - _catalog = staticData.Catalog; - _tappablesManager = tappablesManager; - _objectStore = objectStore; - } + private static readonly SemaphoreSlim AdventureScrollLock = new(1, 1); + private static EarthDB earthDB => Program.DB; + private static BuildplateInstancesManager buildplateInstancesManager => Program.buildplateInstancesManager; + private static Catalog catalog => Program.staticData.Catalog; + private static TappablesManager tappablesManager => Program.tappablesManager; [HttpGet("buildplates")] public async Task> GetBuildplates() @@ -301,6 +293,15 @@ private sealed record EncounterInstanceRequest( string TileId ); + private sealed record AdventureScrollRequest( + Coordinate? Coordinate, + Coordinate? PlayerCoordinate, + float? Latitude, + float? Longitude, + float? Lat, + float? Lon + ); + [HttpPost("multiplayer/encounters/{encounterId}/instances")] public async Task> CreateEncounterInstance(Guid encounterId, CancellationToken cancellationToken) { @@ -316,6 +317,166 @@ public async Task> CreateAdventureInstance(string adventureId, CancellationToken cancellationToken) + { + string? playerId = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (string.IsNullOrEmpty(playerId)) + { + return TypedResults.BadRequest(); + } + + var adventureInstanceRequest = await Request.Body.AsJsonAsync(cancellationToken); + + return await GetNewAdventureBuildplateInstanceResponse(playerId, adventureId, adventureInstanceRequest?.TileId, tappablesManager, cancellationToken); + } + + [HttpPost("adventures/scrolls/{itemId}")] + public async Task> RedeemAdventureScroll(string itemId, CancellationToken cancellationToken) + { + string? playerId = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (string.IsNullOrEmpty(playerId)) + { + return TypedResults.BadRequest(); + } + + AdventureScrollRequest? request = await Request.Body.AsJsonAsync(cancellationToken); + if (request is null || !TryGetCoordinate(request, out float lat, out float lon)) + { + return TypedResults.BadRequest(); + } + + long requestStartedOn = HttpContext.GetTimestamp(); + + await AdventureScrollLock.WaitAsync(cancellationToken); + try + { + TappablesManager.Adventure? recentAtLocation = tappablesManager.GetRecentPlayerAdventureAtLocation(playerId, lat, lon, requestStartedOn); + if (recentAtLocation is not null) + { + return EarthJson(AdventureToActiveLocation(recentAtLocation)); + } + + Catalog.ItemsCatalogR.Item? catalogItem; + string? instanceId = null; + DB.Models.Player.Inventory inventory; + try + { + EarthDB.Results readResults = await new EarthDB.Query(false) + .Get("inventory", playerId, typeof(DB.Models.Player.Inventory)) + .ExecuteAsync(earthDB, cancellationToken); + inventory = readResults.Get("inventory"); + } + catch (EarthDB.DatabaseException exception) + { + throw new ServerErrorException(exception); + } + + catalogItem = catalog.ItemsCatalog.GetItem(itemId); + if (catalogItem is null) + { + (catalogItem, instanceId) = ResolveNonStackableByInstanceId(inventory, itemId); + } + + if (catalogItem is null || catalogItem.Type is not Catalog.ItemsCatalogR.Item.TypeE.ADVENTURE_SCROLL) + { + return TypedResults.NotFound(); + } + + string? templateId = Program.staticData.AdventuresConfig.TryPickTemplateForCrystalItem(catalogItem.Name, Random.Shared); + if (templateId is null) + { + return TypedResults.NotFound(); + } + + TappablesManager.Adventure? recentAdventure = tappablesManager.GetRecentPlayerAdventure(playerId, lat, lon, templateId, requestStartedOn); + if (recentAdventure is not null) + { + return EarthJson(AdventureToActiveLocation(recentAdventure)); + } + + DB.Models.Player.Inventory updatedInventory = inventory.Copy(); + bool consumed = instanceId is null + ? updatedInventory.TakeItems(catalogItem.Id, 1) + : updatedInventory.TakeItems(catalogItem.Id, [instanceId]) is not null; + if (!consumed) + { + return TypedResults.BadRequest(); + } + + try + { + await new EarthDB.Query(true) + .Update("inventory", playerId, updatedInventory) + .ExecuteAsync(earthDB, cancellationToken); + } + catch (EarthDB.DatabaseException exception) + { + throw new ServerErrorException(exception); + } + + string rarity = catalogItem.Rarity.ToString(); + TappablesManager.Adventure adventure = tappablesManager.PlacePlayerAdventure( + playerId, + lat, + lon, + requestStartedOn, + 60 * 60 * 1000, + AdventureMapIcons.ToClientMapIcon(catalogItem.Name, rarity), + Enum.Parse(rarity), + templateId); + + PrewarmAdventureInstance(playerId, adventure); + + return EarthJson(AdventureToActiveLocation(adventure)); + } + finally + { + AdventureScrollLock.Release(); + } + } + + private static void PrewarmAdventureInstance(string playerId, TappablesManager.Adventure adventure) + { + _ = Task.Run(async () => + { + try + { + await buildplateInstancesManager.RequestBuildplateInstance( + playerId, + adventure.Id, + adventure.AdventureBuildplateId, + BuildplateInstancesManager.InstanceType.PLAYER_ADVENTURE, + adventure.SpawnTime + adventure.ValidFor, + false); + } + catch (Exception exception) + { + Log.Warning(exception, "Could not prewarm adventure instance {AdventureId}", adventure.Id); + } + }); + } + + [HttpGet("adventures/scrolls")] + public Results GetActiveAdventureScrolls() + { + string? playerId = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (string.IsNullOrEmpty(playerId)) + { + return TypedResults.BadRequest(); + } + + long requestStartedOn = HttpContext.GetTimestamp(); + ActiveLocation[] activeLocations = [.. tappablesManager.GetAllPlayerAdventures(playerId) + .Where(adventure => adventure.SpawnTime + adventure.ValidFor > requestStartedOn) + .OrderBy(adventure => adventure.SpawnTime) + .Take(1) + .Select(AdventureToActiveLocation)]; + + return EarthJson(activeLocations); + } + // TODO: should we restrict this to matching player ID? [HttpGet("multiplayer/partitions/{partitionId}/instances/{instanceId}")] #pragma warning disable IDE0060 // Remove unused parameter @@ -333,13 +494,25 @@ public async Task> GetInstanceS return TypedResults.NotFound(); } - var buildplate = await _earthDB.PlayerBuildplates - .AsNoTracking() - .FirstOrDefaultAsync(buildplate => buildplate.Id == instanceInfo.BuildplateId && buildplate.AccountId == accountId, cancellationToken: cancellationToken); - - if (buildplate is null) + if (instanceInfo.Type is BuildplateInstancesManager.InstanceType.BUILD or BuildplateInstancesManager.InstanceType.PLAY) { - return TypedResults.NotFound(); + Buildplates.Buildplate? buildplate; + try + { + EarthDB.Results results = await new EarthDB.Query(false) + .Get("buildplates", playerId, typeof(Buildplates)) + .ExecuteAsync(earthDB, cancellationToken); + buildplate = results.Get("buildplates").GetBuildplate(instanceInfo.BuildplateId); + } + catch (EarthDB.DatabaseException ex) + { + throw new ServerErrorException(ex); + } + + if (buildplate is null) + { + return TypedResults.NotFound(); + } } // TODO: the client is supposed to poll until the buildplate server is ready, but instead it just crashes if we tell it that the buildplate server is not ready yet @@ -391,7 +564,7 @@ private async Task> GetNewAdventureBuildplateInstanceResponse(string playerId, string adventureId, string? tileId, TappablesManager tappablesManager, CancellationToken cancellationToken) + { + TappablesManager.Adventure? adventure = tappablesManager.GetPlayerAdventureWithId(playerId, adventureId, tileId); + if (adventure is null) + { + return TypedResults.NotFound(); + } + + string? instanceId = await buildplateInstancesManager.RequestBuildplateInstance(playerId, adventureId, adventure.AdventureBuildplateId, BuildplateInstancesManager.InstanceType.PLAYER_ADVENTURE, adventure.SpawnTime + adventure.ValidFor, false); + + if (instanceId is null) + { + return TypedResults.InternalServerError(); + } + + BuildplateInstancesManager.InstanceInfo? instanceInfo = await WaitForInstanceReadyAsync(instanceId, cancellationToken); + if (instanceInfo is null) + { + return TypedResults.InternalServerError(); + } + + BuildplateInstance? buildplateInstance = await InstanceInfoToApiResponse(instanceInfo, cancellationToken); + if (buildplateInstance is null) + { + return TypedResults.InternalServerError(); + } + + return EarthJson(buildplateInstance); + } + + private static async Task WaitForInstanceReadyAsync(string instanceId, CancellationToken cancellationToken) + { + BuildplateInstancesManager.InstanceInfo? instanceInfo; + int waitCount = 0; + do + { + instanceInfo = buildplateInstancesManager.GetInstanceInfo(instanceId); + if (instanceInfo is null || instanceInfo.ShuttingDown) + { + return null; + } + + if (!instanceInfo.Ready) + { + await Task.Delay(1000, cancellationToken); + waitCount++; + } + } + while (!instanceInfo.Ready && waitCount < 90); + + return instanceInfo.Ready ? instanceInfo : null; + } + + private static bool TryGetCoordinate(AdventureScrollRequest request, out float lat, out float lon) + { + Coordinate? coordinate = request.Coordinate ?? request.PlayerCoordinate; + if (coordinate is not null) + { + lat = coordinate.Latitude; + lon = coordinate.Longitude; + return true; + } + + lat = request.Latitude ?? request.Lat ?? 0; + lon = request.Longitude ?? request.Lon ?? 0; + return (request.Latitude is not null || request.Lat is not null) && (request.Longitude is not null || request.Lon is not null); + } + + private static (Catalog.ItemsCatalogR.Item? Item, string? InstanceId) ResolveNonStackableByInstanceId(DB.Models.Player.Inventory inventory, string instanceId) + { + foreach (DB.Models.Player.Inventory.NonStackableItem item in inventory.NonStackableItems) + { + if (item.Instances.Any(instance => instance.InstanceId == instanceId)) + { + return (catalog.ItemsCatalog.GetItem(item.Id), instanceId); + } + } + + return (null, null); + } + + private static ActiveLocation AdventureToActiveLocation(TappablesManager.Adventure adventure) + => new( + adventure.Id, + TappablesManager.LocationToTileId(adventure.Lat, adventure.Lon), + new Coordinate(adventure.Lat, adventure.Lon), + TimeFormatter.FormatTime(adventure.SpawnTime), + TimeFormatter.FormatTime(adventure.SpawnTime + adventure.ValidFor), + ActiveLocation.TypeE.PLAYER_ADVENTURE, + adventure.Icon, + new ActiveLocation.MetadataR(adventure.Id, Enum.Parse(adventure.Rarity.ToString())), + new ActiveLocation.TappableMetadataR(Enum.Parse(adventure.Rarity.ToString())), + new ActiveLocation.EncounterMetadataR( + ActiveLocation.EncounterMetadataR.EncounterTypeE.SHORT_4X4_PEACEFUL, + adventure.Id, + adventure.AdventureBuildplateId, + ActiveLocation.EncounterMetadataR.AnchorStateE.OFF, + "", + "")); + [JsonConverter(typeof(JsonStringEnumConverter))] private enum Source { @@ -486,6 +759,7 @@ private enum Source BuildplateInstancesManager.InstanceType.SHARED_BUILD => (true, BuildplateInstance.GameplayMetadataR.GameplayModeE.SHARED_BUILDPLATE_PLAY, Source.SHARED), BuildplateInstancesManager.InstanceType.SHARED_PLAY => (true, BuildplateInstance.GameplayMetadataR.GameplayModeE.SHARED_BUILDPLATE_PLAY, Source.SHARED), BuildplateInstancesManager.InstanceType.ENCOUNTER => (true, BuildplateInstance.GameplayMetadataR.GameplayModeE.ENCOUNTER, Source.ENCOUNTER), + BuildplateInstancesManager.InstanceType.PLAYER_ADVENTURE => (true, BuildplateInstance.GameplayMetadataR.GameplayModeE.PLAYER_ADVENTURE, Source.ENCOUNTER), _ => throw new UnreachableException(), }; @@ -550,8 +824,8 @@ private enum Source return new BuildplateInstance( instanceInfo.InstanceId, - Guid.Empty, - "d.projectearth.dev", // TODO + "00000000-0000-0000-0000-000000000000", + "67e.duckdns.org", instanceInfo.Address, instanceInfo.Port, instanceInfo.Ready, @@ -585,22 +859,32 @@ private enum Source private sealed record BuildplateGeometry(int Size, int Offset, int Scale); - private async Task GetEncounterBuildplateGeometry(Guid buildplateId, CancellationToken cancellationToken) + private static async Task GetEncounterBuildplateGeometry(string buildplateId, CancellationToken cancellationToken) { - var encounterBuildplate = await _earthDB.EncounterBuildplates - .AsNoTracking() - .FirstOrDefaultAsync(encounterBuildplate => encounterBuildplate.Id == buildplateId, cancellationToken); - if (encounterBuildplate is not null) + try { - return new BuildplateGeometry(encounterBuildplate.Size, encounterBuildplate.Offset, encounterBuildplate.Scale); - } + EarthDB.Results results = await new EarthDB.Query(false) + .Get("encounterBuildplates", "", typeof(EncounterBuildplates)) + .ExecuteAsync(earthDB, cancellationToken); - var templateBuildplate = await _earthDB.TemplateBuildplates - .AsNoTracking() - .FirstOrDefaultAsync(templateBuildplate => templateBuildplate.Id == buildplateId, cancellationToken); - return templateBuildplate is null - ? null - : new BuildplateGeometry(templateBuildplate.Size, templateBuildplate.Offset, templateBuildplate.Scale); + EncounterBuildplates.EncounterBuildplate? encounterBuildplate = results.Get("encounterBuildplates").GetEncounterBuildplate(buildplateId); + if (encounterBuildplate is not null) + { + return new BuildplateGeometry(encounterBuildplate.Size, encounterBuildplate.Offset, encounterBuildplate.Scale); + } + + EarthDB.ObjectResults objectResults = await new EarthDB.ObjectQuery(false) + .GetBuildplate(buildplateId) + .ExecuteAsync(earthDB, cancellationToken); + TemplateBuildplate? templateBuildplate = objectResults.GetBuildplate(buildplateId); + return templateBuildplate is null + ? null + : new BuildplateGeometry(templateBuildplate.Size, templateBuildplate.Offset, templateBuildplate.Scale); + } + catch (EarthDB.DatabaseException exception) + { + throw new ServerErrorException(exception); + } } private sealed record SharedBuildplateInstanceRequest( diff --git a/src/Solace.ApiServer/Controllers/EarthApi/CatalogController.cs b/src/Solace.ApiServer/Controllers/EarthApi/CatalogController.cs index aad4dcc3..0718d244 100644 --- a/src/Solace.ApiServer/Controllers/EarthApi/CatalogController.cs +++ b/src/Solace.ApiServer/Controllers/EarthApi/CatalogController.cs @@ -27,25 +27,23 @@ namespace Solace.ApiServer.Controllers.EarthApi; internal sealed class CatalogController : SolaceControllerBase { private readonly Catalog _catalog; - private readonly CatalogResponseCacheService _responseCache; - public CatalogController(StaticData.StaticData staticData, CatalogResponseCacheService responseCache) + public CatalogController(StaticData.StaticData staticData) { _catalog = staticData.Catalog; - _responseCache = responseCache; } [HttpGet("inventory/catalogv3")] public ContentHttpResult GetItemsCatalog() - => EarthJson(_responseCache.GetItemsCatalog()); + => EarthJson(MakeItemsCatalogApiResponse(_catalog)); [HttpGet("recipes")] public ContentHttpResult GetRecipeCatalog() - => EarthJson(_responseCache.GetRecipeCatalog()); + => EarthJson(MakeRecipesCatalogApiResponse(_catalog)); [HttpGet("journal/catalog")] public ContentHttpResult GetJournalCatalog() - => EarthJson(_responseCache.GetJournalCatalog()); + => EarthJson(MakeJournalCatalogApiResponse(_catalog)); [HttpGet("products/catalog")] public ContentHttpResult GetNFCBoostsCatalog() @@ -61,7 +59,7 @@ public async Task GetProductInfo(CancellationToken cancellati requestedProductIds = await ReadRequestedProductIdsAsync(Request.Body, cancellationToken); } - NFCBoost[] products = MakeNFCBoostsCatalogApiResponse(_catalog); + NFCBoost[] products = MakeNFCBoostsCatalogApiResponse(catalog); NFCBoost[] matchingProducts = requestedProductIds.Count == 0 ? products : [.. products.Where(product => requestedProductIds.Contains(product.Id))]; @@ -78,6 +76,361 @@ public async Task GetProductInfo(CancellationToken cancellati }); } + // TODO: cache these? + private static ItemsCatalog MakeItemsCatalogApiResponse(Catalog catalog) + { + ItemsCatalog.ItemR[] items = [.. catalog.ItemsCatalog.Items.Select(item => + { + string categoryString = item.Category switch + { + CICICategory.CONSTRUCTION => "Construction", + CICICategory.EQUIPMENT => "Equipment", + CICICategory.ITEMS => "Items", + CICICategory.MOBS => "Mobs", + CICICategory.NATURE => "Nature", + CICICategory.BOOST_ADVENTURE_XP => "adventurexp", + CICICategory.BOOST_CRAFTING => "crafting", + CICICategory.BOOST_DEFENSE => "defense", + CICICategory.BOOST_EATING => "eating", + CICICategory.BOOST_HEALTH => "maxplayerhealth", + CICICategory.BOOST_HOARDING => "hoarding", + CICICategory.BOOST_ITEM_XP => "itemxp", + CICICategory.BOOST_MINING_SPEED => "miningspeed", + CICICategory.BOOST_RETENTION => "retention", + CICICategory.BOOST_SMELTING => "smelting", + CICICategory.BOOST_STRENGTH => "strength", + CICICategory.BOOST_TAPPABLE_RADIUS => "tappableRadius", + _ => throw new UnreachableException(), + }; + + string typeString = item.Type switch + { + CICIType.BLOCK => "Block", + CICIType.ITEM => "Item", + CICIType.TOOL => "Tool", + CICIType.MOB => "Mob", + CICIType.ENVIRONMENT_BLOCK => "EnvironmentBlock", + CICIType.BOOST => "Boost", + CICIType.ADVENTURE_SCROLL => "AdventureScroll", + _ => throw new UnreachableException(), + }; + + string useTypeString = item.UseType switch + { + CICIUseType.NONE => "None", + + CICIUseType.BUILD => "Build", + CICIUseType.BUILD_ATTACK => "BuildAttack", + CICIUseType.INTERACT => "Interact", + CICIUseType.INTERACT_AND_BUILD => "InteractAndBuild", + CICIUseType.DESTROY => "Destroy", + CICIUseType.USE => "Use", + CICIUseType.CONSUME => "Consume", + _ => throw new UnreachableException(), + }; + + string alternativeUseTypeString = item.AlternativeUseType switch + { + CICIUseType.NONE => "None", + + CICIUseType.BUILD => "Build", + CICIUseType.BUILD_ATTACK => "BuildAttack", + CICIUseType.INTERACT => "Interact", + CICIUseType.INTERACT_AND_BUILD => "InteractAndBuild", + CICIUseType.DESTROY => "Destroy", + CICIUseType.USE => "Use", + CICIUseType.CONSUME => "Consume", + _ => throw new UnreachableException(), + }; + + int health; + if (item.BlockInfo is not null) + { + health = item.BlockInfo.BreakingHealth; + } + else if (item.ToolInfo is not null) + { + health = item.ToolInfo.MaxWear; + } + else if (item.MobInfo is not null) + { + health = item.MobInfo.Health; + } + else + { + health = 0; + } + + int blockDamage; + if (item.ToolInfo is not null) + { + blockDamage = item.ToolInfo.BlockDamage; + } + else + { + blockDamage = 0; + } + + int mobDamage; + if (item.ToolInfo is not null) + { + mobDamage = item.ToolInfo.MobDamage; + } + else if (item.ProjectileInfo is not null) + { + mobDamage = item.ProjectileInfo.MobDamage; + } + else + { + mobDamage = 0; + } + + ItemsCatalog.ItemR.ItemData.BlockMetadataR? blockMetadata; + if (item.BlockInfo is not null) + { + blockMetadata = new ItemsCatalog.ItemR.ItemData.BlockMetadataR(item.BlockInfo.BreakingHealth, item.BlockInfo.EfficiencyCategory); + } + else if (item.MobInfo is not null) + { + blockMetadata = new ItemsCatalog.ItemR.ItemData.BlockMetadataR(item.MobInfo.Health, "instant"); + } + else + { + blockMetadata = null; + } + + BoostMetadata? boostMetadata; + if (item.BoostInfo is not null) + { + string boostTypeString = item.BoostInfo.Type switch + { + CICIBIType.POTION => "Potion", + CICIBIType.INVENTORY_ITEM => "InventoryItem", + _ => throw new UnreachableException(), + }; + + string boostAttributeString = item.BoostInfo.Effects[0].Type switch + { + CICIBIEType.ADVENTURE_XP => "ItemExperiencePoints", + CICIBIEType.CRAFTING => "Crafting", + CICIBIEType.DEFENSE => "Defense", + CICIBIEType.EATING => "Eating", + CICIBIEType.HEALING => "Healing", + CICIBIEType.HEALTH => "MaximumPlayerHealth", + CICIBIEType.ITEM_XP => "ItemExperiencePoints", + CICIBIEType.MINING_SPEED => "MiningSpeed", + CICIBIEType.RETENTION_BACKPACK or CICIBIEType.RETENTION_HOTBAR or CICIBIEType.RETENTION_XP => "Retention", + CICIBIEType.SMELTING => "Smelting", + CICIBIEType.STRENGTH => "Strength", + CICIBIEType.TAPPABLE_RADIUS => "TappableInteractionRadius", + _ => throw new UnreachableException(), + }; + + boostMetadata = new BoostMetadata( + item.BoostInfo.Name, + boostTypeString, + boostAttributeString, + false, + item.BoostInfo.CanBeRemoved, + TimeFormatter.FormatDuration(item.BoostInfo.Duration), + true, + item.BoostInfo.Level, + [.. item.BoostInfo.Effects.Select(effect => BoostUtils.BoostEffectToApiResponse(effect, item.BoostInfo.Duration))], + item.BoostInfo.TriggeredOnDeath ? "Death" : null, + null + ); + } + else + { + boostMetadata = null; + } + + ItemsCatalog.ItemR.ItemData.JournalMetadataR? journalMetadata; + if (item.JournalEntry is not null) + { + string behaviorString = item.JournalEntry.Behavior switch + { + CICIJEBehavior.NONE => "None", + CICIJEBehavior.PASSIVE => "Passive", + CICIJEBehavior.HOSTILE => "Hostile", + CICIJEBehavior.NEUTRAL => "Neutral", + _ => throw new UnreachableException(), + }; + + string biomeString = item.JournalEntry.Biome switch + { + CICIJEBiome.NONE => "None", + CICIJEBiome.OVERWORLD => "Overworld", + CICIJEBiome.NETHER => "Hell", + CICIJEBiome.BIRCH_FOREST => "BirchForest", + CICIJEBiome.DESERT => "Desert", + CICIJEBiome.FLOWER_FOREST => "FlowerForest", + CICIJEBiome.FOREST => "Forest", + CICIJEBiome.ICE_PLAINS => "IcePlains", + CICIJEBiome.JUNGLE => "Jungle", + CICIJEBiome.MESA => "Mesa", + CICIJEBiome.MUSHROOM_ISLAND => "MushroomIsland", + CICIJEBiome.OCEAN => "Ocean", + CICIJEBiome.PLAINS => "Plains", + CICIJEBiome.RIVER => "River", + CICIJEBiome.ROOFED_FOREST => "RoofedForest", + CICIJEBiome.SAVANNA => "Savanna", + CICIJEBiome.SUNFLOWER_PLAINS => "SunFlowerPlains", + CICIJEBiome.SWAMP => "Swampland", + CICIJEBiome.TAIGA => "Taiga", + CICIJEBiome.WARM_OCEAN => "WarmOcean", + _ => throw new UnreachableException(), + }; + + journalMetadata = new ItemsCatalog.ItemR.ItemData.JournalMetadataR( + item.JournalEntry.Group, + item.Experience.Journal, + item.JournalEntry.Order, + behaviorString, + biomeString + ); + } + else + { + journalMetadata = null; + } + + return new ItemsCatalog.ItemR( + item.Id, + new ItemsCatalog.ItemR.ItemData( + item.Name, + item.Aux, + typeString, + useTypeString, + 0, + item.ConsumeInfo?.Heal, + 0, + mobDamage, + blockDamage, + health, + blockMetadata, + new ItemsCatalog.ItemR.ItemData.ItemMetadataR( + useTypeString, + alternativeUseTypeString, + mobDamage, + blockDamage, + null, + 0, + item.ConsumeInfo is not null ? item.ConsumeInfo.Heal : 0, + item.ToolInfo?.EfficiencyCategory, + health + ), + boostMetadata, + journalMetadata, + item.JournalEntry is not null && item.JournalEntry.Sound is not null ? new ItemsCatalog.ItemR.ItemData.AudioMetadataR( + new Dictionary() { ["journal"] = item.JournalEntry.Sound }, + item.JournalEntry.Sound + ) : null, + new Dictionary() + ), + categoryString, + Types.Common.Rarity.FromStaticData(item.Rarity), + 1, + item.Stackable, + item.FuelInfo is not null ? new Types.Common.BurnRate(item.FuelInfo.BurnTime, item.FuelInfo.HeatPerSecond) : null, + item.FuelInfo is not null && item.FuelInfo.ReturnItemId is not null ? [new ItemsCatalog.ItemR.ReturnItem(item.FuelInfo.ReturnItemId, 1)] : [], + item.ConsumeInfo is not null && item.ConsumeInfo.ReturnItemId is not null ? [new ItemsCatalog.ItemR.ReturnItem(item.ConsumeInfo.ReturnItemId, 1)] : [], + item.Experience.Tappable, + new Dictionary() { ["tappable"] = item.Experience.Tappable, ["encounter"] = item.Experience.Encounter, ["crafting"] = item.Experience.Crafting }, + false + ); + })]; + + Dictionary efficiencyCategories = []; + foreach (Catalog.ItemEfficiencyCategoriesCatalogR.EfficiencyCategory efficiencyCategory in catalog.ItemEfficiencyCategoriesCatalog.EfficiencyCategories) + { + efficiencyCategories[efficiencyCategory.Name] = new ItemsCatalog.EfficiencyCategory( + new ItemsCatalog.EfficiencyCategory.EfficiencyMapR( + efficiencyCategory.Hand, + efficiencyCategory.Hoe, + efficiencyCategory.Axe, + efficiencyCategory.Shovel, + efficiencyCategory.Pickaxe_1, + efficiencyCategory.Pickaxe_2, + efficiencyCategory.Pickaxe_3, + efficiencyCategory.Pickaxe_4, + efficiencyCategory.Pickaxe_5, + efficiencyCategory.Sword, + efficiencyCategory.Sheers + ) + ); + } + + return new ItemsCatalog(items, efficiencyCategories); + } + + private static RecipesCatalog MakeRecipesCatalogApiResponse(Catalog catalog) + { + RecipesCatalog.CraftingRecipe[] crafting = [.. catalog.RecipesCatalog.Crafting.Select(recipe => + { + string categoryString = recipe.Category switch + { + CRCCRCategory.CONSTRUCTION => "Construction", + CRCCRCategory.EQUIPMENT => "Equipment", + CRCCRCategory.ITEMS => "Items", + CRCCRCategory.NATURE => "Nature", + _ => throw new UnreachableException(), + }; + + return new RecipesCatalog.CraftingRecipe( + recipe.Id, + categoryString, + TimeFormatter.FormatDuration(recipe.Duration * 1000), + [.. recipe.Ingredients.Select(ingredient => new RecipesCatalog.CraftingRecipe.Ingredient(ingredient.PossibleItemIds, ingredient.Count))], + new RecipesCatalog.CraftingRecipe.OutputR(recipe.Output.ItemId, recipe.Output.Count), + [.. recipe.ReturnItems.Select(returnItem => new RecipesCatalog.CraftingRecipe.ReturnItem(returnItem.ItemId, returnItem.Count))], + false + ); + })]; + + RecipesCatalog.SmeltingRecipe[] smelting = [.. catalog.RecipesCatalog.Smelting.Select(recipe => + { + return new RecipesCatalog.SmeltingRecipe( + recipe.Id, + recipe.HeatRequired, + recipe.Input, + new RecipesCatalog.SmeltingRecipe.OutputR(recipe.Output, 1), + recipe.ReturnItemId is not null ? [new RecipesCatalog.SmeltingRecipe.ReturnItem(recipe.ReturnItemId, 1)] : [], + false + ); + })]; + + return new RecipesCatalog(crafting, smelting); + } + + private static JournalCatalog MakeJournalCatalogApiResponse(Catalog catalog) + { + Dictionary items = []; + foreach (Catalog.ItemJournalGroupsCatalogR.JournalGroup group in catalog.ItemJournalGroupsCatalog.Groups) + { + string parentCollectionString = group.ParentCollection switch + { + CIJGCJGParentCollection.BLOCKS => "Blocks", + CIJGCJGParentCollection.ITEMS_CRAFTED => "ItemsCrafted", + CIJGCJGParentCollection.ITEMS_SMELTED => "ItemsSmelted", + CIJGCJGParentCollection.MOBS => "Mobs", + _ => throw new UnreachableException(), + }; + + items[group.Name] = new JournalCatalog.Item( + group.Id, + parentCollectionString, + group.Order, + group.Order, + group.DefaultSound, + false, + "200526.173531" + ); + } + + return new JournalCatalog(items); + } + private static NFCBoost[] MakeNFCBoostsCatalogApiResponse(Catalog catalog) => [.. catalog.NfcBoostsCatalog.MiniFigs.Select(miniFig => new NFCBoost( miniFig.Id, @@ -86,12 +439,12 @@ private static NFCBoost[] MakeNFCBoostsCatalogApiResponse(Catalog catalog) new Types.Common.Rewards( miniFig.Rewards.Rubies, miniFig.Rewards.ExperiencePoints, - miniFig.Rewards.Level, - [.. (miniFig.Rewards.Inventory ?? []).Select(item => new Types.Common.Rewards.Item(item.Id, item.Amount))], - miniFig.Rewards.Buildplates ?? [], - [.. (miniFig.Rewards.Challenges ?? []).Select(challenge => new Types.Common.Rewards.Challenge(challenge.Id))], - miniFig.Rewards.PersonaItems ?? [], - [.. (miniFig.Rewards.UtilityBlocks ?? []).Select(_ => new Types.Common.Rewards.UtilityBlock())] + null, + [], + [], + [], + [], + [] ), new BoostMetadata( miniFig.BoostMetadata.Name, @@ -102,7 +455,7 @@ private static NFCBoost[] MakeNFCBoostsCatalogApiResponse(Catalog catalog) miniFig.BoostMetadata.ActiveDuration, miniFig.BoostMetadata.Additive, miniFig.BoostMetadata.Level, - [.. miniFig.BoostMetadata.Effects.Select(effect => new Effect( + [.. miniFig.BoostMetadata.Effects.Select(effect => new Types.Common.Effect( effect.Type, effect.Duration, effect.Value is null ? null : (int)Math.Round(effect.Value.Value), diff --git a/src/Solace.ApiServer/Controllers/EarthApi/CdnTileController.cs b/src/Solace.ApiServer/Controllers/EarthApi/CdnTileController.cs index d730c2c9..38719854 100644 --- a/src/Solace.ApiServer/Controllers/EarthApi/CdnTileController.cs +++ b/src/Solace.ApiServer/Controllers/EarthApi/CdnTileController.cs @@ -10,7 +10,7 @@ namespace Solace.ApiServer.Controllers.EarthApi; [ApiVersion("1.1")] [Route("cdn/tile/16/{_}/{tilePos1}_{tilePos2}_16.png")] -[ResponseCache(Duration = 11200)] +[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)] internal sealed class CdnTileController : SolaceControllerBase { private readonly EarthDbContext _earthDb; @@ -27,14 +27,14 @@ public CdnTileController(EarthDbContext earthDb, EventBusClient eventBus, Object [HttpGet] public async Task> GetTile(int _, int tilePos1, int tilePos2, CancellationToken cancellationToken) // _ used because we dont care :| { - if (!await TileUtils.TryWriteTile(tilePos1, tilePos2, Response.Body, _earthDb, _eventBus, _objectStore, cancellationToken)) + Response.Headers.ContentType = "image/png"; + if (!await TileUtils.TryWriteTile(tilePos1, tilePos2, Response.Body, cancellationToken)) { return TypedResults.NotFound(); } var cd = new System.Net.Mime.ContentDisposition { FileName = $"{tilePos1}_{tilePos2}_16.png", Inline = true }; Response.Headers.Append("Content-Disposition", cd.ToString()); - Response.Headers.ContentType = "application/octet-stream"; return TypedResults.Empty; } diff --git a/src/Solace.ApiServer/Controllers/EarthApi/ChallengeActionsController.cs b/src/Solace.ApiServer/Controllers/EarthApi/ChallengeActionsController.cs index e1f4b0e8..e550a7dc 100644 --- a/src/Solace.ApiServer/Controllers/EarthApi/ChallengeActionsController.cs +++ b/src/Solace.ApiServer/Controllers/EarthApi/ChallengeActionsController.cs @@ -2,8 +2,11 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; +using Solace.DB; using Solace.ApiServer.Utils; using Solace.Common.Utils; +using System.Security.Claims; +using ApiRewards = Solace.ApiServer.Types.Common.Rewards; using RedeemRewards = Solace.ApiServer.Utils.Rewards; namespace Solace.ApiServer.Controllers.EarthApi; @@ -15,17 +18,51 @@ internal sealed class ChallengeActionsController : SolaceControllerBase { [HttpPost("{challengeId}/modifyState")] [HttpPut("{challengeId}/modifyState")] - public ContentHttpResult ModifyState(string challengeId) + public async Task> ModifyState(string challengeId, CancellationToken cancellationToken) { + string? playerId = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (string.IsNullOrWhiteSpace(playerId)) + { + return TypedResults.BadRequest(); + } + long now = HttpContext.GetTimestamp(); - var updates = new EarthApiResponse.UpdatesResponse(); + ApiRewards? apiRewards = ChallengesController.GetSeasonChallengeRewards(challengeId); + RedeemRewards rewards = ToRedeemRewards(apiRewards); + + EarthDB.Results results = await new EarthDB.Query(true) + .Get("challenges", playerId, typeof(ChallengeProgressVersion)) + .Then(queryResults => + { + ChallengeProgressVersion progress = queryResults.Get("challenges"); + progress.EnsureDate(now); + progress.ClaimedChallengeIds ??= []; + + bool firstClaim = progress.ClaimedChallengeIds.Add(challengeId); + progress.ActiveSeasonId = ChallengesController.ActiveSeasonId; + progress.ActiveSeasonChallengeId = ChallengesController.SelectActiveSeasonChallengeId(progress, progress.ActiveSeasonChallengeId); + progress.UpdatedAt = now; + + var query = new EarthDB.Query(true) + .Update("challenges", playerId, progress); + + if (firstClaim && apiRewards is not null) + { + query.Then(rewards.ToRedeemQuery(playerId, now, Program.staticData), false); + } + + return query; + }) + .ExecuteAsync(Program.DB, cancellationToken); + + var updates = new EarthApiResponse.UpdatesResponse(results); updates.Map["challenges"] = (int)(now / 1000); return EarthJson(new Dictionary { ["challengeId"] = challengeId, ["state"] = "Claimed", - ["rewards"] = new RedeemRewards().ToApiResponse(), + ["rewards"] = apiRewards ?? rewards.ToApiResponse(), ["updates"] = new Dictionary() }, updates); } @@ -48,10 +85,31 @@ public ContentHttpResult ResetChallenges() [HttpPost("continuous/{id}/remove")] [HttpDelete("continuous/{id}/remove")] - public ContentHttpResult RemoveContinuousChallenge(string id) + public async Task> RemoveContinuousChallenge(string id, CancellationToken cancellationToken) { + string? playerId = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (string.IsNullOrWhiteSpace(playerId)) + { + return TypedResults.BadRequest(); + } + long now = HttpContext.GetTimestamp(); - var updates = new EarthApiResponse.UpdatesResponse(); + EarthDB.Results results = await new EarthDB.Query(true) + .Get("challenges", playerId, typeof(ChallengeProgressVersion)) + .Then(queryResults => + { + ChallengeProgressVersion progress = queryResults.Get("challenges"); + progress.EnsureDate(now); + progress.RemovedContinuousChallengeIds ??= []; + progress.RemovedContinuousChallengeIds.Add(id); + progress.UpdatedAt = now; + + return new EarthDB.Query(true) + .Update("challenges", playerId, progress); + }) + .ExecuteAsync(Program.DB, cancellationToken); + + var updates = new EarthApiResponse.UpdatesResponse(results); updates.Map["challenges"] = (int)(now / 1000); return EarthJson(new Dictionary @@ -60,4 +118,40 @@ public ContentHttpResult RemoveContinuousChallenge(string id) ["updates"] = new Dictionary() }, updates); } + + private static RedeemRewards ToRedeemRewards(ApiRewards? rewards) + { + var result = new RedeemRewards(); + if (rewards is null) + { + return result; + } + + if (rewards.Rubies is > 0) + { + result.AddRubies(rewards.Rubies.Value); + } + + if (rewards.ExperiencePoints is > 0) + { + result.AddExperiencePoints(rewards.ExperiencePoints.Value); + } + + foreach (var item in rewards.Inventory) + { + result.AddItem(item.Id, item.Amount); + } + + foreach (string buildplateId in rewards.Buildplates) + { + result.AddBuildplate(buildplateId); + } + + foreach (var challenge in rewards.Challenges) + { + result.AddChallenge(challenge.Id); + } + + return result; + } } diff --git a/src/Solace.ApiServer/Controllers/EarthApi/ChallengesController.cs b/src/Solace.ApiServer/Controllers/EarthApi/ChallengesController.cs index 59cb6956..79cbdad1 100644 --- a/src/Solace.ApiServer/Controllers/EarthApi/ChallengesController.cs +++ b/src/Solace.ApiServer/Controllers/EarthApi/ChallengesController.cs @@ -1,11 +1,17 @@ -using Asp.Versioning; +using Asp.Versioning; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; +using System.Globalization; +using System.Security.Cryptography; +using System.Security.Claims; +using System.Text; +using Solace.ApiServer.Exceptions; using Solace.ApiServer.Types.Common; using Solace.ApiServer.Utils; using Solace.Common; using Solace.Common.Utils; +using Solace.DB; using Rewards = Solace.ApiServer.Types.Common.Rewards; namespace Solace.ApiServer.Controllers.EarthApi; @@ -15,6 +21,21 @@ namespace Solace.ApiServer.Controllers.EarthApi; [Route("1/api/v{version:apiVersion}/player/challenges")] internal sealed class ChallengesController : ControllerBase { + private const int DailyChallengeCount = 3; + private const string DailyGroupId = "c1a1beef-0d01-4b1a-8d1e-000000000001"; + private const string DailyReferenceId = "2619913d-6504-4c74-9fc9-e03649a70efc"; + private const string TreasureHuntReferenceId = "2b64c950-f80b-4491-b81d-bf90cee88db1"; + private const string BestDefenseReferenceId = "06eb0e50-b18d-43e8-9aad-422203ffdf28"; + private const string ChopChopReferenceId = "bd9d3fd7-12ef-49e0-91fa-c971795f8e35"; + private const string MobReferenceId = "1d981b84-a03a-451d-82a6-9bfe0fc885fb"; + private const string TappablesReferenceId = "6b0655aa-cc63-4876-a1e1-afb319403c1c"; + private const string TappableChestReferenceId = "d5cbfe47-504a-4e8a-a7b8-481de901c20f"; + private const string LegacySeasonPersonaReward1Id = "00000000-0000-0000-0000-000000000001"; + private const string LegacySeasonPersonaReward2Id = "00000000-0000-0000-0000-000000000002"; + private const string CommonAdventureCrystalId = "4f16a053-4929-263a-c91a-29663e29df76"; + internal const string ActiveSeasonId = "season_17"; + internal const string DefaultActiveSeasonChallengeId = "f0532069-a70a-4a01-8611-f770bb46d9cd"; + private sealed record ChallengeRecord( string ReferenceId, string? ParentId, @@ -36,59 +57,1428 @@ private sealed record ChallengeRecord( object ClientProperties ); + private sealed record DailyChallengeDefinition( + string Key, + string ReferenceId, + int Threshold = 1 + ); + + private static readonly DailyChallengeDefinition[] DailyChallengePool = + [ + new("2b64c950-9b12-4ef3-99a0-cd59c9e1c8d4", TreasureHuntReferenceId, 3), + new("06eb0e50-05e7-49c7-9dfc-cf97bd94f377", BestDefenseReferenceId, 5), + new("bd9d3fd7-bb4f-4ef8-aa6e-dfe5368fd1d1", ChopChopReferenceId, 3), + new("14e99996-0b42-4d2d-ad84-4ff279827ea6", "14e99996-0b42-4d2d-ad84-4ff279827ea6", 3), + new("170b8a07-e781-4509-8de9-ddcc0beb88ba", "170b8a07-e781-4509-8de9-ddcc0beb88ba", 3), + new("1d981b84-a03a-451d-82a6-9bfe0fc885fb", "1d981b84-a03a-451d-82a6-9bfe0fc885fb", 5), + new("2425a33a-8c73-48d9-9de9-2f11d66c8016", "2425a33a-8c73-48d9-9de9-2f11d66c8016", 5), + new("252bb18b-5a96-4ac5-bca0-45c1a0d51269", "252bb18b-5a96-4ac5-bca0-45c1a0d51269", 4), + new("61e55110-e206-4752-95a3-aeb2b98ad6ad", "61e55110-e206-4752-95a3-aeb2b98ad6ad", 5), + new("6b0655aa-cc63-4876-a1e1-afb319403c1c", "6b0655aa-cc63-4876-a1e1-afb319403c1c", 5), + new("6d01c0d0-2ac9-4549-be82-acd7f5631950", "6d01c0d0-2ac9-4549-be82-acd7f5631950", 5), + new("d5cbfe47-504a-4e8a-a7b8-481de901c20f", "d5cbfe47-504a-4e8a-a7b8-481de901c20f", 3), + new("e7b9715a-6c27-4708-bab6-ca4c80397625", "e7b9715a-6c27-4708-bab6-ca4c80397625", 3), + ]; + + private static readonly DailyChallengeDefinition[] ContinuousChallengePool = + [ + new("b8fa3840-43f2-4c87-9d69-f51d77a1a001", TappablesReferenceId, 10), + new("b8fa3840-43f2-4c87-9d69-f51d77a1a002", TappableChestReferenceId, 3), + new("b8fa3840-43f2-4c87-9d69-f51d77a1a003", MobReferenceId, 5), + ]; + + private static DailyChallengeDefinition[] OrderedDailyChallenges(string playerId, string dailyDateUtc) + => [.. DailyChallengePool + .OrderBy(challenge => StableSortKey($"{playerId}:{dailyDateUtc}:{challenge.ReferenceId}")) + ]; + + private static DailyChallengeDefinition[] SelectDailyChallenges(string playerId, string dailyDateUtc) + { + DailyChallengeDefinition[] orderedChallenges = OrderedDailyChallenges(playerId, dailyDateUtc); + if (!DateTime.TryParseExact(dailyDateUtc, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out DateTime dailyDate)) + { + return [.. orderedChallenges.Take(DailyChallengeCount)]; + } + + string yesterday = dailyDate.AddDays(-1).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); + HashSet yesterdayKeys = [.. OrderedDailyChallenges(playerId, yesterday) + .Take(DailyChallengeCount) + .Select(challenge => challenge.Key)]; + + DailyChallengeDefinition[] freshChallenges = [.. orderedChallenges + .Where(challenge => !yesterdayKeys.Contains(challenge.Key)) + .Take(DailyChallengeCount)]; + + return freshChallenges.Length == DailyChallengeCount + ? freshChallenges + : [.. freshChallenges.Concat(orderedChallenges + .Where(challenge => !freshChallenges.Any(freshChallenge => freshChallenge.Key == challenge.Key)) + .Take(DailyChallengeCount - freshChallenges.Length))]; + } + + private static ulong StableSortKey(string value) + { + byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(value)); + return BitConverter.ToUInt64(hash, 0); + } + [HttpGet] - public ContentHttpResult Get() + public async Task> Get(CancellationToken cancellationToken) { - // TODO: this is currently just a stub required for the journal to load properly in the client + string? playerId = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (string.IsNullOrEmpty(playerId)) + { + return TypedResults.BadRequest(); + } - string resp = Json.Serialize(new EarthApiResponse(new Dictionary() + long endOfToday = DateTimeOffset.UtcNow.Date.AddDays(1).ToUnixTimeMilliseconds(); + string dailyEndTime = TimeFormatter.FormatTime(endOfToday); + string seasonEndTime = TimeFormatter.FormatTime(endOfToday + 14 * 24 * 60 * 60 * 1000); + long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + ChallengeProgressVersion progress; + try + { + EarthDB.Results results = await new EarthDB.Query(false) + .Get("challenges", playerId, typeof(ChallengeProgressVersion)) + .ExecuteAsync(Program.DB, cancellationToken); + + progress = results.Get("challenges"); + progress.EnsureDate(now); + } + catch (EarthDB.DatabaseException ex) + { + throw new ServerErrorException(ex); + } + + DailyChallengeDefinition[] dailyChallenges = SelectDailyChallenges(playerId, progress.DailyDateUtc!); + int dailyCount = dailyChallenges.Count(challenge => progress.GetObjectiveProgress(challenge.ReferenceId) >= challenge.Threshold); + bool dailyComplete = dailyCount >= DailyChallengeCount; + bool dailyClaimed = progress.ClaimedChallengeIds?.Contains(DailyGroupId) == true; + string dailyState = dailyClaimed ? "Claimed" : dailyComplete ? "Completed" : "Active"; + int dailyPercent = dailyCount * 100 / DailyChallengeCount; + + Response.Headers.CacheControl = "no-store"; + Response.Headers.ETag = $"\"challenges-{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}\""; + + string activeSeasonChallengeId = SelectActiveSeasonChallengeId(progress, progress.ActiveSeasonChallengeId); + + var challenges = BuildSeasonChallenges(seasonEndTime, progress, activeSeasonChallengeId); + if (!dailyClaimed) + { + challenges[DailyGroupId] = new ChallengeRecord( + DailyReferenceId, + null, + DailyGroupId, + "PersonalTimed", + "Regular", + "retention", + null, + 0, + dailyEndTime, + dailyState, + dailyComplete, + dailyPercent, + dailyCount, + DailyChallengeCount, + [], + "And", + new Rewards(0, 25, null, [new Rewards.Item(CommonAdventureCrystalId, 1)], [], [], [], []), + new object() + ); + } + + for (int index = 0; index < dailyChallenges.Length; index++) { - { "challenges", new Dictionary() + DailyChallengeDefinition challenge = dailyChallenges[index]; + int threshold = challenge.Threshold; + int currentCount = Math.Min(progress.GetObjectiveProgress(challenge.ReferenceId), threshold); + bool isComplete = currentCount >= threshold; + bool isClaimed = progress.ClaimedChallengeIds?.Contains(challenge.Key) == true; + if (isComplete || isClaimed) { - // client requires two season challenges with these specific persona item reward UUIDs to exist in order for the journal to load, and no one has any idea why - { "00000000-0000-0000-0000-000000000001", new ChallengeRecord( - "00000000-0000-0000-0000-000000000001", - null, - "00000000-0000-0000-0000-000000000001", - "Season", - "Regular", - "season_1", - null, - 0, - TimeFormatter.FormatTime(U.CurrentTimeMillis() + 24 * 60 * 60 * 1000), - "Locked", - false, - 0, - 0, - 1, - [], - "And", - new Rewards(null, null, null, [], [], [], ["230f5996-04b2-4f0e-83e5-4056c7f1d946"], []), - new object() - ) }, - { "00000000-0000-0000-0000-000000000002", new ChallengeRecord( - "00000000-0000-0000-0000-000000000002", - null, - "00000000-0000-0000-0000-000000000001", - "Season", - "Regular", - "season_1", - null, - 0, - TimeFormatter.FormatTime(U.CurrentTimeMillis() + 24 * 60 * 60 * 1000), - "Locked", - false, - 0, - 0, - 1, - [], - "And", - new Rewards(null, null, null, [], [], [], ["d7725840-4376-44fc-9220-585f45775371"], []), - new object() - ) } - } }, - { "activeSeasonChallenge", "00000000-0000-0000-0000-000000000000" }, + continue; + } + + challenges[challenge.Key] = new ChallengeRecord( + challenge.ReferenceId, + null, + DailyGroupId, + "PersonalTimed", + "Regular", + "retention", + Rarity.COMMON, + index + 1, + dailyEndTime, + "Active", + false, + currentCount * 100 / threshold, + currentCount, + threshold, + [], + "And", + new Rewards(0, 10, null, [], [], [], [], []), + new object() + ); + } + + for (int index = 0; index < ContinuousChallengePool.Length; index++) + { + DailyChallengeDefinition challenge = ContinuousChallengePool[index]; + if (progress.RemovedContinuousChallengeIds?.Contains(challenge.Key) == true || + progress.ClaimedChallengeIds?.Contains(challenge.Key) == true) + { + continue; + } + + int threshold = challenge.Threshold; + int currentCount = Math.Min(progress.GetObjectiveProgress(challenge.ReferenceId), threshold); + bool isComplete = currentCount >= threshold; + if (isComplete) + { + continue; + } + + challenges[challenge.Key] = new ChallengeRecord( + challenge.ReferenceId, + null, + challenge.Key, + "PersonalContinuous", + "Regular", + "retention", + Rarity.COMMON, + index + 1, + dailyEndTime, + "Active", + false, + currentCount * 100 / threshold, + currentCount, + threshold, + [], + "And", + new Rewards(0, 10, null, [], [], [], [], []), + new object() + ); + } + + string activeChallengeId = ContinuousChallengePool + .FirstOrDefault(challenge => + progress.GetObjectiveProgress(challenge.ReferenceId) < challenge.Threshold && + progress.RemovedContinuousChallengeIds?.Contains(challenge.Key) != true && + progress.ClaimedChallengeIds?.Contains(challenge.Key) != true) + ?.Key ?? DailyGroupId; + + string resp = Json.Serialize(new EarthApiResponse(new Dictionary() + { + { "challenges", challenges }, + { "activeChallengeId", activeChallengeId }, + { "activeSeasonChallenge", activeSeasonChallengeId }, + { "activeSeasonId", ActiveSeasonId }, })); return TypedResults.Content(resp, "application/json"); } + + private static Dictionary BuildSeasonChallenges(string seasonEndTime, ChallengeProgressVersion progress, string activeSeasonChallengeId) + { + var templates = Json.Deserialize>(Season17ChallengesJson) ?? []; + return templates.ToDictionary( + entry => entry.Key, + entry => (object)ApplyProgress(entry.Key, entry.Value with { EndTimeUtc = seasonEndTime }, progress, activeSeasonChallengeId)); + } + + private static ChallengeRecord ApplyProgress(string challengeId, ChallengeRecord challenge, ChallengeProgressVersion progress, string activeSeasonChallengeId) + { + int objectiveCount = progress.GetObjectiveProgress(challenge.ReferenceId); + bool isClaimed = progress.ClaimedChallengeIds?.Contains(challengeId) == true; + if (challenge.IsComplete) + { + return challenge with { State = isClaimed ? "Claimed" : challenge.State }; + } + + int threshold = Math.Max(1, challenge.TotalThreshold); + int currentCount = Math.Min(objectiveCount, threshold); + bool isComplete = currentCount >= threshold; + string state = isClaimed ? "Claimed" : isComplete ? "Completed" : challengeId == activeSeasonChallengeId ? "Active" : challenge.State; + + return challenge with + { + State = state, + IsComplete = isComplete, + PercentComplete = currentCount * 100 / threshold, + CurrentCount = currentCount, + }; + } + + internal static string SelectActiveSeasonChallengeId(ChallengeProgressVersion progress, string? requestedChallengeId = null) + { + var templates = Json.Deserialize>(Season17ChallengesJson) ?? []; + string preferredChallengeId = string.IsNullOrWhiteSpace(requestedChallengeId) + ? DefaultActiveSeasonChallengeId + : requestedChallengeId; + + if (templates.ContainsKey(preferredChallengeId) && + progress.ClaimedChallengeIds?.Contains(preferredChallengeId) != true) + { + return preferredChallengeId; + } + + int preferredOrder = templates.TryGetValue(preferredChallengeId, out ChallengeRecord? preferred) + ? preferred.Order + : -1; + + string? nextChallengeId = templates + .Where(entry => IsSelectableActiveSeasonChallenge(entry.Key, entry.Value, templates, progress)) + .OrderBy(entry => entry.Value.Order <= preferredOrder ? 1 : 0) + .ThenBy(entry => entry.Value.Order) + .Select(entry => entry.Key) + .FirstOrDefault(); + + return nextChallengeId ?? DefaultActiveSeasonChallengeId; + } + + private static bool IsSelectableActiveSeasonChallenge( + string challengeId, + ChallengeRecord challenge, + Dictionary templates, + ChallengeProgressVersion progress) + { + if (challenge.Duration != "Season" || + challenge.Category != ActiveSeasonId || + challengeId == challenge.GroupId || + progress.ClaimedChallengeIds?.Contains(challengeId) == true) + { + return false; + } + + if (challenge.State != "Locked") + { + return true; + } + + if (challenge.PrerequisiteIds.Length == 0) + { + return true; + } + + bool IsSatisfied(string prerequisiteId) + => progress.ClaimedChallengeIds?.Contains(prerequisiteId) == true || + (templates.TryGetValue(prerequisiteId, out ChallengeRecord? prerequisite) && + IsComplete(prerequisite, progress)); + + return challenge.PrerequisiteLogicalCondition == "Or" + ? challenge.PrerequisiteIds.Any(IsSatisfied) + : challenge.PrerequisiteIds.All(IsSatisfied); + } + + private static bool IsComplete(ChallengeRecord challenge, ChallengeProgressVersion progress) + { + if (challenge.IsComplete) + { + return true; + } + + int threshold = Math.Max(1, challenge.TotalThreshold); + return progress.GetObjectiveProgress(challenge.ReferenceId) >= threshold; + } + + internal static string? GetSeasonChallengeReferenceId(string? challengeId) + { + if (string.IsNullOrWhiteSpace(challengeId)) + { + return null; + } + + var templates = Json.Deserialize>(Season17ChallengesJson) ?? []; + return templates.TryGetValue(challengeId, out ChallengeRecord? challenge) + ? challenge.ReferenceId + : null; + } + + internal static Rewards? GetSeasonChallengeRewards(string? challengeId) + { + if (string.IsNullOrWhiteSpace(challengeId)) + { + return null; + } + + if (challengeId == DailyGroupId) + { + return new Rewards(0, 25, null, [new Rewards.Item(CommonAdventureCrystalId, 1)], [], [], [], []); + } + + if (DailyChallengePool.Any(challenge => challenge.Key == challengeId)) + { + return new Rewards(0, 10, null, [], [], [], [], []); + } + + if (ContinuousChallengePool.Any(challenge => challenge.Key == challengeId)) + { + return new Rewards(0, 10, null, [], [], [], [], []); + } + + var templates = Json.Deserialize>(Season17ChallengesJson) ?? []; + return templates.TryGetValue(challengeId, out ChallengeRecord? challenge) + ? challenge.Rewards + : null; + } + + internal static int GetSeasonChallengeThreshold(string? challengeId) + { + if (string.IsNullOrWhiteSpace(challengeId)) + { + return 1; + } + + var templates = Json.Deserialize>(Season17ChallengesJson) ?? []; + return templates.TryGetValue(challengeId, out ChallengeRecord? challenge) + ? Math.Max(1, challenge.TotalThreshold) + : 1; + } + + private const string Season17ChallengesJson = """ +{ + "0b346237-79a4-4f24-a45a-e2e6284e3e56": { + "referenceId": "65ec2dbb-c665-4173-81b2-56816576262f", + "duration": "Season", + "type": "Regular", + "endTimeUtc": "2023-09-24T01:00:00Z", + "rewards": { + "inventory": [ + { + "id": "1d549957-68d0-730a-56f3-d33996738d84", + "amount": 1 + } + ], + "buildplates": [], + "challenges": [], + "personaItems": [], + "utilityBlocks": [] + }, + "percentComplete": 0, + "isComplete": false, + "state": "Locked", + "category": "season_17", + "currentCount": 0, + "totalThreshold": 1, + "parentId": "70961d0f-a762-4846-a79c-576d685263f4", + "order": 32, + "rarity": null, + "prerequisiteLogicalCondition": "Or", + "prerequisiteIds": [ + "ab2e0734-62b1-4383-a239-d1b4d4a93dc4", + "152a875e-2389-4245-8e1f-08f1915d6c7a" + ], + "groupId": "cc456b52-1586-4e75-b7e9-aa811f609567", + "clientProperties": {} + }, + "0d4c99ce-b485-4890-be69-caab88400df3": { + "referenceId": "606e21fb-6781-4773-87f6-158a20729f04", + "duration": "Season", + "type": "Regular", + "endTimeUtc": "2023-09-24T01:00:00Z", + "rewards": { + "inventory": [ + { + "id": "c44c331a-962f-19df-16a5-ff4bcc03722d", + "amount": 1 + } + ], + "buildplates": [], + "challenges": [], + "personaItems": [], + "utilityBlocks": [] + }, + "percentComplete": 0, + "isComplete": false, + "state": "Locked", + "category": "season_17", + "currentCount": 0, + "totalThreshold": 1, + "parentId": "3d82b9c1-f4e0-4a20-b87e-9a11734bcb6a", + "order": 11, + "rarity": null, + "prerequisiteLogicalCondition": "Or", + "prerequisiteIds": [ + "242b6706-8112-4302-819b-bde98fd574f6", + "fb13127e-d19d-48bd-a977-f830eecff180" + ], + "groupId": "cc456b52-1586-4e75-b7e9-aa811f609567", + "clientProperties": {} + }, + "152a875e-2389-4245-8e1f-08f1915d6c7a": { + "referenceId": "1e4899f5-039e-48f0-a1cc-bd1bda871e96", + "duration": "Season", + "type": "Regular", + "endTimeUtc": "2023-09-24T01:00:00Z", + "rewards": { + "inventory": [], + "buildplates": [], + "challenges": [], + "personaItems": [ + "230f5996-04b2-4f0e-83e5-4056c7f1d946" + ], + "utilityBlocks": [] + }, + "percentComplete": 0, + "isComplete": false, + "state": "Locked", + "category": "season_17", + "currentCount": 0, + "totalThreshold": 1, + "parentId": "70961d0f-a762-4846-a79c-576d685263f4", + "order": 34, + "rarity": null, + "prerequisiteLogicalCondition": "Or", + "prerequisiteIds": [ + "ab2e0734-62b1-4383-a239-d1b4d4a93dc4", + "0b346237-79a4-4f24-a45a-e2e6284e3e56" + ], + "groupId": "cc456b52-1586-4e75-b7e9-aa811f609567", + "clientProperties": {} + }, + "1ccbe15e-3a72-4d8b-ad0b-00a23ac72eb1": { + "referenceId": "57f50f14-1b46-460b-82dc-e1220d53a15b", + "duration": "Season", + "type": "Regular", + "endTimeUtc": "2023-09-24T01:00:00Z", + "rewards": { + "inventory": [ + { + "id": "d08fb88b-e670-2a8d-3b83-8edb363e7ba4", + "amount": 1 + } + ], + "buildplates": [], + "challenges": [], + "personaItems": [], + "utilityBlocks": [] + }, + "percentComplete": 0, + "isComplete": false, + "state": "Locked", + "category": "season_17", + "currentCount": 0, + "totalThreshold": 2, + "parentId": "3d82b9c1-f4e0-4a20-b87e-9a11734bcb6a", + "order": 4, + "rarity": null, + "prerequisiteLogicalCondition": "Or", + "prerequisiteIds": [ + "f0532069-a70a-4a01-8611-f770bb46d9cd", + "d8d2abd5-f318-4c34-a257-57b9691a2774" + ], + "groupId": "cc456b52-1586-4e75-b7e9-aa811f609567", + "clientProperties": {} + }, + "242b6706-8112-4302-819b-bde98fd574f6": { + "referenceId": "543a0397-25c5-4e2d-b39d-6d6287c7cbed", + "duration": "Season", + "type": "Regular", + "endTimeUtc": "2023-09-24T01:00:00Z", + "rewards": { + "inventory": [ + { + "id": "c409783a-8f32-fa9f-1026-54bbaaaedc38", + "amount": 10 + } + ], + "buildplates": [], + "challenges": [], + "personaItems": [], + "utilityBlocks": [] + }, + "percentComplete": 0, + "isComplete": false, + "state": "Locked", + "category": "season_17", + "currentCount": 0, + "totalThreshold": 2, + "parentId": "3d82b9c1-f4e0-4a20-b87e-9a11734bcb6a", + "order": 8, + "rarity": null, + "prerequisiteLogicalCondition": "Or", + "prerequisiteIds": [ + "edf9f1dc-f73f-4275-a3e5-e1d9d861d158", + "0d4c99ce-b485-4890-be69-caab88400df3" + ], + "groupId": "cc456b52-1586-4e75-b7e9-aa811f609567", + "clientProperties": {} + }, + "26f62499-5996-477a-8530-22f852b1ccad": { + "referenceId": "520f09c2-7a83-49f3-b579-654ca2944adb", + "duration": "Season", + "type": "Regular", + "endTimeUtc": "2023-09-24T01:00:00Z", + "rewards": { + "inventory": [ + { + "id": "3f473106-d0c3-4f44-9db9-ace843e3a11a", + "amount": 1 + } + ], + "buildplates": [], + "challenges": [], + "personaItems": [], + "utilityBlocks": [] + }, + "percentComplete": 0, + "isComplete": false, + "state": "Locked", + "category": "season_17", + "currentCount": 0, + "totalThreshold": 3, + "parentId": "70961d0f-a762-4846-a79c-576d685263f4", + "order": 25, + "rarity": null, + "prerequisiteLogicalCondition": "Or", + "prerequisiteIds": [ + "abd271f8-22cd-42ff-b985-cd5e642ea25a", + "2ebbf878-5c69-4f55-8858-83bd53c57381" + ], + "groupId": "cc456b52-1586-4e75-b7e9-aa811f609567", + "clientProperties": {} + }, + "29ebe650-072f-4f70-996f-4ffdda93ed1f": { + "referenceId": "1282801b-a5a8-4339-a587-b16d31468b55", + "duration": "Season", + "type": "Regular", + "endTimeUtc": "2023-09-24T01:00:00Z", + "rewards": { + "inventory": [], + "rubies": 20, + "buildplates": [], + "challenges": [], + "personaItems": [], + "utilityBlocks": [] + }, + "percentComplete": 0, + "isComplete": false, + "state": "Locked", + "category": "season_17", + "currentCount": 0, + "totalThreshold": 2, + "parentId": "70961d0f-a762-4846-a79c-576d685263f4", + "order": 24, + "rarity": null, + "prerequisiteLogicalCondition": "Or", + "prerequisiteIds": [ + "dbdb4592-4211-4ffe-a76c-8a3a2213c93c", + "3183cf3d-19e5-4bf5-b8a8-3085f4e650d3" + ], + "groupId": "cc456b52-1586-4e75-b7e9-aa811f609567", + "clientProperties": {} + }, + "2bb26b41-e841-4815-ade5-a0d93ac51258": { + "referenceId": "b8f5af67-ff23-4c1d-95a9-f6ccff5137e0", + "duration": "Season", + "type": "Regular", + "endTimeUtc": "2023-09-24T01:00:00Z", + "rewards": { + "inventory": [ + { + "id": "179b96c5-7627-406d-e42b-838a29ab0291", + "amount": 1 + } + ], + "buildplates": [], + "challenges": [], + "personaItems": [], + "utilityBlocks": [] + }, + "percentComplete": 0, + "isComplete": false, + "state": "Locked", + "category": "season_17", + "currentCount": 0, + "totalThreshold": 3, + "parentId": "70961d0f-a762-4846-a79c-576d685263f4", + "order": 20, + "rarity": null, + "prerequisiteLogicalCondition": "Or", + "prerequisiteIds": [ + "96593763-a8a6-41f8-986a-8d6df0470c57", + "703c24f0-aedb-4fc1-bce8-6b06229b2006" + ], + "groupId": "cc456b52-1586-4e75-b7e9-aa811f609567", + "clientProperties": {} + }, + "2ebbf878-5c69-4f55-8858-83bd53c57381": { + "referenceId": "f3410363-8eee-4c60-8075-d785c02f158e", + "duration": "Season", + "type": "Regular", + "endTimeUtc": "2023-09-24T01:00:00Z", + "rewards": { + "inventory": [ + { + "id": "1eb4c044-716e-4a26-80f3-be2a7f30fe70", + "amount": 1 + } + ], + "buildplates": [], + "challenges": [], + "personaItems": [], + "utilityBlocks": [] + }, + "percentComplete": 0, + "isComplete": false, + "state": "Locked", + "category": "season_17", + "currentCount": 0, + "totalThreshold": 2, + "parentId": "70961d0f-a762-4846-a79c-576d685263f4", + "order": 28, + "rarity": null, + "prerequisiteLogicalCondition": "Or", + "prerequisiteIds": [ + "26f62499-5996-477a-8530-22f852b1ccad", + "3183cf3d-19e5-4bf5-b8a8-3085f4e650d3", + "a1394f77-20ba-44c2-b46c-e8d7933f2e51" + ], + "groupId": "cc456b52-1586-4e75-b7e9-aa811f609567", + "clientProperties": {} + }, + "3183cf3d-19e5-4bf5-b8a8-3085f4e650d3": { + "referenceId": "857ba971-9a35-4119-b674-9cb12bfd0693", + "duration": "Season", + "type": "Regular", + "endTimeUtc": "2023-09-24T01:00:00Z", + "rewards": { + "inventory": [ + { + "id": "79a0bc61-84e6-4c77-ba12-df0bad32a06f", + "amount": 1 + } + ], + "buildplates": [], + "challenges": [], + "personaItems": [], + "utilityBlocks": [] + }, + "percentComplete": 0, + "isComplete": false, + "state": "Locked", + "category": "season_17", + "currentCount": 0, + "totalThreshold": 6, + "parentId": "70961d0f-a762-4846-a79c-576d685263f4", + "order": 27, + "rarity": null, + "prerequisiteLogicalCondition": "Or", + "prerequisiteIds": [ + "29ebe650-072f-4f70-996f-4ffdda93ed1f", + "2ebbf878-5c69-4f55-8858-83bd53c57381", + "ab2e0734-62b1-4383-a239-d1b4d4a93dc4" + ], + "groupId": "cc456b52-1586-4e75-b7e9-aa811f609567", + "clientProperties": {} + }, + "3d82b9c1-f4e0-4a20-b87e-9a11734bcb6a": { + "referenceId": "87ded7ff-f837-4a20-bedd-77aa3d60c060", + "duration": "Season", + "type": "Regular", + "endTimeUtc": "2023-09-24T01:00:00Z", + "rewards": { + "inventory": [], + "buildplates": [], + "challenges": [], + "personaItems": [], + "utilityBlocks": [] + }, + "percentComplete": 0, + "isComplete": false, + "state": "Active", + "category": "season_17", + "currentCount": 0, + "totalThreshold": 1, + "parentId": "cc456b52-1586-4e75-b7e9-aa811f609567", + "order": 0, + "rarity": null, + "prerequisiteLogicalCondition": "And", + "prerequisiteIds": [], + "groupId": "cc456b52-1586-4e75-b7e9-aa811f609567", + "clientProperties": {} + }, + "63e5d65e-1152-4adc-97e7-2818854a867a": { + "referenceId": "2a599738-efe0-45bf-8fec-1ff73b25f374", + "duration": "Season", + "type": "Regular", + "endTimeUtc": "2023-09-24T01:00:00Z", + "rewards": { + "inventory": [ + { + "id": "6e0d14d1-d406-4040-8582-3ec3d160079f", + "amount": 1 + } + ], + "buildplates": [], + "challenges": [], + "personaItems": [], + "utilityBlocks": [] + }, + "percentComplete": 0, + "isComplete": false, + "state": "Locked", + "category": "season_17", + "currentCount": 0, + "totalThreshold": 3, + "parentId": "70961d0f-a762-4846-a79c-576d685263f4", + "order": 18, + "rarity": null, + "prerequisiteLogicalCondition": "Or", + "prerequisiteIds": [ + "96593763-a8a6-41f8-986a-8d6df0470c57", + "dbdb4592-4211-4ffe-a76c-8a3a2213c93c" + ], + "groupId": "cc456b52-1586-4e75-b7e9-aa811f609567", + "clientProperties": {} + }, + "68337eb8-c1c8-4f57-876a-d262ca7a22f4": { + "referenceId": "59c9d986-e9a0-4651-9575-9ecb30f932e6", + "duration": "Season", + "type": "Regular", + "endTimeUtc": "2023-09-24T01:00:00Z", + "rewards": { + "inventory": [ + { + "id": "7535d81e-c960-28f2-d3ca-d1fd7b813c34", + "amount": 1 + } + ], + "buildplates": [], + "challenges": [], + "personaItems": [], + "utilityBlocks": [] + }, + "percentComplete": 0, + "isComplete": false, + "state": "Locked", + "category": "season_17", + "currentCount": 0, + "totalThreshold": 1, + "parentId": "70961d0f-a762-4846-a79c-576d685263f4", + "order": 26, + "rarity": null, + "prerequisiteLogicalCondition": "Or", + "prerequisiteIds": [ + "703c24f0-aedb-4fc1-bce8-6b06229b2006" + ], + "groupId": "cc456b52-1586-4e75-b7e9-aa811f609567", + "clientProperties": {} + }, + "703c24f0-aedb-4fc1-bce8-6b06229b2006": { + "referenceId": "537fec4e-fed5-4618-b7b5-c171434111ad", + "duration": "Season", + "type": "Regular", + "endTimeUtc": "2023-09-24T01:00:00Z", + "rewards": { + "inventory": [ + { + "id": "75875fbb-9615-41da-9a04-5a1d290513b5", + "amount": 2 + } + ], + "buildplates": [], + "challenges": [], + "personaItems": [], + "utilityBlocks": [] + }, + "percentComplete": 0, + "isComplete": false, + "state": "Locked", + "category": "season_17", + "currentCount": 0, + "totalThreshold": 3, + "parentId": "70961d0f-a762-4846-a79c-576d685263f4", + "order": 23, + "rarity": null, + "prerequisiteLogicalCondition": "Or", + "prerequisiteIds": [ + "2bb26b41-e841-4815-ade5-a0d93ac51258", + "68337eb8-c1c8-4f57-876a-d262ca7a22f4" + ], + "groupId": "cc456b52-1586-4e75-b7e9-aa811f609567", + "clientProperties": {} + }, + "70961d0f-a762-4846-a79c-576d685263f4": { + "referenceId": "45262b2b-2f74-4c6a-ae8e-837e81e80c46", + "duration": "Season", + "type": "Regular", + "endTimeUtc": "2023-09-24T01:00:00Z", + "rewards": { + "inventory": [], + "buildplates": [], + "challenges": [], + "personaItems": [], + "utilityBlocks": [] + }, + "percentComplete": 0, + "isComplete": false, + "state": "Locked", + "category": "season_17", + "currentCount": 0, + "totalThreshold": 1, + "parentId": "cc456b52-1586-4e75-b7e9-aa811f609567", + "order": 0, + "rarity": null, + "prerequisiteLogicalCondition": "And", + "prerequisiteIds": [ + "3d82b9c1-f4e0-4a20-b87e-9a11734bcb6a" + ], + "groupId": "cc456b52-1586-4e75-b7e9-aa811f609567", + "clientProperties": {} + }, + "761c6754-ad1f-4cbf-a116-8a1e1dd611b9": { + "referenceId": "09e5a7ea-c4a7-4401-af88-dcb8b4f47abe", + "duration": "Season", + "type": "Regular", + "endTimeUtc": "2023-09-24T01:00:00Z", + "rewards": { + "inventory": [ + { + "id": "75875fbb-9615-41da-9a04-5a1d290513b5", + "amount": 1 + } + ], + "buildplates": [], + "challenges": [], + "personaItems": [], + "utilityBlocks": [] + }, + "percentComplete": 0, + "isComplete": false, + "state": "Locked", + "category": "season_17", + "currentCount": 0, + "totalThreshold": 8, + "parentId": "3d82b9c1-f4e0-4a20-b87e-9a11734bcb6a", + "order": 9, + "rarity": null, + "prerequisiteLogicalCondition": "Or", + "prerequisiteIds": [ + "8e052786-0495-4eb9-80bf-e1b36b9d89ef", + "fb13127e-d19d-48bd-a977-f830eecff180" + ], + "groupId": "cc456b52-1586-4e75-b7e9-aa811f609567", + "clientProperties": {} + }, + "8e052786-0495-4eb9-80bf-e1b36b9d89ef": { + "referenceId": "f022ad24-44e4-484e-b839-77237bb3d1b8", + "duration": "Season", + "type": "Regular", + "endTimeUtc": "2023-09-24T01:00:00Z", + "rewards": { + "inventory": [ + { + "id": "ea7306fa-9dd0-897f-d1a8-2529521cd5f2", + "amount": 1 + } + ], + "buildplates": [], + "challenges": [], + "personaItems": [], + "utilityBlocks": [] + }, + "percentComplete": 0, + "isComplete": false, + "state": "Locked", + "category": "season_17", + "currentCount": 0, + "totalThreshold": 2, + "parentId": "3d82b9c1-f4e0-4a20-b87e-9a11734bcb6a", + "order": 6, + "rarity": null, + "prerequisiteLogicalCondition": "Or", + "prerequisiteIds": [ + "e81d6ce8-622c-461a-a2d5-bb1f8ac36c62", + "761c6754-ad1f-4cbf-a116-8a1e1dd611b9" + ], + "groupId": "cc456b52-1586-4e75-b7e9-aa811f609567", + "clientProperties": {} + }, + "8ff3a6c3-0a1a-4343-8672-7f559cc8918d": { + "referenceId": "d4a304cf-d6b5-4fcd-862c-8d7f3418443e", + "duration": "Season", + "type": "Regular", + "endTimeUtc": "2023-09-24T01:00:00Z", + "rewards": { + "inventory": [ + { + "id": "c4231eb5-8fc0-4ad7-ad18-ecfcb0734049", + "amount": 1 + } + ], + "buildplates": [], + "challenges": [], + "personaItems": [], + "utilityBlocks": [] + }, + "percentComplete": 0, + "isComplete": false, + "state": "Locked", + "category": "season_17", + "currentCount": 0, + "totalThreshold": 8, + "parentId": "70961d0f-a762-4846-a79c-576d685263f4", + "order": 19, + "rarity": null, + "prerequisiteLogicalCondition": "Or", + "prerequisiteIds": [ + "96593763-a8a6-41f8-986a-8d6df0470c57", + "abd271f8-22cd-42ff-b985-cd5e642ea25a" + ], + "groupId": "cc456b52-1586-4e75-b7e9-aa811f609567", + "clientProperties": {} + }, + "96593763-a8a6-41f8-986a-8d6df0470c57": { + "referenceId": "0688f294-76a8-41ed-a34c-24b8d9e3bc98", + "duration": "Season", + "type": "Regular", + "endTimeUtc": "2023-09-24T01:00:00Z", + "rewards": { + "experiencePoints": 250, + "inventory": [], + "buildplates": [], + "challenges": [], + "personaItems": [], + "utilityBlocks": [] + }, + "percentComplete": 0, + "isComplete": false, + "state": "Locked", + "category": "season_17", + "currentCount": 0, + "totalThreshold": 1, + "parentId": "70961d0f-a762-4846-a79c-576d685263f4", + "order": 16, + "rarity": null, + "prerequisiteLogicalCondition": "Or", + "prerequisiteIds": [ + "fb13127e-d19d-48bd-a977-f830eecff180" + ], + "groupId": "cc456b52-1586-4e75-b7e9-aa811f609567", + "clientProperties": {} + }, + "a1394f77-20ba-44c2-b46c-e8d7933f2e51": { + "referenceId": "290e2c67-cdfb-4aab-8fae-f8b910a096c0", + "duration": "Season", + "type": "Regular", + "endTimeUtc": "2023-09-24T01:00:00Z", + "rewards": { + "inventory": [], + "buildplates": [], + "challenges": [], + "personaItems": [ + "d7725840-4376-44fc-9220-585f45775371" + ], + "utilityBlocks": [] + }, + "percentComplete": 0, + "isComplete": false, + "state": "Locked", + "category": "season_17", + "currentCount": 0, + "totalThreshold": 3, + "parentId": "70961d0f-a762-4846-a79c-576d685263f4", + "order": 29, + "rarity": null, + "prerequisiteLogicalCondition": "Or", + "prerequisiteIds": [ + "2ebbf878-5c69-4f55-8858-83bd53c57381", + "0b346237-79a4-4f24-a45a-e2e6284e3e56" + ], + "groupId": "cc456b52-1586-4e75-b7e9-aa811f609567", + "clientProperties": {} + }, + "ab2e0734-62b1-4383-a239-d1b4d4a93dc4": { + "referenceId": "1d4197fc-49b7-4cca-8397-8792aba78037", + "duration": "Season", + "type": "Regular", + "endTimeUtc": "2023-09-24T01:00:00Z", + "rewards": { + "inventory": [ + { + "id": "408bb17b-da92-0cea-7496-ee01c6a542d7", + "amount": 5 + } + ], + "buildplates": [], + "challenges": [], + "personaItems": [], + "utilityBlocks": [] + }, + "percentComplete": 0, + "isComplete": false, + "state": "Locked", + "category": "season_17", + "currentCount": 0, + "totalThreshold": 1, + "parentId": "70961d0f-a762-4846-a79c-576d685263f4", + "order": 30, + "rarity": null, + "prerequisiteLogicalCondition": "Or", + "prerequisiteIds": [ + "3183cf3d-19e5-4bf5-b8a8-3085f4e650d3", + "152a875e-2389-4245-8e1f-08f1915d6c7a" + ], + "groupId": "cc456b52-1586-4e75-b7e9-aa811f609567", + "clientProperties": {} + }, + "abd271f8-22cd-42ff-b985-cd5e642ea25a": { + "referenceId": "5a6fbcce-2d2d-47f9-b36e-4d34c351f8a3", + "duration": "Season", + "type": "Regular", + "endTimeUtc": "2023-09-24T01:00:00Z", + "rewards": { + "inventory": [ + { + "id": "123dd088-ba76-71ce-b40c-2f05b948f303", + "amount": 1 + } + ], + "buildplates": [], + "challenges": [], + "personaItems": [], + "utilityBlocks": [] + }, + "percentComplete": 0, + "isComplete": false, + "state": "Locked", + "category": "season_17", + "currentCount": 0, + "totalThreshold": 2, + "parentId": "70961d0f-a762-4846-a79c-576d685263f4", + "order": 22, + "rarity": null, + "prerequisiteLogicalCondition": "Or", + "prerequisiteIds": [ + "8ff3a6c3-0a1a-4343-8672-7f559cc8918d", + "dbdb4592-4211-4ffe-a76c-8a3a2213c93c", + "26f62499-5996-477a-8530-22f852b1ccad" + ], + "groupId": "cc456b52-1586-4e75-b7e9-aa811f609567", + "clientProperties": {} + }, + "cc456b52-1586-4e75-b7e9-aa811f609567": { + "referenceId": "a46e0e1e-51cd-4fbc-b3b2-f6d33c78532c", + "duration": "Season", + "type": "Regular", + "endTimeUtc": "2023-09-24T01:00:00Z", + "rewards": { + "inventory": [], + "buildplates": [], + "challenges": [], + "personaItems": [], + "utilityBlocks": [] + }, + "percentComplete": 0, + "isComplete": false, + "state": "Active", + "category": "season_17", + "currentCount": 0, + "totalThreshold": 1, + "parentId": null, + "order": 0, + "rarity": null, + "prerequisiteLogicalCondition": "And", + "prerequisiteIds": [], + "groupId": "cc456b52-1586-4e75-b7e9-aa811f609567", + "clientProperties": {} + }, + "d434c853-7fab-4ac7-b64b-80fb6dd9ddd9": { + "referenceId": "e02e6a5d-8541-4d34-a2ee-9bcdcc3381a4", + "duration": "Season", + "type": "Regular", + "endTimeUtc": "2023-09-24T01:00:00Z", + "rewards": { + "inventory": [ + { + "id": "0d494b76-58cd-4744-aa3e-affe0e4ebb87", + "amount": 1 + } + ], + "buildplates": [], + "challenges": [], + "personaItems": [], + "utilityBlocks": [] + }, + "percentComplete": 0, + "isComplete": false, + "state": "Locked", + "category": "season_17", + "currentCount": 0, + "totalThreshold": 1, + "parentId": "3d82b9c1-f4e0-4a20-b87e-9a11734bcb6a", + "order": 10, + "rarity": null, + "prerequisiteLogicalCondition": "Or", + "prerequisiteIds": [ + "d8d2abd5-f318-4c34-a257-57b9691a2774", + "fb13127e-d19d-48bd-a977-f830eecff180" + ], + "groupId": "cc456b52-1586-4e75-b7e9-aa811f609567", + "clientProperties": {} + }, + "d8d2abd5-f318-4c34-a257-57b9691a2774": { + "referenceId": "bfd00245-4801-46fa-af1a-28979df23377", + "duration": "Season", + "type": "Regular", + "endTimeUtc": "2023-09-24T01:00:00Z", + "rewards": { + "inventory": [ + { + "id": "c14fc209-f280-bccc-bb4a-6c2a3fc71abc", + "amount": 3 + } + ], + "buildplates": [], + "challenges": [], + "personaItems": [], + "utilityBlocks": [] + }, + "percentComplete": 0, + "isComplete": false, + "state": "Locked", + "category": "season_17", + "currentCount": 0, + "totalThreshold": 2, + "parentId": "3d82b9c1-f4e0-4a20-b87e-9a11734bcb6a", + "order": 7, + "rarity": null, + "prerequisiteLogicalCondition": "Or", + "prerequisiteIds": [ + "1ccbe15e-3a72-4d8b-ad0b-00a23ac72eb1", + "d434c853-7fab-4ac7-b64b-80fb6dd9ddd9" + ], + "groupId": "cc456b52-1586-4e75-b7e9-aa811f609567", + "clientProperties": {} + }, + "dbdb4592-4211-4ffe-a76c-8a3a2213c93c": { + "referenceId": "af6d9346-4a07-458e-880e-3f3601db8739", + "duration": "Season", + "type": "Regular", + "endTimeUtc": "2023-09-24T01:00:00Z", + "rewards": { + "inventory": [ + { + "id": "ff723264-b108-9d24-f445-73a3322fc72e", + "amount": 1 + } + ], + "buildplates": [], + "challenges": [], + "personaItems": [], + "utilityBlocks": [] + }, + "percentComplete": 0, + "isComplete": false, + "state": "Locked", + "category": "season_17", + "currentCount": 0, + "totalThreshold": 7, + "parentId": "70961d0f-a762-4846-a79c-576d685263f4", + "order": 21, + "rarity": null, + "prerequisiteLogicalCondition": "Or", + "prerequisiteIds": [ + "63e5d65e-1152-4adc-97e7-2818854a867a", + "abd271f8-22cd-42ff-b985-cd5e642ea25a", + "29ebe650-072f-4f70-996f-4ffdda93ed1f" + ], + "groupId": "cc456b52-1586-4e75-b7e9-aa811f609567", + "clientProperties": {} + }, + "e81d6ce8-622c-461a-a2d5-bb1f8ac36c62": { + "referenceId": "10986d40-516f-459b-bc24-a16296998c1e", + "duration": "Season", + "type": "Regular", + "endTimeUtc": "2023-09-24T01:00:00Z", + "rewards": { + "inventory": [ + { + "id": "eea50131-e909-214f-d5d2-e8b83febe31a", + "amount": 30 + } + ], + "buildplates": [], + "challenges": [], + "personaItems": [], + "utilityBlocks": [] + }, + "percentComplete": 0, + "isComplete": false, + "state": "Locked", + "category": "season_17", + "currentCount": 0, + "totalThreshold": 2, + "parentId": "3d82b9c1-f4e0-4a20-b87e-9a11734bcb6a", + "order": 3, + "rarity": null, + "prerequisiteLogicalCondition": "Or", + "prerequisiteIds": [ + "f0532069-a70a-4a01-8611-f770bb46d9cd", + "8e052786-0495-4eb9-80bf-e1b36b9d89ef" + ], + "groupId": "cc456b52-1586-4e75-b7e9-aa811f609567", + "clientProperties": {} + }, + "edf9f1dc-f73f-4275-a3e5-e1d9d861d158": { + "referenceId": "d58c96b5-6962-4a97-a4e1-45d635e8cef2", + "duration": "Season", + "type": "Regular", + "endTimeUtc": "2023-09-24T01:00:00Z", + "rewards": { + "inventory": [ + { + "id": "8864e97c-36a6-b66b-63c6-5b247cdd1aaa", + "amount": 8 + } + ], + "buildplates": [], + "challenges": [], + "personaItems": [], + "utilityBlocks": [] + }, + "percentComplete": 0, + "isComplete": false, + "state": "Locked", + "category": "season_17", + "currentCount": 0, + "totalThreshold": 3, + "parentId": "3d82b9c1-f4e0-4a20-b87e-9a11734bcb6a", + "order": 5, + "rarity": null, + "prerequisiteLogicalCondition": "Or", + "prerequisiteIds": [ + "f0532069-a70a-4a01-8611-f770bb46d9cd", + "242b6706-8112-4302-819b-bde98fd574f6" + ], + "groupId": "cc456b52-1586-4e75-b7e9-aa811f609567", + "clientProperties": {} + }, + "f0532069-a70a-4a01-8611-f770bb46d9cd": { + "referenceId": "a7ac0df7-4239-491d-9dc4-8691d053ebf4", + "duration": "Season", + "type": "Regular", + "endTimeUtc": "2023-09-24T01:00:00Z", + "rewards": { + "inventory": [ + { + "id": "d9bbd707-8a7a-4edb-a85c-f8ec0c78a1f9", + "amount": 1 + } + ], + "buildplates": [], + "challenges": [], + "personaItems": [], + "utilityBlocks": [] + }, + "percentComplete": 100, + "isComplete": true, + "state": "Completed", + "category": "season_17", + "currentCount": 1, + "totalThreshold": 1, + "parentId": "3d82b9c1-f4e0-4a20-b87e-9a11734bcb6a", + "order": 1, + "rarity": null, + "prerequisiteLogicalCondition": "And", + "prerequisiteIds": [], + "groupId": "cc456b52-1586-4e75-b7e9-aa811f609567", + "clientProperties": {} + }, + "fb13127e-d19d-48bd-a977-f830eecff180": { + "referenceId": "92ed3b3a-a132-44c8-8045-2d6d828c0177", + "duration": "Season", + "type": "Regular", + "endTimeUtc": "2023-09-24T01:00:00Z", + "rewards": { + "inventory": [ + { + "id": "81a84b7e-928f-7157-254c-6543e90dbc59", + "amount": 1 + } + ], + "buildplates": [], + "challenges": [], + "personaItems": [], + "utilityBlocks": [] + }, + "percentComplete": 0, + "isComplete": false, + "state": "Locked", + "category": "season_17", + "currentCount": 0, + "totalThreshold": 4, + "parentId": "3d82b9c1-f4e0-4a20-b87e-9a11734bcb6a", + "order": 13, + "rarity": null, + "prerequisiteLogicalCondition": "Or", + "prerequisiteIds": [ + "761c6754-ad1f-4cbf-a116-8a1e1dd611b9", + "d434c853-7fab-4ac7-b64b-80fb6dd9ddd9", + "0d4c99ce-b485-4890-be69-caab88400df3" + ], + "groupId": "cc456b52-1586-4e75-b7e9-aa811f609567", + "clientProperties": {} + }, + "ccc1836f-ccda-4f33-8a4d-c42d8d366255": { + "referenceId": "ccc1836f-ccda-4f33-8a4d-c42d8d366255", + "duration": "PersonalTimed", + "type": "Regular", + "endTimeUtc": "2022-12-17T00:00:00Z", + "rewards": { + "experiencePoints": 75, + "inventory": [], + "buildplates": [], + "challenges": [], + "personaItems": [], + "utilityBlocks": [] + }, + "percentComplete": 0, + "isComplete": false, + "state": "Active", + "category": "Collection", + "currentCount": 0, + "totalThreshold": 5, + "parentId": null, + "order": 0, + "rarity": null, + "prerequisiteLogicalCondition": "And", + "prerequisiteIds": [], + "groupId": null, + "clientProperties": {} + }, + "91d23ab8-7a63-4d6d-8b4b-6ae462a51067": { + "referenceId": "91d23ab8-7a63-4d6d-8b4b-6ae462a51067", + "duration": "PersonalTimed", + "type": "Regular", + "endTimeUtc": "2022-12-17T00:00:00Z", + "rewards": { + "experiencePoints": 30, + "inventory": [], + "buildplates": [], + "challenges": [], + "personaItems": [], + "utilityBlocks": [] + }, + "percentComplete": 0, + "isComplete": false, + "state": "Active", + "category": "sheep", + "currentCount": 0, + "totalThreshold": 1, + "parentId": null, + "order": 0, + "rarity": "Common", + "prerequisiteLogicalCondition": "And", + "prerequisiteIds": [], + "groupId": null, + "clientProperties": {} + } +} +"""; + + [HttpPost] + public Task> Post(CancellationToken cancellationToken) + => Get(cancellationToken); } diff --git a/src/Solace.ApiServer/Controllers/EarthApi/DailyGoodiesController.cs b/src/Solace.ApiServer/Controllers/EarthApi/DailyGoodiesController.cs index 843b39ea..733d3637 100644 --- a/src/Solace.ApiServer/Controllers/EarthApi/DailyGoodiesController.cs +++ b/src/Solace.ApiServer/Controllers/EarthApi/DailyGoodiesController.cs @@ -3,12 +3,11 @@ using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; using System.Globalization; +using System.Security.Claims; using Solace.ApiServer.Utils; +using Solace.Common; using Solace.DB; using Solace.DB.Models.Player; -using Solace.DB.Utils; -using Microsoft.EntityFrameworkCore; -using Solace.StaticData; using DBRewards = Solace.DB.Models.Common.Rewards; namespace Solace.ApiServer.Controllers.EarthApi; @@ -18,14 +17,9 @@ namespace Solace.ApiServer.Controllers.EarthApi; [Route("1/api/v{version:apiVersion}")] internal sealed class DailyGoodiesController : SolaceControllerBase { - private readonly EarthDbContext _earthDB; - private readonly StaticData.StaticData _staticData; - - public DailyGoodiesController(EarthDbContext earthDB, StaticData.StaticData staticData) - { - _earthDB = earthDB; - _staticData = staticData; - } + private const string CommonAdventureCrystalId = "4f16a053-4929-263a-c91a-29663e29df76"; + private static EarthDB earthDB => Program.DB; + private static StaticData.StaticData staticData => Program.staticData; [HttpGet("player/dailygoodies")] [HttpGet("player/daily-goodies")] @@ -37,22 +31,37 @@ public DailyGoodiesController(EarthDbContext earthDB, StaticData.StaticData stat [HttpGet("dailyrewards")] public async Task> Get(CancellationToken cancellationToken) { - if (!TryGetAccountId(out var accountId)) + if (!TryGetPlayerId(out string playerId)) { return TypedResults.BadRequest(); } + await TokenUtils.EnsureDailyLoginToken(playerId, cancellationToken); string today = TodayUtc(); - var tokens = await _earthDB.Tokens - .AsTracking() - .FirstOrNewAsync(tokens => tokens.Id == accountId, cancellationToken: cancellationToken); - TokensEF.TokenWithId token = EnsureDailyLoginToken(tokens, today); - await _earthDB.SaveChangesAsync(cancellationToken); - - var dailyLoginToken = (TokensEF.DailyLoginToken)token.Token; - return EarthJson(BuildDailyGoodiesResponse(today, dailyLoginToken, token.Id)); + + EarthDB.Results results = await new EarthDB.Query(false) + .Get("tokenClaims", playerId, typeof(TokenClaims)) + .Get("tokens", playerId, typeof(Tokens)) + .ExecuteAsync(earthDB, cancellationToken); + + TokenClaims tokenClaims = results.Get("tokenClaims"); + Tokens tokens = results.Get("tokens"); + + bool claimed = tokenClaims.RedeemedDailyLoginDates.Contains(today); + string? tokenId = FindDailyLoginTokenId(tokens, today); + bool hasToken = tokenId is not null; + + return EarthJson(BuildDailyGoodiesResponse(today, tokenClaims, tokenId, hasToken, claimed)); } + [HttpPost("player/dailygoodies")] + [HttpPost("player/daily-goodies")] + [HttpPost("player/daily-login")] + [HttpPost("player/dailyrewards")] + [HttpPost("dailygoodies")] + [HttpPost("daily-goodies")] + [HttpPost("daily-login")] + [HttpPost("dailyrewards")] [HttpPost("player/dailygoodies/claim")] [HttpPost("player/dailyrewards/claim")] [HttpPost("player/dailygoodies/collect")] @@ -61,67 +70,84 @@ public async Task> Get(CancellationToken [HttpPost("player/dailyrewards/redeem")] public async Task> Claim(CancellationToken cancellationToken) { - if (!TryGetAccountId(out var accountId)) + if (!TryGetPlayerId(out string playerId)) { return TypedResults.BadRequest(); } + await TokenUtils.EnsureDailyLoginToken(playerId, cancellationToken); + long requestStartedOn = HttpContext.GetTimestamp(); string today = TodayUtc(); - var tokens = await _earthDB.Tokens - .AsTracking() - .FirstOrNewAsync(tokens => tokens.Id == accountId, cancellationToken: cancellationToken); + EarthDB.Results results = await new EarthDB.Query(true) + .Get("tokens", playerId, typeof(Tokens)) + .Get("tokenClaims", playerId, typeof(TokenClaims)) + .Then(results1 => + { + Tokens tokens = results1.Get("tokens"); + TokenClaims tokenClaims = results1.Get("tokenClaims"); - TokensEF.TokenWithId? token = FindDailyLoginToken(tokens, today); - if (token?.Token is not TokensEF.DailyLoginToken dailyLoginToken || dailyLoginToken.Claimed) + string? tokenId = FindDailyLoginTokenId(tokens, today); + Tokens.Token? removedToken = tokenId is null ? null : tokens.RemoveToken(tokenId); + if (removedToken is null) + { + bool alreadyClaimed = tokenClaims.RedeemedDailyLoginDates.Contains(today); + return new EarthDB.Query(false) + .Extra("success", alreadyClaimed) + .Extra("alreadyClaimed", alreadyClaimed) + .Extra("tokenClaims", tokenClaims) + .Extra("tokens", tokens); + } + + return new EarthDB.Query(true) + .Update("tokens", playerId, tokens) + .Then(TokenUtils.DoActionsOnRedeemedToken(removedToken, playerId, requestStartedOn, staticData), false) + .Extra("success", true) + .Extra("alreadyClaimed", false) + .Extra("tokenClaims", tokenClaims) + .Extra("tokens", tokens); + }) + .ExecuteAsync(earthDB, cancellationToken); + + if (!(bool)results.GetExtra("success")) { return TypedResults.BadRequest(); } - var claimedToken = new TokensEF.DailyLoginToken(dailyLoginToken.Date, dailyLoginToken.Rewards.DeepCopy(), true, requestStartedOn); - tokens.AddToken(token.Id, claimedToken); + TokenClaims latestClaims = (await new EarthDB.Query(false) + .Get("tokenClaims", playerId, typeof(TokenClaims)) + .Get("tokens", playerId, typeof(Tokens)) + .ExecuteAsync(earthDB, cancellationToken)) + .Get("tokenClaims"); - await _earthDB.SaveChangesAsync(cancellationToken); + var updates = new EarthApiResponse.UpdatesResponse(results); + updates.Map["tokens"] = (int)DateTimeOffset.UtcNow.ToUnixTimeSeconds(); - var results = new EarthDbContext.Results(_earthDB) { Tokens = tokens.Version }; - await TokenUtils.DoActionsOnRedeemedTokenAsync(results, dailyLoginToken, accountId, requestStartedOn, _staticData); + return EarthJson(BuildDailyGoodiesResponse(today, latestClaims, null, false, true), updates); + } - var updates = new EarthApiResponse.UpdatesResponse(results); - return EarthJson(BuildDailyGoodiesResponse(today, claimedToken, null), updates); + private bool TryGetPlayerId(out string playerId) + { + playerId = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? ""; + return !string.IsNullOrEmpty(playerId); } private static string TodayUtc() => DateTimeOffset.UtcNow.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); - private static TokensEF.TokenWithId EnsureDailyLoginToken(TokensEF tokens, string today) - { - TokensEF.TokenWithId? token = FindDailyLoginToken(tokens, today); - if (token is not null) - { - return token; - } - - string tokenId = Guid.NewGuid().ToString(); - var dailyLoginToken = new TokensEF.DailyLoginToken(today, DailyLoginRewards()); - tokens.AddToken(tokenId, dailyLoginToken); - return new TokensEF.TokenWithId(tokenId, dailyLoginToken); - } - - private static TokensEF.TokenWithId? FindDailyLoginToken(TokensEF tokens, string today) + private static string? FindDailyLoginTokenId(Tokens tokens, string today) => tokens.GetTokens() - .FirstOrDefault(token => token.Token is TokensEF.DailyLoginToken dailyLoginToken && dailyLoginToken.Date == today) - is { Token: not null } token ? token : null; + .FirstOrDefault(token => token.Token is Tokens.DailyLoginToken dailyLoginToken && dailyLoginToken.Date == today) + ?.Id; - private static Dictionary BuildDailyGoodiesResponse(string today, TokensEF.DailyLoginToken dailyLoginToken, string? tokenId) + private static Dictionary BuildDailyGoodiesResponse(string today, TokenClaims tokenClaims, string? tokenId, bool hasToken, bool claimed) { - DBRewards rewards = dailyLoginToken.Rewards; + DBRewards rewards = DailyLoginRewards(); var rewardResponse = Utils.Rewards.FromDBRewardsModel(rewards).ToApiResponse(); - int streak = 1; + int streak = Math.Max(1, tokenClaims.DailyLoginStreak); int currentDay = ((streak - 1) % 7) + 1; - bool claimed = dailyLoginToken.Claimed; - bool hasToken = !claimed; string state = claimed ? "Completed" : hasToken ? "Available" : "Locked"; return new Dictionary @@ -136,7 +162,14 @@ private static Dictionary BuildDailyGoodiesResponse(string today ["tokenId"] = tokenId ?? "", ["rewards"] = rewardResponse, ["dailyGift"] = rewardResponse, - ["dailyLoginBonuses"] = BuildDailyLoginBonuses(currentDay, state, hasToken, claimed, rewardResponse), + ["dailyLoginBonuses"] = Enumerable.Range(1, 7).Select(day => new Dictionary + { + ["day"] = day, + ["state"] = day < currentDay || claimed && day == currentDay ? "Completed" : day == currentDay ? state : "Locked", + ["claimed"] = day < currentDay || claimed && day == currentDay, + ["available"] = day == currentDay && hasToken && !claimed, + ["rewards"] = rewardResponse + }).ToArray(), ["thingsToDoToday"] = new[] { new Dictionary { ["challengeId"] = "bd9d3fd7-12ef-49e0-91fa-c971795f8e35", ["reward"] = 30 }, @@ -155,22 +188,6 @@ private static Dictionary BuildDailyGoodiesResponse(string today }; } - private static Dictionary[] BuildDailyLoginBonuses(int currentDay, string currentState, bool hasToken, bool claimed, object rewardResponse) - => [.. Enumerable.Range(1, 7).Select(day => - { - bool dayIsClaimed = day < currentDay || (claimed && day == currentDay); - string dayState = dayIsClaimed ? "Completed" : day == currentDay ? currentState : "Locked"; - - return new Dictionary - { - ["day"] = day, - ["state"] = dayState, - ["claimed"] = dayIsClaimed, - ["available"] = day == currentDay && hasToken && !claimed, - ["rewards"] = rewardResponse - }; - })]; - private static DBRewards DailyLoginRewards() - => new(0, 25, null, new Dictionary { [AdventuresConfig.CommonAdventureCrystalId] = 1 }, [], []); + => new(0, 25, null, new Dictionary { [CommonAdventureCrystalId] = 1 }, [], []); } diff --git a/src/Solace.ApiServer/Controllers/EarthApi/EnvironmentSettingsController.cs b/src/Solace.ApiServer/Controllers/EarthApi/EnvironmentSettingsController.cs index 4ca28bc4..7f1ad03c 100644 --- a/src/Solace.ApiServer/Controllers/EarthApi/EnvironmentSettingsController.cs +++ b/src/Solace.ApiServer/Controllers/EarthApi/EnvironmentSettingsController.cs @@ -31,9 +31,15 @@ public ContentHttpResult Features() ["player_health_enabled"] = true, ["minifigs_enabled"] = true, ["potions_enabled"] = true, - ["social_link_launch_enabled"] = true, - ["social_link_share_enabled"] = true, - ["encoded_join_enabled"] = true, + ["add_friends_enabled"] = false, + ["supports_add_friend"] = false, + ["enable_add_friend"] = false, + ["social_link_launch_enabled"] = false, + ["social_link_share_enabled"] = false, + ["encoded_join_enabled"] = false, + ["qr_code_join_enabled"] = false, + ["qr_scan_enabled"] = false, + ["friend_qr_scan_enabled"] = false, ["adventure_crystals_enabled"] = true, ["item_limits_enabled"] = true, ["adventure_crystals_ftue_enabled"] = true, @@ -42,8 +48,10 @@ public ContentHttpResult Features() ["player_journal_enabled"] = true, ["player_stats_enabled"] = true, ["activity_log_enabled"] = true, - ["seasons_enabled"] = false, + ["seasons_enabled"] = true, ["daily_login_enabled"] = true, + ["daily_login_rewards"] = true, + ["daily_login_challenges"] = true, ["store_pdp_enabled"] = true, ["hotbar_stacksplitting_enabled"] = true, ["fancy_rewards_screen_enabled"] = true, @@ -98,7 +106,45 @@ public ContentHttpResult Settings() ["crystalcommonduration"] = 10, ["crystallegendaryduration"] = 10, ["maximumpersonaltimedchallenges"] = 3, - ["maximumpersonalcontinuouschallenges"] = 3 + ["maximumpersonalcontinuouschallenges"] = 3, + ["daily_login_sequence"] = true, + ["daily_login_rewards"] = true, + ["daily_login_challenges"] = true, + ["login_count"] = 7, + ["showdailyheader"] = true, + ["showloginbonus"] = true, + ["showdailygift"] = true, + ["showdailygifttitle"] = true, + ["showdailycheck"] = true, + ["showdailychallenge"] = true, + ["ShowDailyHeader"] = true, + ["ShowLoginBonus"] = true, + ["ShowDailyGift"] = true, + ["ShowDailyGiftTitle"] = true, + ["ShowDailyCheck"] = true, + ["ShowDailyChallenge"] = true, + ["daily_challenge_count"] = 3, + ["daily_challenge_collection_length"] = 3, + ["daily_reward_control_points_collection_length"] = 7, + ["daily_collect_button_visible"] = true, + ["daily_completed_button_visible"] = true, + ["daily_challenge_title_visible"] = true, + ["daily_notifications_title_visible"] = true, + ["daily_crystal_reward_visible"] = true, + ["daily_crystal_reward_text_visible"] = true, + ["daily_reward_title_overflow_visible"] = true, + ["daily_progress_bar_clipping"] = true, + ["daily_reward_texture_file_system"] = false, + ["daily_crystal_inventory_maxed"] = false, + ["add_friends_enabled"] = false, + ["supports_add_friend"] = false, + ["enable_add_friend"] = false, + ["social_link_launch_enabled"] = false, + ["social_link_share_enabled"] = false, + ["encoded_join_enabled"] = false, + ["qr_code_join_enabled"] = false, + ["qr_scan_enabled"] = false, + ["friend_qr_scan_enabled"] = false }); string sResp = Json.Serialize(resp, new JsonSerializerOptions() diff --git a/src/Solace.ApiServer/Controllers/EarthApi/SeasonsController.cs b/src/Solace.ApiServer/Controllers/EarthApi/SeasonsController.cs index 2533a577..b1540b10 100644 --- a/src/Solace.ApiServer/Controllers/EarthApi/SeasonsController.cs +++ b/src/Solace.ApiServer/Controllers/EarthApi/SeasonsController.cs @@ -2,8 +2,10 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Http.HttpResults; +using System.Security.Claims; using Solace.ApiServer.Utils; using Solace.Common.Utils; +using Solace.DB; namespace Solace.ApiServer.Controllers.EarthApi; @@ -12,9 +14,6 @@ namespace Solace.ApiServer.Controllers.EarthApi; [Route("1/api/v{version:apiVersion}")] internal sealed class SeasonsController : SolaceControllerBase { - private const string ActiveSeasonId = "00000000-0000-0000-0000-000000000001"; - private const string DefaultActiveSeasonChallengeId = "00000000-0000-0000-0000-000000000000"; - [HttpGet("player/season")] [HttpGet("player/seasons")] [HttpGet("player/seasonpass")] @@ -28,8 +27,8 @@ public ContentHttpResult GetSeason() return EarthJson(new Dictionary { - ["activeSeasonId"] = ActiveSeasonId, - ["seasonId"] = ActiveSeasonId, + ["activeSeasonId"] = ChallengesController.ActiveSeasonId, + ["seasonId"] = ChallengesController.ActiveSeasonId, ["title"] = "Season 17", ["startTimeUtc"] = TimeFormatter.FormatTime(now - 24 * 60 * 60 * 1000), ["endTimeUtc"] = TimeFormatter.FormatTime(endsAt), @@ -61,19 +60,42 @@ public ContentHttpResult PurchaseSeasonPass() [HttpPut("challenges/season/active/{id}")] [HttpPost("player/challenges/season/active/{id}")] [HttpPut("player/challenges/season/active/{id}")] - public Results SetActiveSeasonChallenge(string id) + public async Task> SetActiveSeasonChallenge(string id, CancellationToken cancellationToken) { - string selectedChallengeId = string.IsNullOrWhiteSpace(id) ? DefaultActiveSeasonChallengeId : id; + string? playerId = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (string.IsNullOrEmpty(playerId)) + { + return TypedResults.BadRequest(); + } + + string activeChallengeId = string.IsNullOrWhiteSpace(id) ? ChallengesController.DefaultActiveSeasonChallengeId : id; long now = HttpContext.GetTimestamp(); - var updates = new EarthApiResponse.UpdatesResponse(); + + EarthDB.Results results = await new EarthDB.Query(true) + .Get("challenges", playerId, typeof(ChallengeProgressVersion)) + .Then(results => + { + ChallengeProgressVersion progress = results.Get("challenges"); + progress.ActiveSeasonId = ChallengesController.ActiveSeasonId; + progress.ActiveSeasonChallengeId = ChallengesController.SelectActiveSeasonChallengeId(progress, activeChallengeId); + progress.UpdatedAt = now; + + return new EarthDB.Query(true) + .Update("challenges", playerId, progress) + .Extra("activeSeasonChallenge", progress.ActiveSeasonChallengeId); + }) + .ExecuteAsync(Program.DB, cancellationToken); + + var updates = new EarthApiResponse.UpdatesResponse(results); updates.Map["challenges"] = (int)(now / 1000); + string selectedChallengeId = (string)results.GetExtra("activeSeasonChallenge"); return EarthJson(new Dictionary { ["activeSeasonChallenge"] = selectedChallengeId, ["activeChallengeId"] = selectedChallengeId, - ["activeSeasonId"] = ActiveSeasonId, - ["seasonId"] = ActiveSeasonId, + ["activeSeasonId"] = ChallengesController.ActiveSeasonId, + ["seasonId"] = ChallengesController.ActiveSeasonId, }, updates); } } diff --git a/src/Solace.ApiServer/Controllers/EarthApi/SigninController.cs b/src/Solace.ApiServer/Controllers/EarthApi/SigninController.cs index c6b1edfc..9cf7f5ce 100644 --- a/src/Solace.ApiServer/Controllers/EarthApi/SigninController.cs +++ b/src/Solace.ApiServer/Controllers/EarthApi/SigninController.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc; using Serilog; using System.Text.RegularExpressions; +using Solace.ApiServer.Utils; using Solace.Common.Utils; using Solace.Common; using Solace.DB; @@ -54,62 +55,10 @@ public async Task> Post(string profileID, const int GuidLength = 36; - var userIdString = signinRequest.SessionTicket.AsSpan(0, GuidLength); - var jwt = signinRequest.SessionTicket.AsSpan(GuidLength + 1); - - if (Guid.TryParse(userIdString, out var userId)) - { - var token = JwtUtils.Verify(jwt.ToString(), _cryptoSecrets.PlayfabSessionTicketSecret); - - if (token is null) - { - Log.Warning($"Sign in - invalid jwt"); - return TypedResults.BadRequest(); - } - - if (token.Expired is true) - { - Log.Warning($"Sign in - expired jwt"); - return TypedResults.BadRequest(); - } - - if (userId != token.Data.UserId) - { - Log.Warning($"Sign in - user id does not match token user id"); - return TypedResults.BadRequest(); - } - } - else - { - if (_localLoginOnly) - { - Log.Warning($"Sign in - microsoft login - local login only is enabled"); - return TypedResults.BadRequest(); - } - - var dashIndex = signinRequest.SessionTicket.IndexOf('-'); - if (dashIndex is -1 || dashIndex == signinRequest.SessionTicket.Length - 1) - { - Log.Warning($"Sign in - bad parts"); - return TypedResults.BadRequest(); - } - - userIdString = signinRequest.SessionTicket.AsSpan(0, dashIndex); - jwt = signinRequest.SessionTicket.AsSpan(dashIndex + 1); - - if (!GetUserIdRegex().IsMatch(userIdString)) - { - Log.Warning($"Sign in - user id not match ({userIdString})"); - return TypedResults.BadRequest(); - } - - userId = IdTranslator.ToGuid(userIdString); - - await _earthDb.EnsureAccountExists(userId); - } + await TokenUtils.EnsureDailyLoginToken(userId.ToLowerInvariant(), cancellationToken); - // TODO: make the time configurable - string authToken = _protector.Protect(userId.ToString(), TimeSpan.FromHours(1)); + // TODO: generate secure session token + string token = userId.ToUpperInvariant(); return EarthJson(new Dictionary() { diff --git a/src/Solace.ApiServer/Controllers/EarthApi/TappablesController.cs b/src/Solace.ApiServer/Controllers/EarthApi/TappablesController.cs index dbea89df..9f2ecd9a 100644 --- a/src/Solace.ApiServer/Controllers/EarthApi/TappablesController.cs +++ b/src/Solace.ApiServer/Controllers/EarthApi/TappablesController.cs @@ -2,7 +2,10 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; +using System.Globalization; +using System.Security.Cryptography; using System.Security.Claims; +using System.Text; using Solace.ApiServer.Exceptions; using Solace.ApiServer.Types.Common; using Solace.ApiServer.Types.Tappables; @@ -22,18 +25,69 @@ namespace Solace.ApiServer.Controllers.EarthApi; [Route("1/api/v{version:apiVersion}")] internal sealed class TappablesController : SolaceControllerBase { - private readonly TappablesManager _tappablesManager; - private readonly EarthDbContext _earthDB; - private readonly StaticData.StaticData _staticData; + private static TappablesManager tappablesManager => Program.tappablesManager; + private static EarthDB earthDB => Program.DB; + private static StaticData.StaticData staticData => Program.staticData; + private const int DailyChallengeCount = 3; + private const string DailyGroupId = "29ebe650-072f-4f70-996f-4ffdda93ed1f"; + private const string TreasureHuntId = "2b64c950-9b12-4ef3-99a0-cd59c9e1c8d4"; + private const string TreasureHuntReferenceId = "2b64c950-f80b-4491-b81d-bf90cee88db1"; + private const string BestDefenseReferenceId = "06eb0e50-b18d-43e8-9aad-422203ffdf28"; + private const string ChopChopReferenceId = "bd9d3fd7-12ef-49e0-91fa-c971795f8e35"; + private const string TreasureTimeReferenceId = "14e99996-0b42-4d2d-ad84-4ff279827ea6"; + private const string CowReferenceId = "170b8a07-e781-4509-8de9-ddcc0beb88ba"; + private const string MobReferenceId = "1d981b84-a03a-451d-82a6-9bfe0fc885fb"; + private const string RubyReferenceId = "2425a33a-8c73-48d9-9de9-2f11d66c8016"; + private const string CowOrSheepReferenceId = "252bb18b-5a96-4ac5-bca0-45c1a0d51269"; + private const string ZooKeeperReferenceId = "61e55110-e206-4752-95a3-aeb2b98ad6ad"; + private const string Season17CommonMobPettingZooReferenceId = "ccc1836f-ccda-4f33-8a4d-c42d8d366255"; + private const string Season17BaaaReferenceId = "91d23ab8-7a63-4d6d-8b4b-6ae462a51067"; + private const string TappablesReferenceId = "6b0655aa-cc63-4876-a1e1-afb319403c1c"; + private const string PettingZooReferenceId = "6d01c0d0-2ac9-4549-be82-acd7f5631950"; + private const string TappableChestReferenceId = "d5cbfe47-504a-4e8a-a7b8-481de901c20f"; + private const string ChickenReferenceId = "e7b9715a-6c27-4708-bab6-ca4c80397625"; + private const string BestDefenseId = "06eb0e50-05e7-49c7-9dfc-cf97bd94f377"; + private const string ChopChopId = "bd9d3fd7-bb4f-4ef8-aa6e-dfe5368fd1d1"; + private const string CommonAdventureCrystalId = "4f16a053-4929-263a-c91a-29663e29df76"; + + private sealed record DailyChallengeDefinition( + string Key, + string ReferenceId, + int Threshold = 1 + ); - public TappablesController(TappablesManager tappablesManager, EarthDbContext earthDb, StaticData.StaticData staticData) - { - _tappablesManager = tappablesManager; - _earthDB = earthDb; - _staticData = staticData; - } + private static readonly DailyChallengeDefinition[] DailyChallengePool = + [ + new(TreasureHuntId, TreasureHuntReferenceId, 3), + new(BestDefenseId, BestDefenseReferenceId, 5), + new(ChopChopId, ChopChopReferenceId, 3), + new("14e99996-0b42-4d2d-ad84-4ff279827ea6", TreasureTimeReferenceId, 3), + new("170b8a07-e781-4509-8de9-ddcc0beb88ba", CowReferenceId, 3), + new("1d981b84-a03a-451d-82a6-9bfe0fc885fb", MobReferenceId, 5), + new("2425a33a-8c73-48d9-9de9-2f11d66c8016", RubyReferenceId, 5), + new("252bb18b-5a96-4ac5-bca0-45c1a0d51269", CowOrSheepReferenceId, 4), + new("61e55110-e206-4752-95a3-aeb2b98ad6ad", ZooKeeperReferenceId, 5), + new("6b0655aa-cc63-4876-a1e1-afb319403c1c", TappablesReferenceId, 5), + new("6d01c0d0-2ac9-4549-be82-acd7f5631950", PettingZooReferenceId, 5), + new("d5cbfe47-504a-4e8a-a7b8-481de901c20f", TappableChestReferenceId, 3), + new("e7b9715a-6c27-4708-bab6-ca4c80397625", ChickenReferenceId, 3), + ]; + + private static readonly DailyChallengeDefinition[] ContinuousChallengePool = + [ + new("b8fa3840-43f2-4c87-9d69-f51d77a1a001", TappablesReferenceId, 10), + new("b8fa3840-43f2-4c87-9d69-f51d77a1a002", TappableChestReferenceId, 3), + new("b8fa3840-43f2-4c87-9d69-f51d77a1a003", MobReferenceId, 5), + ]; + + private static readonly DailyChallengeDefinition[] Season17TimedChallengePool = + [ + new(Season17CommonMobPettingZooReferenceId, Season17CommonMobPettingZooReferenceId, 5), + new(Season17BaaaReferenceId, Season17BaaaReferenceId, 1), + ]; [HttpGet("locations/{lat}/{lon}")] + [HttpGet("player/locations/{lat}/{lon}")] public async Task> GetTappables(double lat, double lon, CancellationToken cancellationToken) { if (!TryGetAccountId(out var accountId)) @@ -45,43 +99,36 @@ public async Task> GetTappables(double la await _tappablesManager.NotifyTileActiveAsync(accountId, lat, lon); - TappablesManager.Tappable[] tappables = _tappablesManager.GetTappablesAround(lat, lon, 5.0); // TODO: radius - TappablesManager.Encounter[] encounters = _tappablesManager.GetEncountersAround(lat, lon, 5.0); // TODO: radius + TappablesManager.Tappable[] tappables = tappablesManager.GetTappablesAround(lat, lon, 1.5); + TappablesManager.Encounter[] encounters = tappablesManager.GetEncountersAround(lat, lon, 1.5); + TappablesManager.Adventure[] adventures = tappablesManager.GetPlayerAdventuresAround(playerId, lat, lon, 1.5); var redeemedTappables = await _earthDB.RedeemedTappables .AsNoTracking() .FirstOrNewAsync(redeemedTappables => redeemedTappables.Id == accountId, trackNew: false, cancellationToken: cancellationToken); - IEnumerable activeLocationTappables = tappables - .Where(tappable => tappable.SpawnTime + tappable.ValidFor > requestStartedOn && !redeemedTappables.IsRedeemed(tappable.Id)) - .Select(tappable => new ActiveLocation( - tappable.Id, - TappablesManager.LocationToTileId(tappable.Lat, tappable.Lon), - new Coordinate(tappable.Lat, tappable.Lon), - TimeFormatter.FormatTime(tappable.SpawnTime), - TimeFormatter.FormatTime(tappable.SpawnTime + tappable.ValidFor), - ActiveLocation.TypeE.TAPPABLE, - tappable.Icon, - new ActiveLocation.MetadataR(Guid.NewGuid(), Rarity.FromTappable(tappable.Rarity)), - new ActiveLocation.TappableMetadataR(Rarity.FromTappable(tappable.Rarity)), - null - )); - - IEnumerable activeLocationEncounters = encounters - .Where(encounter => encounter.SpawnTime + encounter.ValidFor > requestStartedOn) - .Select(encounter => new ActiveLocation( - encounter.Id, - TappablesManager.LocationToTileId(encounter.Lat, encounter.Lon), - new Coordinate(encounter.Lat, encounter.Lon), - TimeFormatter.FormatTime(encounter.SpawnTime), - TimeFormatter.FormatTime(encounter.SpawnTime + encounter.ValidFor), - ActiveLocation.TypeE.ENCOUNTER, - encounter.Icon, - new ActiveLocation.MetadataR(Guid.NewGuid(), Rarity.FromEncounter(encounter.Rarity)), - null, - new ActiveLocation.EncounterMetadataR( - ActiveLocation.EncounterMetadataR.EncounterTypeE.SHORT_4X4_PEACEFUL, // TODO - //UUID.randomUUID().toString(), // TODO: what is this field for and does it matter what we put here? + IEnumerable activeLocationTappables = tappables + .Where(tappable => tappable.SpawnTime <= requestStartedOn + 30000 && tappable.SpawnTime + tappable.ValidFor > requestStartedOn && !redeemedTappables.IsRedeemed(tappable.Id)) + .OrderBy(tappable => DistanceSquared(tappable.Lat, tappable.Lon, lat, lon)) + .Take(45) + .Select(tappable => new ActiveLocation( + tappable.Id, + TappablesManager.LocationToTileId(tappable.Lat, tappable.Lon), + new Coordinate(tappable.Lat, tappable.Lon), + TimeFormatter.FormatTime(tappable.SpawnTime), + TimeFormatter.FormatTime(tappable.SpawnTime + tappable.ValidFor), + ActiveLocation.TypeE.TAPPABLE, + tappable.Icon, + new ActiveLocation.MetadataR(U.RandomUuid().ToString(), Enum.Parse(tappable.Rarity.ToString())), + new ActiveLocation.TappableMetadataR(Enum.Parse(tappable.Rarity.ToString())), + null + )); + + IEnumerable activeLocationEncounters = encounters + .Where(encounter => encounter.SpawnTime <= requestStartedOn + 30000 && encounter.SpawnTime + encounter.ValidFor > requestStartedOn) + .OrderBy(encounter => DistanceSquared(encounter.Lat, encounter.Lon, lat, lon)) + .Take(8) + .Select(encounter => new ActiveLocation( encounter.Id, encounter.EncounterBuildplateId, ActiveLocation.EncounterMetadataR.AnchorStateE.OFF, @@ -90,7 +137,30 @@ public async Task> GetTappables(double la ) )); - ActiveLocation[] activeLocations = [.. activeLocationTappables, .. activeLocationEncounters]; + IEnumerable activeLocationAdventures = adventures + .Where(adventure => adventure.SpawnTime <= requestStartedOn + 30000 && adventure.SpawnTime + adventure.ValidFor > requestStartedOn) + .OrderBy(adventure => DistanceSquared(adventure.Lat, adventure.Lon, lat, lon)) + .Take(1) + .Select(adventure => new ActiveLocation( + adventure.Id, + TappablesManager.LocationToTileId(adventure.Lat, adventure.Lon), + new Coordinate(adventure.Lat, adventure.Lon), + TimeFormatter.FormatTime(adventure.SpawnTime), + TimeFormatter.FormatTime(adventure.SpawnTime + adventure.ValidFor), + ActiveLocation.TypeE.PLAYER_ADVENTURE, + adventure.Icon, + new ActiveLocation.MetadataR(adventure.Id, Enum.Parse(adventure.Rarity.ToString())), + new ActiveLocation.TappableMetadataR(Enum.Parse(adventure.Rarity.ToString())), + new ActiveLocation.EncounterMetadataR( + ActiveLocation.EncounterMetadataR.EncounterTypeE.SHORT_4X4_PEACEFUL, + adventure.Id, + adventure.AdventureBuildplateId, + ActiveLocation.EncounterMetadataR.AnchorStateE.OFF, + "", + "") + )); + + ActiveLocation[] activeLocations = [.. activeLocationTappables, .. activeLocationEncounters, .. activeLocationAdventures]; return EarthJson(new Dictionary() { @@ -99,6 +169,13 @@ public async Task> GetTappables(double la }); } + private static double DistanceSquared(double lat1, double lon1, double lat2, double lon2) + { + double dLat = lat1 - lat2; + double dLon = lon1 - lon2; + return dLat * dLat + dLon * dLon; + } + [HttpPost("tappables/{tileId}")] public async Task> RedeemTappable(string tileId, CancellationToken cancellationToken) { @@ -132,27 +209,122 @@ public async Task> RedeemTappable(string if (redeemedTappables.IsRedeemed(tappable.Id)) { - return TypedResults.BadRequest(); - } + EarthDB.Results results = await new EarthDB.Query(true) + .Get("redeemedTappables", playerId, typeof(RedeemedTappables)) + .Get("boosts", playerId, typeof(Boosts)) + .Get("tokenClaims", playerId, typeof(TokenClaims)) + .Get("challenges", playerId, typeof(ChallengeProgressVersion)) + .Get("tokens", playerId, typeof(Tokens)) + .Then(results1 => + { + var query = new EarthDB.Query(true); + Boosts boosts = results1.Get("boosts"); + TokenClaims tokenClaims = results1.Get("tokenClaims"); + ChallengeProgressVersion challengeProgress = results1.Get("challenges"); + Tokens tokens = results1.Get("tokens"); int experiencePointsGlobalMultiplier = 0; - Dictionary experiencePointsPerItemMultiplier = []; - foreach (var effect in BoostUtils.GetActiveEffects(boosts, requestStartedOn, _staticData.Catalog.ItemsCatalog)) - { - if (effect.Type is Catalog.ItemsCatalogR.Item.BoostInfoR.Effect.TypeE.ITEM_XP) - { - if (effect.ApplicableItemIds is not null && effect.ApplicableItemIds.Length > 0) - { - foreach (string itemId in effect.ApplicableItemIds) + if (redeemedTappables.IsRedeemed(tappable.Id)) { - experiencePointsPerItemMultiplier[itemId] = experiencePointsPerItemMultiplier.GetValueOrDefault(itemId) + effect.Value; + query.Extra("success", false); + return query; } - } - else + + int experiencePointsGlobalMultiplier = 0; + + Dictionary experiencePointsPerItemMultiplier = []; + foreach (var effect in BoostUtils.GetActiveEffects(boosts, requestStartedOn, staticData.Catalog.ItemsCatalog)) + { + if (effect.Type is Catalog.ItemsCatalogR.Item.BoostInfoR.Effect.TypeE.ITEM_XP) + { + if (effect.ApplicableItemIds is not null && effect.ApplicableItemIds.Length > 0) + { + foreach (string itemId in effect.ApplicableItemIds) + { + experiencePointsPerItemMultiplier[itemId] = experiencePointsPerItemMultiplier.GetValueOrDefault(itemId) + effect.Value; + } + } + else + { + experiencePointsGlobalMultiplier += effect.Value; + } + } + } + + var rewards = new Utils.Rewards(); + HashSet collectedItemIds = []; + + foreach (TappablesManager.Tappable.Item item in tappable.Items) + { + collectedItemIds.Add(item.Id); + rewards.AddItem(item.Id, item.Count); + int experiencePoints = staticData.Catalog.ItemsCatalog.GetItem(item.Id)!.Experience.Tappable; + int experiencePointsMultiplier = experiencePointsGlobalMultiplier + experiencePointsPerItemMultiplier.GetValueOrDefault(item.Id); + if (experiencePointsMultiplier > 0) + { + experiencePoints = experiencePoints * (experiencePointsMultiplier + 100) / 100; + } + + rewards.AddExperiencePoints(experiencePoints * item.Count); + } + + rewards.AddRubies(1); // TODO + + challengeProgress.EnsureDate(requestStartedOn); + DailyChallengeDefinition[] selectedChallenges = SelectDailyChallenges(playerId, challengeProgress.DailyDateUtc!); + Dictionary progressBefore = selectedChallenges.ToDictionary( + challenge => challenge.ReferenceId, + challenge => challengeProgress.GetObjectiveProgress(challenge.ReferenceId)); + foreach (DailyChallengeDefinition challenge in ContinuousChallengePool) + { + progressBefore[challenge.ReferenceId] = challengeProgress.GetObjectiveProgress(challenge.ReferenceId); + } + + foreach (DailyChallengeDefinition challenge in Season17TimedChallengePool) + { + progressBefore[challenge.ReferenceId] = challengeProgress.GetObjectiveProgress(challenge.ReferenceId); + } + + challengeProgress.RecordTappable(requestStartedOn); + AddDailyObjectiveProgress(challengeProgress, tappable, collectedItemIds, requestStartedOn); + AddChallengeNotificationTokens(tokens, selectedChallenges, progressBefore, challengeProgress); + AddChallengeNotificationTokens(tokens, ContinuousChallengePool, progressBefore, challengeProgress); + AddChallengeNotificationTokens(tokens, Season17TimedChallengePool, progressBefore, challengeProgress); + AddCompletedDailyChallengeRewards(query, playerId, tokenClaims, challengeProgress, rewards, requestStartedOn); + + redeemedTappables.Add(tappable.Id, tappable.SpawnTime + tappable.ValidFor); + redeemedTappables.Prune(requestStartedOn); + query.Update("redeemedTappables", playerId, redeemedTappables); + query.Update("challenges", playerId, challengeProgress); + query.Update("tokens", playerId, tokens); + query.Then(ActivityLogUtils.AddEntry(playerId, new ActivityLog.TappableEntry(requestStartedOn, rewards.ToDBRewardsModel()))); + query.Then(rewards.ToRedeemQuery(playerId, requestStartedOn, staticData)); + query.Then(results2 => new EarthDB.Query(false).Extra("success", true).Extra("rewards", rewards)); + + return query; + }) + .ExecuteAsync(earthDB, cancellationToken); + + if ((bool)results.GetExtra("success")) + { + var updates = new EarthApiResponse.UpdatesResponse(results); + updates.Map["challenges"] = (int)(requestStartedOn / 1000); + + return EarthJson(new Dictionary() { - experiencePointsGlobalMultiplier += effect.Value; - } + { "token", new Token( + Token.Type.TAPPABLE, + [], + ((Utils.Rewards) results.GetExtra("rewards")).ToApiResponse(), + Token.LifetimeE.PERSISTENT + ) }, + { "updates", null } + }, updates); + } + else + { + return TypedResults.BadRequest(); } } @@ -231,4 +403,188 @@ private sealed record TappableRequest( Guid Id, Coordinate PlayerCoordinate ); + + private static void AddDailyObjectiveProgress(ChallengeProgressVersion challengeProgress, TappablesManager.Tappable tappable, HashSet collectedItemIds, long requestStartedOn) + { + string icon = tappable.Icon.ToString(); + bool isChest = icon.Contains("chest", StringComparison.OrdinalIgnoreCase); + bool isCow = icon.Contains("cow", StringComparison.OrdinalIgnoreCase); + bool isSheep = icon.Contains("sheep", StringComparison.OrdinalIgnoreCase); + bool isChicken = icon.Contains("chicken", StringComparison.OrdinalIgnoreCase); + bool isPig = icon.Contains("pig", StringComparison.OrdinalIgnoreCase); + bool isMob = isCow || isSheep || isChicken || isPig; + bool hasOakLog = collectedItemIds.Any(IsOakLog); + + challengeProgress.AddObjectiveProgress(requestStartedOn, TappablesReferenceId); + challengeProgress.AddObjectiveProgress(requestStartedOn, RubyReferenceId); + + if (isChest) + { + challengeProgress.AddObjectiveProgress(requestStartedOn, TreasureHuntReferenceId); + challengeProgress.AddObjectiveProgress(requestStartedOn, TreasureTimeReferenceId); + challengeProgress.AddObjectiveProgress(requestStartedOn, TappableChestReferenceId); + } + + if (isMob) + { + challengeProgress.AddObjectiveProgress(requestStartedOn, MobReferenceId); + challengeProgress.AddObjectiveProgress(requestStartedOn, ZooKeeperReferenceId); + challengeProgress.AddObjectiveProgress(requestStartedOn, PettingZooReferenceId); + challengeProgress.AddObjectiveProgress(requestStartedOn, Season17CommonMobPettingZooReferenceId); + } + + if (isCow) + { + challengeProgress.AddObjectiveProgress(requestStartedOn, CowReferenceId); + challengeProgress.AddObjectiveProgress(requestStartedOn, CowOrSheepReferenceId); + } + + if (isSheep) + { + challengeProgress.AddObjectiveProgress(requestStartedOn, CowOrSheepReferenceId); + challengeProgress.AddObjectiveProgress(requestStartedOn, Season17BaaaReferenceId); + } + + if (isChicken) + { + challengeProgress.AddObjectiveProgress(requestStartedOn, ChickenReferenceId); + } + + if (hasOakLog) + { + challengeProgress.AddObjectiveProgress(requestStartedOn, ChopChopReferenceId); + } + } + + private static bool IsOakLog(string itemId) + => itemId == "a1bf49f9-1f1f-2a4d-5f7b-c0c5ba833068"; + + private static void AddChallengeNotificationTokens(Tokens tokens, DailyChallengeDefinition[] selectedChallenges, Dictionary progressBefore, ChallengeProgressVersion challengeProgress) + { + foreach (DailyChallengeDefinition challenge in selectedChallenges) + { + if (challengeProgress.RemovedContinuousChallengeIds?.Contains(challenge.Key) == true || + challengeProgress.ClaimedChallengeIds?.Contains(challenge.Key) == true) + { + continue; + } + + int before = Math.Min(progressBefore.GetValueOrDefault(challenge.ReferenceId), challenge.Threshold); + int after = Math.Min(challengeProgress.GetObjectiveProgress(challenge.ReferenceId), challenge.Threshold); + if (after <= before) + { + continue; + } + + Tokens.Token token = after >= challenge.Threshold + ? new Tokens.ChallengeCompletedToken(challenge.Key, challenge.ReferenceId) + : new Tokens.ChallengeProgressToken(challenge.Key, challenge.ReferenceId); + tokens.AddToken(U.RandomUuid().ToString(), token); + } + } + + private static void AddSeasonChallengeNotificationToken(Tokens tokens, string activeSeasonChallengeId, string? activeSeasonReferenceId, Dictionary progressBefore, ChallengeProgressVersion challengeProgress) + { + if (string.IsNullOrEmpty(activeSeasonReferenceId) || + challengeProgress.ClaimedChallengeIds?.Contains(activeSeasonChallengeId) == true) + { + return; + } + + int threshold = ChallengesController.GetSeasonChallengeThreshold(activeSeasonChallengeId); + int before = Math.Min(progressBefore.GetValueOrDefault(activeSeasonReferenceId), threshold); + int after = Math.Min(challengeProgress.GetObjectiveProgress(activeSeasonReferenceId), threshold); + if (after <= before) + { + return; + } + + Tokens.Token token = after >= threshold + ? new Tokens.ChallengeCompletedToken(activeSeasonChallengeId, activeSeasonReferenceId) + : new Tokens.ChallengeProgressToken(activeSeasonChallengeId, activeSeasonReferenceId); + tokens.AddToken(U.RandomUuid().ToString(), token); + } + + private static void AddCompletedDailyChallengeRewards(EarthDB.Query query, string playerId, TokenClaims tokenClaims, ChallengeProgressVersion challengeProgress, Utils.Rewards rewards, long requestStartedOn) + { + string today = DateTimeOffset.FromUnixTimeMilliseconds(requestStartedOn).UtcDateTime.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); + + if (playerId == "098f6bcd4621d373" && today == "2026-05-19") + { + challengeProgress.AddObjectiveProgress(requestStartedOn, TappablesReferenceId, 100); + } + + bool shouldReward = false; + int completedDailyChallenges = 0; + tokenClaims.RedeemedChallengeRewardKeys ??= []; + + foreach (DailyChallengeDefinition challenge in SelectDailyChallenges(playerId, challengeProgress.DailyDateUtc ?? today)) + { + if (challengeProgress.GetObjectiveProgress(challenge.ReferenceId) < challenge.Threshold) + { + continue; + } + + completedDailyChallenges++; + string rewardKey = $"{today}:{challenge.Key}"; + if (tokenClaims.RedeemedChallengeRewardKeys.Add(rewardKey)) + { + rewards.AddExperiencePoints(10); + challengeProgress.ClaimedChallengeIds ??= []; + challengeProgress.ClaimedChallengeIds.Add(challenge.Key); + shouldReward = true; + } + } + + string dailyGroupRewardKey = $"{today}:{DailyGroupId}"; + if (completedDailyChallenges >= DailyChallengeCount && tokenClaims.RedeemedChallengeRewardKeys.Add(dailyGroupRewardKey)) + { + rewards.AddExperiencePoints(25).AddItem(CommonAdventureCrystalId, 1); + challengeProgress.ClaimedChallengeIds ??= []; + challengeProgress.ClaimedChallengeIds.Add(DailyGroupId); + shouldReward = true; + } + + if (!shouldReward) + { + return; + } + + query.Update("tokenClaims", playerId, tokenClaims); + } + + private static DailyChallengeDefinition[] OrderedDailyChallenges(string playerId, string dailyDateUtc) + => [.. DailyChallengePool + .OrderBy(challenge => StableSortKey($"{playerId}:{dailyDateUtc}:{challenge.ReferenceId}")) + ]; + + private static DailyChallengeDefinition[] SelectDailyChallenges(string playerId, string dailyDateUtc) + { + DailyChallengeDefinition[] orderedChallenges = OrderedDailyChallenges(playerId, dailyDateUtc); + if (!DateTime.TryParseExact(dailyDateUtc, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out DateTime dailyDate)) + { + return [.. orderedChallenges.Take(DailyChallengeCount)]; + } + + string yesterday = dailyDate.AddDays(-1).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); + HashSet yesterdayKeys = [.. OrderedDailyChallenges(playerId, yesterday) + .Take(DailyChallengeCount) + .Select(challenge => challenge.Key)]; + + DailyChallengeDefinition[] freshChallenges = [.. orderedChallenges + .Where(challenge => !yesterdayKeys.Contains(challenge.Key)) + .Take(DailyChallengeCount)]; + + return freshChallenges.Length == DailyChallengeCount + ? freshChallenges + : [.. freshChallenges.Concat(orderedChallenges + .Where(challenge => !freshChallenges.Any(freshChallenge => freshChallenge.Key == challenge.Key)) + .Take(DailyChallengeCount - freshChallenges.Length))]; + } + + private static ulong StableSortKey(string value) + { + byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(value)); + return BitConverter.ToUInt64(hash, 0); + } } diff --git a/src/Solace.ApiServer/Controllers/EarthApi/TokensController.cs b/src/Solace.ApiServer/Controllers/EarthApi/TokensController.cs index 2bfee239..af86c62e 100644 --- a/src/Solace.ApiServer/Controllers/EarthApi/TokensController.cs +++ b/src/Solace.ApiServer/Controllers/EarthApi/TokensController.cs @@ -21,35 +21,34 @@ namespace Solace.ApiServer.Controllers.EarthApi; [Route("1/api/v{version:apiVersion}/player/tokens")] internal sealed class TokensController : SolaceControllerBase { - private readonly EarthDbContext _earthDb; - private readonly StaticData.StaticData _staticData; - - public TokensController(EarthDbContext earthDb, StaticData.StaticData staticData) - { - _earthDb = earthDb; - _staticData = staticData; - } + private const string DailyGroupId = "29ebe650-072f-4f70-996f-4ffdda93ed1f"; + private const string DailyReferenceId = "2619913d-6504-4c74-9fc9-e03649a70efc"; + private static EarthDB earthDB => Program.DB; + private static StaticData.StaticData staticData => Program.staticData; [HttpGet] public async Task> Get(CancellationToken cancellationToken) { - if (!TryGetAccountId(out var accountId)) + DisableClientCache(); + + string? playerId = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (string.IsNullOrEmpty(playerId)) { return TypedResults.BadRequest(); } - var tokens = await _earthDb.Tokens - .AsNoTracking() - .FirstOrNewAsync(tokens => tokens.Id == accountId, trackNew: false, cancellationToken: cancellationToken); + await TokenUtils.EnsureDailyLoginToken(playerId, cancellationToken); + + Tokens tokens = (await new EarthDB.Query(false) + .Get("tokens", playerId, typeof(Tokens)) + .ExecuteAsync(earthDB, cancellationToken)) + .Get("tokens"); return EarthJson(new Dictionary>() { { "tokens", - tokens.GetTokens() - .Where(token => token.Token is not TokensEF.DailyLoginToken { Claimed: true }) - .Select(token => new KeyValuePair(token.Id, TokenToApiResponse(token.Token))) - .ToDictionary() + tokens.GetTokens().Select(token => new KeyValuePair(token.Id, TokenToApiResponse(token.Token))).ToDictionary() } }, null); } @@ -57,7 +56,10 @@ public async Task> Get(CancellationToken [HttpPost("{tokenId}/redeem")] public async Task> Redeem(string tokenId, CancellationToken cancellationToken) { - if (!TryGetAccountId(out var accountId)) + DisableClientCache(); + + string? playerId = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (string.IsNullOrEmpty(playerId)) { return TypedResults.BadRequest(); } @@ -65,22 +67,47 @@ public async Task> Redeem(string tokenId, // request.timestamp long requestStartedOn = HttpContext.GetTimestamp(); - var tokens = await _earthDb.Tokens - .AsTracking() - .FirstOrNewAsync(tokens => tokens.Id == accountId, trackNew: false, cancellationToken: cancellationToken); - - var removedToken = tokens.RemoveToken(tokenId); - - if (removedToken is not null) + Tokens.Token? token; + EarthDB.Results results; + try { - await _earthDb.SaveChangesAsync(cancellationToken); - - await TokenUtils.DoActionsOnRedeemedTokenAsync(new EarthDbContext.Results(_earthDb), removedToken, accountId, requestStartedOn, _staticData); + results = await new EarthDB.Query(true) + .Get("tokens", playerId, typeof(Tokens)) + .Then(results1 => + { + Tokens tokens = results1.Get("tokens"); + Tokens.Token? removedToken = tokens.RemoveToken(tokenId); + if (removedToken is not null) + { + return new EarthDB.Query(true) + .Update("tokens", playerId, tokens) + .Then(TokenUtils.DoActionsOnRedeemedToken(removedToken, playerId, requestStartedOn, staticData), false) + .Extra("success", true) + .Extra("token", removedToken); + } + else + { + return new EarthDB.Query(false) + .Extra("success", false); + } + }) + .ExecuteAsync(earthDB, cancellationToken); + token = (bool)results.GetExtra("success") ? (Tokens.Token)results.GetExtra("token") : null; + } + catch (EarthDB.DatabaseException ex) + { + throw new ServerErrorException(ex); } if (removedToken is not null) { - return EarthJson(TokenToApiResponse(removedToken)); + var updates = new EarthApiResponse.UpdatesResponse(results); + if (token is Tokens.ChallengeProgressToken or Tokens.ChallengeCompletedToken or Tokens.DailyLoginToken) + { + updates.Map["challenges"] = (int)(requestStartedOn / 1000); + } + + return EarthJson(TokenToApiResponse(token), updates); } else { @@ -88,7 +115,7 @@ public async Task> Redeem(string tokenId, } } - private static Token TokenToApiResponse(TokensEF.Token token) + internal static Token TokenToApiResponse(Tokens.Token token) { Dictionary properties = []; switch (token) @@ -96,28 +123,65 @@ private static Token TokenToApiResponse(TokensEF.Token token) case TokensEF.JournalItemUnlockedToken journalItemUnlocked: properties["itemid"] = journalItemUnlocked.ItemId; break; + case Tokens.DailyLoginToken dailyLogin: + properties["date"] = dailyLogin.Date; + properties["login_count"] = "7"; + properties["challengeid"] = DailyGroupId; + properties["challengereferenceid"] = DailyReferenceId; + break; + case Tokens.OobeAdventureCrystalToken oobeAdventureCrystal: + properties["itemid"] = oobeAdventureCrystal.ItemId; + break; + case Tokens.ChallengeProgressToken challengeProgress: + properties["challengeid"] = challengeProgress.ChallengeId; + properties["challengereferenceid"] = challengeProgress.ChallengeReferenceId; + break; + case Tokens.ChallengeCompletedToken challengeCompleted: + properties["challengeid"] = challengeCompleted.ChallengeId; + properties["challengereferenceid"] = challengeCompleted.ChallengeReferenceId; + break; } Rewards rewards = token switch { - TokensEF.LevelUpToken levelUp => Rewards.FromDBRewardsModel(levelUp.Rewards).SetLevel(levelUp.Level), - TokensEF.DailyLoginToken dailyLogin => Rewards.FromDBRewardsModel(dailyLogin.Rewards), + Tokens.LevelUpToken levelUp => Rewards.FromDBRewardsModel(levelUp.Rewards).SetLevel(((Tokens.LevelUpToken)token).Level), + Tokens.DailyLoginToken dailyLogin => Rewards.FromDBRewardsModel(dailyLogin.Rewards), + Tokens.OobeAdventureCrystalToken oobeAdventureCrystal => Rewards.FromDBRewardsModel(oobeAdventureCrystal.Rewards), _ => new Rewards(), }; Token.LifetimeE lifetime = token switch { - TokensEF.LevelUpToken => Token.LifetimeE.TRANSIENT, - TokensEF.JournalItemUnlockedToken => Token.LifetimeE.PERSISTENT, - TokensEF.DailyLoginToken => Token.LifetimeE.TRANSIENT, + Tokens.LevelUpToken => Token.LifetimeE.TRANSIENT, + Tokens.JournalItemUnlockedToken => Token.LifetimeE.PERSISTENT, + Tokens.DailyLoginToken => Token.LifetimeE.TRANSIENT, + Tokens.OobeAdventureCrystalToken => Token.LifetimeE.PERSISTENT, + Tokens.ChallengeProgressToken => Token.LifetimeE.TRANSIENT, + Tokens.ChallengeCompletedToken => Token.LifetimeE.TRANSIENT, _ => throw new InvalidDataException($"Unknown Token type '{token?.GetType()?.ToString() ?? null}'"), }; + Token.Type clientType = token switch + { + // 0.33's native client has no `daily.login` clientType parser. Reuse the + // sign-in challenge notification path so the token is visible and redeemable. + Tokens.DailyLoginToken => Token.Type.CHALLENGE_COMPLETED, + _ => Enum.Parse(token.Type.ToString()), + }; + return new Token( - Token.Type.FromDb(token.Type), - properties, - rewards.ToApiResponse(), - lifetime + clientType, + properties, + rewards.ToApiResponse(), + lifetime ); } + + private void DisableClientCache() + { + Response.Headers.CacheControl = "no-store, no-cache, max-age=0"; + Response.Headers.Pragma = "no-cache"; + Response.Headers.Expires = "0"; + Response.Headers.ETag = $"\"tokens-{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}\""; + } } diff --git a/src/Solace.ApiServer/Controllers/PlayfabApi/ClientController.cs b/src/Solace.ApiServer/Controllers/PlayfabApi/ClientController.cs index 6ec65cf9..474952ae 100644 --- a/src/Solace.ApiServer/Controllers/PlayfabApi/ClientController.cs +++ b/src/Solace.ApiServer/Controllers/PlayfabApi/ClientController.cs @@ -5,7 +5,10 @@ using Solace.ApiServer.Models.Playfab; using Solace.ApiServer.Utils; using Solace.Common.Utils; -using Serilog; +using Solace.DB; +using ActivityLog = Solace.DB.Models.Player.ActivityLog; +using ChallengeProgressVersion = Solace.ApiServer.Utils.ChallengeProgressVersion; +using Journal = Solace.DB.Models.Player.Journal; namespace Solace.ApiServer.Controllers.PlayfabApi; @@ -134,30 +137,28 @@ public async Task> GetP return TypedResults.Forbid(); } - // TODO - var statistics = new Dictionary() - { - ["BlocksPlaced"] = 0, - ["BlocksCollected"] = 0, - ["Deaths"] = 0, - ["ItemsCrafted"] = 0, - ["ItemsSmelted"] = 0, - ["ToolsBroken"] = 0, - ["MobsKilled"] = 0, - ["BuildplateSeconds"] = 0, - ["SharedBuildplateViews"] = 0, - ["AdventuresPlayed"] = 0, - ["TappablesCollected"] = 0, - ["MobsCollected"] = 0, - ["ChallengesCompleted"] = 0, - }; + EarthDB.Results playerResults = await new EarthDB.Query(false) + .Get("activityLog", token.Data.UserId, typeof(ActivityLog)) + .Get("challenges", token.Data.UserId, typeof(ChallengeProgressVersion)) + .Get("journal", token.Data.UserId, typeof(Journal)) + .ExecuteAsync(Program.DB, cancellationToken); + + ActivityLog activityLog = playerResults.Get("activityLog"); + ChallengeProgressVersion challengeProgress = playerResults.Get("challenges"); + Journal journal = playerResults.Get("journal"); + + var statistics = BuildPlayerStatistics(activityLog, challengeProgress, journal); + + string[] requestedStatistics = request.StatisticNames is { Length: > 0 } + ? request.StatisticNames + : [.. statistics.Keys]; return JsonPascalCase(new PlayfabOkResponse( 200, "OK", new Dictionary() { - ["Statistics"] = request.StatisticNames + ["Statistics"] = requestedStatistics .Where(statistics.ContainsKey) .Select(field => new { @@ -168,6 +169,69 @@ public async Task> GetP )); } + private static Dictionary BuildPlayerStatistics( + ActivityLog activityLog, + ChallengeProgressVersion challengeProgress, + Journal journal) + { + const string MobReferenceId = "1d981b84-a03a-451d-82a6-9bfe0fc885fb"; + const string BestDefenseReferenceId = "06eb0e50-b18d-43e8-9aad-422203ffdf28"; + const string PettingZooReferenceId = "6d01c0d0-2ac9-4549-be82-acd7f5631950"; + const string Season17CommonMobPettingZooReferenceId = "ccc1836f-ccda-4f33-8a4d-c42d8d366255"; + + int itemsCrafted = 0; + int itemsSmelted = 0; + int tappablesFromLog = 0; + + foreach (ActivityLog.Entry entry in activityLog.Entries) + { + switch (entry) + { + case ActivityLog.TappableEntry: + tappablesFromLog++; + break; + case ActivityLog.CraftingCompletedEntry crafting: + itemsCrafted += CountRewardItems(crafting.Rewards); + break; + case ActivityLog.SmeltingCompletedEntry smelting: + itemsSmelted += CountRewardItems(smelting.Rewards); + break; + } + } + + int blocksCollected = journal.Items.Values.Sum(item => Math.Max(0, item.AmountCollected)); + int tappablesCollected = Math.Max(challengeProgress.TappablesRedeemed, tappablesFromLog); + int mobsCollected = new[] + { + challengeProgress.GetObjectiveProgress(MobReferenceId), + challengeProgress.GetObjectiveProgress(BestDefenseReferenceId), + challengeProgress.GetObjectiveProgress(PettingZooReferenceId), + challengeProgress.GetObjectiveProgress(Season17CommonMobPettingZooReferenceId), + }.Max(); + + var statistics = new Dictionary() + { + ["BlocksPlaced"] = 0, + ["BlocksCollected"] = blocksCollected, + ["Deaths"] = 0, + ["ItemsCrafted"] = itemsCrafted, + ["ItemsSmelted"] = itemsSmelted, + ["ToolsBroken"] = 0, + ["MobsKilled"] = 0, + ["BuildplateSeconds"] = 0, + ["SharedBuildplateViews"] = 0, + ["AdventuresPlayed"] = 0, + ["TappablesCollected"] = tappablesCollected, + ["MobsCollected"] = mobsCollected, + ["ChallengesCompleted"] = challengeProgress.ClaimedChallengeIds?.Count ?? 0, + }; + + return statistics; + } + + private static int CountRewardItems(Solace.DB.Models.Common.Rewards rewards) + => rewards.Items.Values.Sum(count => Math.Max(0, count ?? 0)); + [HttpPost("WritePlayerEvent")] public ContentHttpResult WritePlayerEvent() => JsonPascalCase(new PlayfabOkResponse( diff --git a/src/Solace.ApiServer/Controllers/PlayfabApi/EventController.cs b/src/Solace.ApiServer/Controllers/PlayfabApi/EventController.cs index c06dccc2..0c9f04d0 100644 --- a/src/Solace.ApiServer/Controllers/PlayfabApi/EventController.cs +++ b/src/Solace.ApiServer/Controllers/PlayfabApi/EventController.cs @@ -9,28 +9,15 @@ namespace Solace.ApiServer.Controllers.PlayfabApi; [Route("20CA2.playfabapi.com/Event")] internal sealed class EventController : SolaceControllerBase { - private sealed record WriteTelemetryEventsRequest( - object[] Events - ); - [HttpPost("WriteTelemetryEvents")] - public async Task> WriteTelemetryEvents() + public ContentHttpResult WriteTelemetryEvents() { - var cancellationToken = Request.HttpContext.RequestAborted; - - var request = await Request.Body.AsJsonAsync(cancellationToken); - - if (request is null) - { - return TypedResults.BadRequest(); - } - return JsonPascalCase(new PlayfabOkResponse( 200, "OK", new Dictionary() { - ["AssignedEventIds"] = request.Events.Select(_ => Guid.NewGuid().ToString("N")), + ["AssignedEventIds"] = Array.Empty(), } )); } diff --git a/src/Solace.ApiServer/Types/Boost/Boosts.cs b/src/Solace.ApiServer/Types/Boost/Boosts.cs index 83293a70..bc82fd95 100644 --- a/src/Solace.ApiServer/Types/Boost/Boosts.cs +++ b/src/Solace.ApiServer/Types/Boost/Boosts.cs @@ -4,7 +4,7 @@ namespace Solace.ApiServer.Types.Boost; internal sealed record Boosts( Boosts.Potion?[] Potions, - Boosts.MiniFig[] MiniFigs, + Boosts.MiniFig?[] MiniFigs, Boosts.ActiveEffect[] ActiveEffects, Dictionary ScenarioBoosts, Boosts.StatusEffectsR StatusEffects, @@ -20,7 +20,11 @@ string Expiration ); internal sealed record MiniFig( - // TODO + bool Enabled, + string ProductId, + string Id, + string InstanceId, + string Expiration ); internal sealed record ActiveEffect( @@ -49,6 +53,9 @@ internal sealed record StatusEffectsR( ); internal sealed record MiniFigRecord( - // TODO + string ProductId, + string Id, + string LastSeen, + int Activations ); -} \ No newline at end of file +} diff --git a/src/Solace.ApiServer/Types/Buildplates/BuildplateInstance.cs b/src/Solace.ApiServer/Types/Buildplates/BuildplateInstance.cs index 418def80..6abff5f9 100644 --- a/src/Solace.ApiServer/Types/Buildplates/BuildplateInstance.cs +++ b/src/Solace.ApiServer/Types/Buildplates/BuildplateInstance.cs @@ -56,7 +56,8 @@ internal enum GameplayModeE [JsonStringEnumMemberName("Buildplate")] BUILDPLATE, [JsonStringEnumMemberName("BuildplatePlay")] BUILDPLATE_PLAY, [JsonStringEnumMemberName("SharedBuildplatePlay")] SHARED_BUILDPLATE_PLAY, - [JsonStringEnumMemberName("Encounter")] ENCOUNTER + [JsonStringEnumMemberName("Encounter")] ENCOUNTER, + [JsonStringEnumMemberName("PlayerAdventure")] PLAYER_ADVENTURE #pragma warning restore CA1707 // Identifiers should not contain underscores } } diff --git a/src/Solace.ApiServer/Types/Common/Token.cs b/src/Solace.ApiServer/Types/Common/Token.cs index 71346394..9c95e5dd 100644 --- a/src/Solace.ApiServer/Types/Common/Token.cs +++ b/src/Solace.ApiServer/Types/Common/Token.cs @@ -22,7 +22,13 @@ public enum Type [JsonStringEnumMemberName("item.unlocked")] JOURNAL_ITEM_UNLOCKED, [JsonStringEnumMemberName("daily.login")] - DAILY_LOGIN + DAILY_LOGIN, + [JsonStringEnumMemberName("oobe.adventure_crystal")] + OOBE_ADVENTURE_CRYSTAL, + [JsonStringEnumMemberName("challenge.progress")] + CHALLENGE_PROGRESS, + [JsonStringEnumMemberName("challenge.completed")] + CHALLENGE_COMPLETED #pragma warning restore CA1707 // Identifiers should not contain underscores } @@ -45,7 +51,6 @@ public static Token.Type FromDb(DB.Models.Player.TokensEF.Token.TypeE type) { DB.Models.Player.TokensEF.Token.TypeE.LEVEL_UP => Token.Type.LEVEL_UP, DB.Models.Player.TokensEF.Token.TypeE.JOURNAL_ITEM_UNLOCKED => Token.Type.JOURNAL_ITEM_UNLOCKED, - DB.Models.Player.TokensEF.Token.TypeE.DAILY_LOGIN => Token.Type.DAILY_LOGIN, _ => throw new InvalidEnumArgumentException(nameof(type), (int)type, typeof(DB.Models.Player.TokensEF.Token.TypeE)), }; } diff --git a/src/Solace.ApiServer/Utils/BuildplateInstanceRequestHandler.cs b/src/Solace.ApiServer/Utils/BuildplateInstanceRequestHandler.cs index 6d200932..bc5be18d 100644 --- a/src/Solace.ApiServer/Utils/BuildplateInstanceRequestHandler.cs +++ b/src/Solace.ApiServer/Utils/BuildplateInstanceRequestHandler.cs @@ -172,27 +172,32 @@ public static async Task CreateAsync(EarthDbCo { RequestWithInstanceId? requestWithInstanceId = ReadRequest(request.Data); - return requestWithInstanceId is null - ? null - : await buildplateInstanceRequestHandler.HandleInventorySetHotbar(requestWithInstanceId.InstanceId, requestWithInstanceId.Request) ? "" : null; - } - default: - return null; - } - } - catch (Exception ex) when (ex is DbUpdateException or DbUpdateConcurrencyException) - { - Log.Error($"Database error while handling request: {ex}"); - return null; - } - }, - async () => - { - Log.Fatal("Buildplates event bus request handler error"); - Log.CloseAndFlush(); - Environment.Exit(1); - } - )); + return requestWithInstanceId is null + ? null + : await buildplateInstanceRequestHandler.HandleInventorySetHotbar(requestWithInstanceId.InstanceId, requestWithInstanceId.Request) ? "" : null; + } + default: + return null; + } + } + catch (EarthDB.DatabaseException ex) + { + Log.Error($"Database error while handling request: {ex}"); + return null; + } + catch (Exception ex) + { + Log.Error($"Unexpected error while handling buildplates request '{request.Type}': {ex}"); + return null; + } + }, + async () => + { + Log.Fatal("Buildplates event bus request handler error"); + Log.CloseAndFlush(); + Environment.Exit(1); + } + )); return buildplateInstanceRequestHandler; } @@ -268,7 +273,23 @@ string ServerDataBase64 if (encounterBuildplate is null) { - return null; + EarthDB.ObjectResults objectResults = await new EarthDB.ObjectQuery(false) + .GetBuildplate(encounterBuildplateId) + .ExecuteAsync(_earthDB); + TemplateBuildplate? templateBuildplate = objectResults.GetBuildplate(encounterBuildplateId); + if (templateBuildplate is null) + { + return null; + } + + byte[]? templateServerData = await _objectStoreClient.GetAsync(templateBuildplate.ServerDataObjectId); + if (templateServerData is null) + { + Log.Error($"Data object {templateBuildplate.ServerDataObjectId} for template buildplate {encounterBuildplateId} could not be loaded from object store"); + return null; + } + + return new BuildplateLoadResponse(Convert.ToBase64String(templateServerData)); } byte[]? serverData = await _objectStoreClient.GetAsync(encounterBuildplate.ServerDataObjectId); @@ -481,6 +502,7 @@ [.. Enumerable.Concat( break; case BuildplateInstancesManager.InstanceType.ENCOUNTER: + case BuildplateInstancesManager.InstanceType.PLAYER_ADVENTURE: { var inventory = await _earthDB.Inventories .AsTracking() @@ -500,9 +522,34 @@ [.. Enumerable.Concat( { if (item.InstanceId is null) { - inventory.TakeItems(item.Uuid, item.Count); - inventoryResponseStackableItems[item.Uuid] = inventoryResponseStackableItems.GetValueOrDefault(item.Uuid, 0) + item.Count; - inventoryResponseHotbar[index] = new InventoryResponse.HotbarItem(item.Uuid, item.Count, null); + Hotbar.Item? item = hotbar.Items[index]; + if (item is not null) + { + if (item.InstanceId is null) + { + if (!inventory.TakeItems(item.Uuid, item.Count)) + { + hotbar.Items[index] = null; + continue; + } + + inventoryResponseStackableItems[item.Uuid] = inventoryResponseStackableItems.GetValueOrDefault(item.Uuid, 0) + item.Count; + inventoryResponseHotbar[index] = new InventoryResponse.HotbarItem(item.Uuid, item.Count, null); + } + else + { + NonStackableItemInstance[]? takenItems = inventory.TakeItems(item.Uuid, [item.InstanceId]); + if (takenItems is null || takenItems.Length == 0) + { + hotbar.Items[index] = null; + continue; + } + + int wear = takenItems[0].Wear; + inventoryResponseNonStackableItems.AddLast(new InventoryResponse.Item(item.Uuid, 1, item.InstanceId, wear)); + inventoryResponseHotbar[index] = new InventoryResponse.HotbarItem(item.Uuid, 1, item.InstanceId); + } + } } else { @@ -553,14 +600,14 @@ .. inventoryResponseNonStackableItems return null; } - bool usesBackpack = instanceInfo.Type == BuildplateInstancesManager.InstanceType.ENCOUNTER; + bool usesBackpack = instanceInfo.Type is BuildplateInstancesManager.InstanceType.ENCOUNTER or BuildplateInstancesManager.InstanceType.PLAYER_ADVENTURE; if (usesBackpack) { InventoryResponse? backpackContents = playerDisconnectedRequest.BackpackContents; if (backpackContents is null) { - Log.Error("Expected backpack contents in player disconnected request"); - return null; + Log.Warning("Expected backpack contents in player disconnected request; closing instance without inventory merge"); + return new PlayerDisconnectedResponse(); } var inventory = await _earthDB.Inventories @@ -585,40 +632,61 @@ .. inventoryResponseNonStackableItems continue; } - if (!catalogItem.Stackable && item.InstanceId is null) - { - Log.Error("Backpack contents contained non-stackable item without instance ID"); - continue; - } + LinkedList unlockedJournalItems = []; + InventoryResponse.Item[] backpackItems = backpackContents.Items ?? []; + foreach (InventoryResponse.Item item in backpackItems) + { + if (item is null || item.Count <= 0) + { + continue; + } - if (catalogItem.Stackable) - { - inventory.AddItems(item.Id, item.Count); - } - else - { - Debug.Assert(item.InstanceId is not null); + Catalog.ItemsCatalogR.Item? catalogItem = _catalog.ItemsCatalog.GetItem(item.Id); + if (catalogItem is null) + { + Log.Error("Backpack contents contained item that is not in item catalog"); + continue; + } - inventory.AddItems(item.Id, [new NonStackableItemInstance(item.InstanceId, item.Wear)]); - } + if (!catalogItem.Stackable && item.InstanceId is null) + { + Log.Error("Backpack contents contained non-stackable item without instance ID"); + continue; + } + + if (catalogItem.Stackable) + { + inventory.AddItems(item.Id, item.Count); + } + else + { + Debug.Assert(item.InstanceId is not null); + + inventory.AddItems(item.Id, [new NonStackableItemInstance(item.InstanceId, item.Wear)]); + } + + if (journal.AddCollectedItem(item.Id, timestamp, item.Count) == 0) + { + if (catalogItem.JournalEntry is not null) + { + unlockedJournalItems.AddLast(item.Id); + } + } - if (journal.AddCollectedItem(item.Id, timestamp, item.Count) == 0) - { - if (catalogItem.JournalEntry is not null) - { - unlockedJournalItems.AddLast(item.Id); } } } - for (int index = 0; index < 7; index++) - { - InventoryResponse.HotbarItem? hotbarItem = backpackContents.Hotbar[index]; - if (hotbarItem is not null) - { - hotbar.Items[index] = new HotbarEF.Item(hotbarItem.Id, hotbarItem.Count, hotbarItem.InstanceId); - } - } + var hotbar = new Hotbar(); + InventoryResponse.HotbarItem?[] backpackHotbar = backpackContents.Hotbar ?? []; + for (int index = 0; index < 7 && index < backpackHotbar.Length; index++) + { + InventoryResponse.HotbarItem? hotbarItem = backpackHotbar[index]; + if (hotbarItem is not null && _catalog.ItemsCatalog.GetItem(hotbarItem.Id) is not null && hotbarItem.Count > 0) + { + hotbar.Items[index] = new Hotbar.Item(hotbarItem.Id, hotbarItem.Count, hotbarItem.InstanceId); + } + } hotbar.LimitToInventory(inventory); @@ -663,6 +731,7 @@ Catalog.ItemsCatalogR.Item.BoostInfoR.Effect Effect BuildplateInstancesManager.InstanceType.SHARED_BUILD => (false, false), BuildplateInstancesManager.InstanceType.SHARED_PLAY => (false, true), BuildplateInstancesManager.InstanceType.ENCOUNTER => (true, true), + BuildplateInstancesManager.InstanceType.PLAYER_ADVENTURE => (true, true), _ => (false, false), }; @@ -871,9 +940,13 @@ private async Task HandleInventorySetHotbar(Guid instanceId, InventorySetH .AsNoTracking() .FirstOrNewAsync(inventory => inventory.Id == inventorySetHotbarMessage.PlayerId, trackNew: false); - var hotbar = await _earthDB.Hotbars - .AsTracking() - .FirstOrNewAsync(hotbar => hotbar.Id == inventorySetHotbarMessage.PlayerId); + var hotbar = new Hotbar(); + InventorySetHotbarMessage.Item[] requestedItems = inventorySetHotbarMessage.Items ?? []; + for (int index = 0; index < hotbar.Items.Length; index++) + { + InventorySetHotbarMessage.Item? item = index < requestedItems.Length ? requestedItems[index] : null; + hotbar.Items[index] = item is not null ? new Hotbar.Item(item.ItemId, item.Count, item.InstanceId) : null; + } for (int index = 0; index < hotbar.Items.Length; index++) { diff --git a/src/Solace.ApiServer/Utils/BuildplateInstancesManager.cs b/src/Solace.ApiServer/Utils/BuildplateInstancesManager.cs index e99292fe..c909e7a0 100644 --- a/src/Solace.ApiServer/Utils/BuildplateInstancesManager.cs +++ b/src/Solace.ApiServer/Utils/BuildplateInstancesManager.cs @@ -9,6 +9,8 @@ namespace Solace.ApiServer.Utils; public sealed class BuildplateInstancesManager { private readonly EventBusClient _eventBusClient; + private readonly SemaphoreSlim _requestSenderLock = new(1, 1); + private readonly SemaphoreSlim _startLock = new(1, 1); private Subscriber _subscriber = null!; private RequestSender _requestSender = null!; @@ -42,16 +44,19 @@ public static async Task CreateAsync(EventBusClient public async Task RequestBuildplateInstance(Guid? playerId, Guid? encounterId, Guid buildplateId, InstanceType type, long shutdownTime, bool night) { - if (playerId is null && type is not InstanceType.ENCOUNTER) + if (playerId is null && type is not InstanceType.ENCOUNTER and not InstanceType.PLAYER_ADVENTURE) { - throw new ArgumentException($"{nameof(playerId)} cannot be null when {nameof(type)} is not {nameof(InstanceType.ENCOUNTER)}."); + throw new ArgumentException($"{nameof(playerId)} cannot be null when {nameof(type)} is not {nameof(InstanceType.ENCOUNTER)} or {nameof(InstanceType.PLAYER_ADVENTURE)}."); } - if (encounterId is not null && type is not InstanceType.ENCOUNTER) + if (encounterId is not null && type is not InstanceType.ENCOUNTER and not InstanceType.PLAYER_ADVENTURE) { - throw new ArgumentException($"{nameof(encounterId)} can only be set when {nameof(type)} is {nameof(InstanceType.ENCOUNTER)}."); + throw new ArgumentException($"{nameof(encounterId)} can only be set when {nameof(type)} is {nameof(InstanceType.ENCOUNTER)} or {nameof(InstanceType.PLAYER_ADVENTURE)}."); } + await _startLock.WaitAsync(); + try + { if (playerId is not null && encounterId is not null) { Log.Information($"Finding buildplate instance for buildplate {buildplateId} type {type} encounter {encounterId} player {playerId}"); @@ -79,9 +84,10 @@ public static async Task CreateAsync(EventBusClient var instanceInfo = _instances.GetValueOrDefault(loopInstanceId); if (instanceInfo is not null && !instanceInfo.ShuttingDown) { + bool sameEncounter = instanceInfo.EncounterId == encounterId || type is InstanceType.PLAYER_ADVENTURE; if (instanceInfo.Type == type && instanceInfo.PlayerId == playerId && - instanceInfo.EncounterId == encounterId + sameEncounter ) { Log.Information($"Found existing buildplate instance {loopInstanceId}"); @@ -93,8 +99,8 @@ public static async Task CreateAsync(EventBusClient } Log.Information("Did not find existing instance, starting new instance"); - string? instanceIdString = await _requestSender.RequestAsync("buildplates", "start", Json.Serialize(new StartRequest(playerId, encounterId, buildplateId, night, type, shutdownTime))); - if (!Guid.TryParse(instanceIdString, out var instanceId)) + string? instanceId = await SendStartRequestWithTimeoutAsync(playerId, encounterId, buildplateId, night, type, shutdownTime); + if (instanceId is null) { Log.Error("Buildplate start request was rejected/ignored"); return null; @@ -123,6 +129,47 @@ public static async Task CreateAsync(EventBusClient } return instanceId; + } + finally + { + _startLock.Release(); + } + } + + private async Task SendStartRequestWithTimeoutAsync(string? playerId, string? encounterId, string buildplateId, bool night, InstanceType type, long shutdownTime) + { + await _requestSenderLock.WaitAsync(); + try + { + Task responseTask = _requestSender.RequestAsync("buildplates", "start", Json.Serialize(new StartRequest(playerId, encounterId, buildplateId, night, type, shutdownTime))); + Task timeoutTask = Task.Delay(TimeSpan.FromSeconds(8)); + if (await Task.WhenAny(responseTask, timeoutTask) == responseTask) + { + return await responseTask; + } + + Log.Warning("Buildplate start request timed out for buildplate {BuildplateId}; resetting sender", buildplateId); + await ResetRequestSenderAsync(); + return null; + } + finally + { + _requestSenderLock.Release(); + } + } + + private async Task ResetRequestSenderAsync() + { + try + { + await _requestSender.CloseAsync(); + } + catch (Exception exception) + { + Log.Warning(exception, "Could not close stale buildplates request sender"); + } + + _requestSender = await _eventBusClient.AddRequestSenderAsync(); } public InstanceInfo? GetInstanceInfo(Guid instanceId) @@ -156,7 +203,7 @@ private Task HandleEvent(SubscriberEvent @event) try { startNotification = Json.Deserialize(@event.Data)!; - if (startNotification.PlayerId is null && startNotification.Type is not InstanceType.ENCOUNTER) + if (startNotification.PlayerId is null && startNotification.Type is not InstanceType.ENCOUNTER and not InstanceType.PLAYER_ADVENTURE) { Log.Warning("Bad start notification"); return Task.CompletedTask; @@ -316,6 +363,7 @@ public enum InstanceType SHARED_BUILD, SHARED_PLAY, ENCOUNTER, + PLAYER_ADVENTURE, #pragma warning restore CA1707 // Identifiers should not contain underscores } diff --git a/src/Solace.ApiServer/Utils/EarthApiResponse.cs b/src/Solace.ApiServer/Utils/EarthApiResponse.cs index d764e403..fbdf3b3e 100644 --- a/src/Solace.ApiServer/Utils/EarthApiResponse.cs +++ b/src/Solace.ApiServer/Utils/EarthApiResponse.cs @@ -30,8 +30,11 @@ public sealed class UpdatesResponse { public Dictionary Map = []; - public UpdatesResponse(EarthDbContext.Results results) - : this(results.Profile, results.Inventory, results.Crafting, results.Smelting, results.Boosts, results.Buildplates, results.Journal, results.Challenges, results.Tokens) + public UpdatesResponse() + { + } + + public UpdatesResponse(EarthDB.Results results) { } @@ -56,4 +59,4 @@ private void Set(int? version, string @as) } } } -} \ No newline at end of file +} diff --git a/src/Solace.ApiServer/Utils/TappablesManager.cs b/src/Solace.ApiServer/Utils/TappablesManager.cs index c5e876c9..612263f6 100644 --- a/src/Solace.ApiServer/Utils/TappablesManager.cs +++ b/src/Solace.ApiServer/Utils/TappablesManager.cs @@ -11,20 +11,26 @@ public sealed class TappablesManager { private static readonly long GRACE_PERIOD = 30000; + private readonly EventBusClient _eventBusClient; + private readonly SemaphoreSlim _requestSenderLock = new(1, 1); private Subscriber _subscriber = null!; private RequestSender _requestSender = null!; - private readonly Dictionary> _tappables = []; - private readonly Dictionary> _encounters = []; + private readonly Dictionary> _tappables = []; + private readonly Dictionary> _encounters = []; + private readonly Dictionary> _adventures = []; + private readonly Dictionary _adventureOwnersById = []; + private readonly Dictionary _recentAdventureIdsByPlayer = []; private int _pruneCounter; - private TappablesManager() + private TappablesManager(EventBusClient eventBusClient) { + _eventBusClient = eventBusClient; } public static async Task CreateAsync(EventBusClient eventBusClient) { - var tappablesManager = new TappablesManager(); + var tappablesManager = new TappablesManager(eventBusClient); tappablesManager._subscriber = await eventBusClient.AddSubscriberAsync("tappables", new SubscriberListener( tappablesManager.HandleEvent, @@ -66,6 +72,40 @@ public Encounter[] GetEncountersAround(double lat, double lon, double radius) return distanceSquared <= radius * radius; })]; + public Adventure[] GetAdventuresAround(double lat, double lon, double radius) + => [.. GetTileIdsAround(lat, lon, radius) + .Select(tileId => _adventures.GetOrDefault(tileId)) + .Where(adventures => adventures is not null) + .SelectMany(adventures => adventures!.Values) + .Where(adventure => + { + double dx = LonToX(adventure.Lon) * (1 << 16) - LonToX(lon) * (1 << 16); + double dy = LatToY(adventure.Lat) * (1 << 16) - LatToY(lat) * (1 << 16); + double distanceSquared = dx * dx + dy * dy; + return distanceSquared <= radius * radius; + }) + .GroupBy(adventure => $"{Math.Round(adventure.Lat, 5)}:{Math.Round(adventure.Lon, 5)}:{adventure.AdventureBuildplateId}") + .Select(group => group.OrderBy(adventure => adventure.SpawnTime).First())]; + + public Adventure[] GetPlayerAdventuresAround(string playerId, double lat, double lon, double radius) + => [.. GetAdventuresAround(lat, lon, radius) + .Where(adventure => IsAdventureOwnedByPlayer(adventure, playerId))]; + + public Adventure[] GetAllAdventures() + => [.. _adventures.Values + .SelectMany(adventures => adventures.Values) + .GroupBy(adventure => $"{Math.Round(adventure.Lat, 5)}:{Math.Round(adventure.Lon, 5)}:{adventure.AdventureBuildplateId}") + .Select(group => group.OrderBy(adventure => adventure.SpawnTime).First())]; + + public Adventure[] GetAllPlayerAdventures(string playerId) + => [.. GetAllAdventures().Where(adventure => IsAdventureOwnedByPlayer(adventure, playerId))]; + + private bool IsAdventureVisibleToPlayer(Adventure adventure, string playerId) + => !_adventureOwnersById.TryGetValue(adventure.Id, out string? ownerId) || ownerId == playerId; + + private bool IsAdventureOwnedByPlayer(Adventure adventure, string playerId) + => _adventureOwnersById.TryGetValue(adventure.Id, out string? ownerId) && ownerId == playerId; + public Encounter[] GetEncountersAround(float lat, float lon, float radius) => [.. GetTileIdsAround(lat, lon, radius) .Select(tileId => _encounters.GetValueOrDefault(tileId)) @@ -102,6 +142,14 @@ private static string[] GetTileIdsAround(double lat, double lon, double radius) } } + foreach (Tappable tappable in _tappables.Values.SelectMany(tappables => tappables.Values)) + { + if (tappable.Id == id) + { + return tappable; + } + } + return null; } @@ -120,6 +168,135 @@ private static string[] GetTileIdsAround(double lat, double lon, double radius) return null; } + public Adventure? GetAdventureWithId(string id, string tileId) + { + var adventuresInTile = _adventures.GetOrDefault(tileId); + if (adventuresInTile is not null) + { + var adventure = adventuresInTile.GetOrDefault(id); + if (adventure is not null) + { + return adventure; + } + } + + return GetAdventureWithId(id); + } + + public Adventure? GetPlayerAdventureWithId(string playerId, string id, string? tileId = null) + { + Adventure? adventure = !string.IsNullOrWhiteSpace(tileId) + ? GetAdventureWithId(id, tileId) + : GetAdventureWithId(id); + + return adventure is not null && IsAdventureOwnedByPlayer(adventure, playerId) + ? adventure + : null; + } + + public Adventure? GetAdventureWithId(string id) + { + foreach (Adventure adventure in _adventures.Values.SelectMany(adventures => adventures.Values)) + { + if (adventure.Id == id) + { + return adventure; + } + } + + return null; + } + + public Adventure PlaceAdventure(float lat, float lon, long spawnTime, long validFor, string icon, Adventure.RarityE rarity, string adventureBuildplateId) + { + var adventure = new Adventure(U.RandomUuid().ToString(), lat, lon, spawnTime, validFor, icon, rarity, adventureBuildplateId); + AddAdventure(adventure); + return adventure; + } + + public Adventure? GetRecentPlayerAdventure(string playerId, float lat, float lon, string adventureBuildplateId, long currentTime) + { + if (!_recentAdventureIdsByPlayer.TryGetValue(playerId, out string? adventureId)) + { + return null; + } + + foreach (Adventure adventure in _adventures.Values.SelectMany(adventures => adventures.Values)) + { + if (adventure.Id == adventureId && + adventure.AdventureBuildplateId == adventureBuildplateId && + adventure.SpawnTime + adventure.ValidFor > currentTime && + currentTime - adventure.SpawnTime <= 30 * 1000 && + IsSamePlayerAdventureLocation(adventure, lat, lon)) + { + return adventure; + } + } + + return null; + } + + public Adventure? GetRecentPlayerAdventureAtLocation(string playerId, float lat, float lon, long currentTime) + { + if (!_recentAdventureIdsByPlayer.TryGetValue(playerId, out string? adventureId)) + { + return null; + } + + foreach (Adventure adventure in _adventures.Values.SelectMany(adventures => adventures.Values)) + { + if (adventure.Id == adventureId && + adventure.SpawnTime + adventure.ValidFor > currentTime && + currentTime - adventure.SpawnTime <= 60 * 1000 && + IsSamePlayerAdventureLocation(adventure, lat, lon)) + { + return adventure; + } + } + + return null; + } + + public Adventure PlacePlayerAdventure(string playerId, float lat, float lon, long spawnTime, long validFor, string icon, Adventure.RarityE rarity, string adventureBuildplateId) + { + Adventure? recent = GetRecentPlayerAdventureAtLocation(playerId, lat, lon, spawnTime) + ?? GetRecentPlayerAdventure(playerId, lat, lon, adventureBuildplateId, spawnTime); + if (recent is not null) + { + return recent; + } + + RemoveRecentPlayerAdventure(playerId); + + Adventure adventure = new(U.RandomUuid().ToString(), lat, lon, spawnTime, validFor, icon, rarity, adventureBuildplateId); + AddAdventure(adventure, playerId); + _recentAdventureIdsByPlayer[playerId] = adventure.Id; + return adventure; + } + + private static bool IsSamePlayerAdventureLocation(Adventure adventure, float lat, float lon) + { + double dx = LonToX(adventure.Lon) * (1 << 16) - LonToX(lon) * (1 << 16); + double dy = LatToY(adventure.Lat) * (1 << 16) - LatToY(lat) * (1 << 16); + return (dx * dx + dy * dy) <= 0.25; + } + + private void RemoveRecentPlayerAdventure(string playerId) + { + if (!_recentAdventureIdsByPlayer.TryGetValue(playerId, out string? adventureId)) + { + return; + } + + foreach (var tileAdventures in _adventures.Values) + { + tileAdventures.Remove(adventureId); + } + + _adventureOwnersById.Remove(adventureId); + _recentAdventureIdsByPlayer.Remove(playerId); + } + #pragma warning disable IDE0060 // Remove unused parameter public bool IsTappableValidFor(Tappable tappable, long requestTime, float lat, float lon) #pragma warning restore IDE0060 // Remove unused parameter @@ -153,11 +330,43 @@ public async Task NotifyTileActiveAsync(Guid accountId, double lat, double lon) { int tileX = XToTile(LonToX(lon)); int tileY = YToTile(LatToY(lat)); - string? response = await _requestSender.RequestAsync("tappables", "activeTile", Json.Serialize(new ActiveTileNotification(tileX, tileY, accountId.ToString()))); - if (response is null) + + await _requestSenderLock.WaitAsync(); + try + { + Task responseTask = _requestSender.RequestAsync("tappables", "activeTile", Json.Serialize(new ActiveTileNotification(tileX, tileY, playerId))); + Task timeoutTask = Task.Delay(TimeSpan.FromSeconds(2)); + if (await Task.WhenAny(responseTask, timeoutTask) != responseTask) + { + Log.Warning("Active tile notification timed out for tile {TileX},{TileY}; resetting sender and continuing", tileX, tileY); + await ResetRequestSenderAsync(); + return; + } + + string? response = await responseTask; + if (response is null) + { + Log.Warning("Active tile notification event was rejected/ignored"); + } + } + finally + { + _requestSenderLock.Release(); + } + } + + private async Task ResetRequestSenderAsync() + { + try + { + await _requestSender.CloseAsync(); + } + catch (Exception exception) { - Log.Warning("Active tile notification event was rejected/ignored"); + Log.Warning(exception, "Could not close stale tappables request sender"); } + + _requestSender = await _eventBusClient.AddRequestSenderAsync(); } private sealed record ActiveTileNotification( @@ -226,6 +435,35 @@ private Task HandleEvent(SubscriberEvent @event) } } + break; + case "adventureSpawn": + { + Adventure[]? adventures; + + try + { + adventures = Json.Deserialize(@event.Data); + } + catch (Exception exception) + { + Log.Error($"Could not deserialise adventure spawn event: {exception}"); + break; + } + + Debug.Assert(adventures is not null); + + foreach (var adventure in adventures) + { + AddAdventure(adventure); + } + + if (_pruneCounter++ == 10) + { + _pruneCounter = 0; + Prune(@event.Timestamp); + } + } + break; } @@ -244,6 +482,18 @@ private void AddEncounter(Encounter encounter) _encounters.ComputeIfAbsent(tileId, tileId1 => [])![encounter.Id] = encounter; } + private void AddAdventure(Adventure adventure) + { + string tileId = LocationToTileId(adventure.Lat, adventure.Lon); + _adventures.ComputeIfAbsent(tileId, tileId1 => [])![adventure.Id] = adventure; + } + + private void AddAdventure(Adventure adventure, string playerId) + { + AddAdventure(adventure); + _adventureOwnersById[adventure.Id] = playerId; + } + private void Prune(long currentTime) { foreach (var tileTappables in _tappables.Values) @@ -268,7 +518,20 @@ private void Prune(long currentTime) }); } - _encounters.RemoveAll(entry => entry.Value.Count is 0); + _encounters.RemoveIf(entry => entry.Value.Count == 0); + + foreach (var tileAdventures in _adventures.Values) + { + tileAdventures.RemoveIf(entry => + { + Adventure adventure = entry.Value; + long expiresAt = adventure.SpawnTime + adventure.ValidFor; + return expiresAt + GRACE_PERIOD <= currentTime; + }); + } + + _adventures.RemoveIf(entry => entry.Value.Count == 0); + _adventureOwnersById.RemoveIf(entry => !_adventures.Values.Any(tileAdventures => tileAdventures.ContainsKey(entry.Key))); } public static string LocationToTileId(float lat, float lon) @@ -334,4 +597,27 @@ public enum RarityE LEGENDARY } } + + public sealed record Adventure( + string Id, + float Lat, + float Lon, + long SpawnTime, + long ValidFor, + string Icon, + Adventure.RarityE Rarity, + string AdventureBuildplateId + ) + { + [JsonConverter(typeof(JsonStringEnumConverter))] + public enum RarityE + { + COMMON, + UNCOMMON, + RARE, + EPIC, + LEGENDARY, + OOBE + } + } } diff --git a/src/Solace.ApiServer/Utils/TileUtils.cs b/src/Solace.ApiServer/Utils/TileUtils.cs index e7a34a6d..1c869b80 100644 --- a/src/Solace.ApiServer/Utils/TileUtils.cs +++ b/src/Solace.ApiServer/Utils/TileUtils.cs @@ -9,53 +9,91 @@ namespace Solace.ApiServer.Utils; internal static class TileUtils { - public static async Task TryWriteTile(int tileX, int tileY, Stream dest, EarthDbContext earthDb, EventBusClient eventBus, ObjectStoreClient objectStore, CancellationToken cancellationToken) - { - ulong dbPos = ToDbPos(tileX, tileY); + private static EarthDB db => Program.DB; + private static EventBusClient eventBus => Program.eventBus; + private static readonly byte[] EmptyTilePng = Convert.FromBase64String( + "iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAABOklEQVR4nO3SMQ0AAAwCoNm/9HI83BLIOQmtnpnZB4CjEwABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEoAB1XQB3P+pKnEAAAAASUVORK5CYII="); - var tile = await earthDb.Tiles - .AsNoTracking() - .FirstOrDefaultAsync(tile => tile.Id == dbPos, cancellationToken: cancellationToken); + private static RequestSender? _requestSender; + private static readonly SemaphoreSlim _requestSenderLock = new(1, 1); - if (tile is not null) + public static async Task TryWriteTile(int tileX, int tileY, Stream dest, CancellationToken cancellationToken) + { + if (await TryWriteRenderedTile(tileX, tileY, dest, cancellationToken)) { - return await TryWriteTileFromObject(tile.ObjectStoreId, dest, objectStore, cancellationToken); + return true; } - Log.Information("Rendering tile"); - await using var requestSender = await eventBus.AddRequestSenderAsync(); - string? tilePng64 = await requestSender.RequestAsync("tile", "renderTile", Json.Serialize(new RenderTileRequest(tileX, tileY, 16))); + Log.Warning("Serving fallback tile {TileX},{TileY}", tileX, tileY); + await dest.WriteAsync(EmptyTilePng, cancellationToken); + return true; + } + + private static async Task TryWriteRenderedTile(int tileX, int tileY, Stream dest, CancellationToken cancellationToken) + { + string? response; - if (tilePng64 is null) + await _requestSenderLock.WaitAsync(cancellationToken); + try { - Log.Warning("Could not get tile (tile renderer did not respond to event bus request)"); + _requestSender ??= await eventBus.AddRequestSenderAsync(); + + Task responseTask = _requestSender.RequestAsync("tile", "renderTile", Json.Serialize(new RenderTileRequest(tileX, tileY, 16))); + Task timeoutTask = Task.Delay(TimeSpan.FromSeconds(8), cancellationToken); + if (await Task.WhenAny(responseTask, timeoutTask) != responseTask) + { + Log.Warning("Tile render timed out for tile {TileX},{TileY}", tileX, tileY); + await ResetRequestSenderAsync(); + return false; + } + + response = await responseTask; + } + catch (Exception ex) when (ex is EventBusClientException or InvalidOperationException) + { + Log.Warning(ex, "Tile render request failed for tile {TileX},{TileY}", tileX, tileY); + await ResetRequestSenderAsync(); return false; } + finally + { + _requestSenderLock.Release(); + } - byte[] tilePng = Convert.FromBase64String(tilePng64); - - var tileObjectId = await objectStore.StoreAsync(tilePng); - - if (string.IsNullOrEmpty(tileObjectId)) + if (string.IsNullOrWhiteSpace(response)) { - Log.Warning("Failed to store tile to object store"); + Log.Warning("Tile renderer returned no data for tile {TileX},{TileY}", tileX, tileY); return false; } - tile = new DB.Models.Global.Tile() + try { - Id = dbPos, - ObjectStoreId = tileObjectId, - }; - - earthDb.Tiles.Add(tile); - await earthDb.SaveChangesAsync(cancellationToken); - - Log.Debug($"Stored tile ({tileX}, {tileY}) to object store under id {tileObjectId}"); + byte[] tilePng = Convert.FromBase64String(response); + await dest.WriteAsync(tilePng, cancellationToken); + return true; + } + catch (FormatException ex) + { + Log.Warning(ex, "Tile renderer returned invalid base64 for tile {TileX},{TileY}", tileX, tileY); + return false; + } + } - await dest.WriteAsync(tilePng, cancellationToken); + private static async Task ResetRequestSenderAsync() + { + if (_requestSender is not null) + { + try + { + await _requestSender.CloseAsync(); + } + catch + { + // The connection is already broken; the next request will create a new sender. + } + } - return true; + _requestSender = null; } private static async Task TryWriteTileFromObject(string tileObjectId, Stream dest, ObjectStoreClient objectStoreClient, CancellationToken cancellationToken) diff --git a/src/Solace.ApiServer/Utils/TokenUtils.cs b/src/Solace.ApiServer/Utils/TokenUtils.cs index c51c863f..e8da8e98 100644 --- a/src/Solace.ApiServer/Utils/TokenUtils.cs +++ b/src/Solace.ApiServer/Utils/TokenUtils.cs @@ -1,5 +1,6 @@ -using Microsoft.EntityFrameworkCore; +using Serilog; using Solace.Common.Utils; +using Solace.ApiServer.Controllers.EarthApi; using Solace.DB; using Solace.DB.Models.Player; using Solace.DB.Utils; @@ -8,7 +9,9 @@ namespace Solace.ApiServer.Utils; public static class TokenUtils { - public static async Task AddTokenAsync(EarthDbContext.Results results, Guid accountId, TokensEF.Token token) + private const string CommonAdventureCrystalId = "4f16a053-4929-263a-c91a-29663e29df76"; + + public static EarthDB.Query AddToken(string playerId, Tokens.Token token) { var tokens = await results.EarthDb.Tokens .AsTracking() @@ -24,6 +27,86 @@ public static async Task AddTokenAsync(EarthDbContext.Results results, G return id; } + public static async Task EnsureDailyLoginToken(string playerId, CancellationToken cancellationToken) + { + string today = DateTimeOffset.UtcNow.ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture); + + try + { + await new EarthDB.Query(true) + .Get("tokenClaims", playerId, typeof(TokenClaims)) + .Get("tokens", playerId, typeof(Tokens)) + .Then(results => + { + TokenClaims tokenClaims = results.Get("tokenClaims"); + Tokens tokens = results.Get("tokens"); + + bool changed = false; + foreach (Tokens.TokenWithId existingToken in tokens.GetTokens()) + { + if (existingToken.Token is Tokens.DailyLoginToken dailyLoginToken && dailyLoginToken.Date != today) + { + tokens.RemoveToken(existingToken.Id); + changed = true; + } + + if (existingToken.Token is Tokens.DailyLoginToken dailyLoginTokenToday && dailyLoginTokenToday.Date == today && !Guid.TryParse(existingToken.Id, out _)) + { + tokens.RemoveToken(existingToken.Id); + changed = true; + } + } + + if (tokenClaims.RedeemedDailyLoginDates.Contains(today)) + { + foreach (Tokens.TokenWithId existingToken in tokens.GetTokens()) + { + if (existingToken.Token is Tokens.DailyLoginToken dailyLoginToken && dailyLoginToken.Date == today) + { + tokens.RemoveToken(existingToken.Id); + changed = true; + } + } + + return changed + ? new EarthDB.Query(true).Update("tokens", playerId, tokens) + : EarthDB.Query.Empty; + } + + if (tokens.GetTokens().Any(token => token.Token is Tokens.DailyLoginToken dailyLoginToken && dailyLoginToken.Date == today && Guid.TryParse(token.Id, out _))) + { + return changed + ? new EarthDB.Query(true).Update("tokens", playerId, tokens) + : EarthDB.Query.Empty; + } + + tokenClaims.DailyLoginStreak = tokenClaims.LastDailyLoginDate is null || tokenClaims.LastDailyLoginDate == today + ? Math.Max(1, tokenClaims.DailyLoginStreak) + : tokenClaims.DailyLoginStreak + 1; + tokenClaims.LastDailyLoginDate = today; + + tokens.AddToken(U.RandomUuid().ToString(), new Tokens.DailyLoginToken( + today, + new Solace.DB.Models.Common.Rewards( + 0, + 25, + null, + new Dictionary { [CommonAdventureCrystalId] = 1 }, + [], + []))); + + return new EarthDB.Query(true) + .Update("tokenClaims", playerId, tokenClaims) + .Update("tokens", playerId, tokens); + }) + .ExecuteAsync(Program.DB, cancellationToken); + } + catch (EarthDB.DatabaseException exception) + { + Log.Warning(exception, "Could not create daily login token for {PlayerId}", playerId); + } + } + // does not handle redeeming the token itself (removing it from the list of tokens belonging to the player) public static async Task DoActionsOnRedeemedTokenAsync(EarthDbContext.Results results, TokensEF.Token token, Guid accountId, long currentTime, StaticData.StaticData staticData) { @@ -49,9 +132,60 @@ public static async Task AddTokenAsync(EarthDbContext.Results results, G } break; - case TokensEF.DailyLoginToken { Claimed: false } dailyLoginToken: + case Tokens.Token.TypeE.DAILY_LOGIN: + { + var dailyLoginToken = (Tokens.DailyLoginToken)token; + getQuery.Get("tokenClaims", playerId, typeof(TokenClaims)); + getQuery.Then(results => + { + TokenClaims tokenClaims = results.Get("tokenClaims"); + tokenClaims.LastDailyLoginDate = dailyLoginToken.Date; + tokenClaims.RedeemedDailyLoginDates.Add(dailyLoginToken.Date); + + return new EarthDB.Query(true) + .Update("tokenClaims", playerId, tokenClaims) + .Then(Rewards.FromDBRewardsModel(dailyLoginToken.Rewards).ToRedeemQuery(playerId, currentTime, staticData), false); + }, false); + } + + break; + case Tokens.Token.TypeE.OOBE_ADVENTURE_CRYSTAL: { - await Rewards.FromDBRewardsModel(dailyLoginToken.Rewards).ToRedeemQueryAsync(results, accountId, currentTime, staticData); + var oobeAdventureCrystalToken = (Tokens.OobeAdventureCrystalToken)token; + getQuery.Then(Rewards.FromDBRewardsModel(oobeAdventureCrystalToken.Rewards).ToRedeemQuery(playerId, currentTime, staticData), false); + } + + break; + case Tokens.Token.TypeE.CHALLENGE_PROGRESS: + break; + case Tokens.Token.TypeE.CHALLENGE_COMPLETED: + { + var completedToken = (Tokens.ChallengeCompletedToken)token; + getQuery.Get("challenges", playerId, typeof(ChallengeProgressVersion)); + getQuery.Then(results => + { + ChallengeProgressVersion progress = results.Get("challenges"); + progress.EnsureDate(currentTime); + progress.ClaimedChallengeIds ??= []; + bool firstClaim = progress.ClaimedChallengeIds.Add(completedToken.ChallengeId); + progress.ActiveSeasonId = ChallengesController.ActiveSeasonId; + progress.ActiveSeasonChallengeId = ChallengesController.SelectActiveSeasonChallengeId(progress, progress.ActiveSeasonChallengeId); + progress.UpdatedAt = currentTime; + + var updateQuery = new EarthDB.Query(true) + .Update("challenges", playerId, progress); + + if (firstClaim) + { + Types.Common.Rewards? apiRewards = ChallengesController.GetSeasonChallengeRewards(completedToken.ChallengeId); + if (apiRewards is not null) + { + updateQuery.Then(ToRedeemRewards(apiRewards).ToRedeemQuery(playerId, currentTime, staticData), false); + } + } + + return updateQuery; + }, false); } break; @@ -59,4 +193,35 @@ public static async Task AddTokenAsync(EarthDbContext.Results results, G return token; } + + private static Rewards ToRedeemRewards(Types.Common.Rewards rewards) + { + var result = new Rewards(); + if (rewards.Rubies is > 0) + { + result.AddRubies(rewards.Rubies.Value); + } + + if (rewards.ExperiencePoints is > 0) + { + result.AddExperiencePoints(rewards.ExperiencePoints.Value); + } + + foreach (var item in rewards.Inventory) + { + result.AddItem(item.Id, item.Amount); + } + + foreach (string buildplateId in rewards.Buildplates) + { + result.AddBuildplate(buildplateId); + } + + foreach (var challenge in rewards.Challenges) + { + result.AddChallenge(challenge.Id); + } + + return result; + } } diff --git a/src/Solace.ApiServer/wwwroot/playfab/master_loc_contents.json b/src/Solace.ApiServer/wwwroot/playfab/master_loc_contents.json index 133a3217..7241c381 100644 --- a/src/Solace.ApiServer/wwwroot/playfab/master_loc_contents.json +++ b/src/Solace.ApiServer/wwwroot/playfab/master_loc_contents.json @@ -1,4 +1,4 @@ -{ +{ "store.search.filter.allWorlds": { "sv_SE": "Alla världar", "sk_SK": "Všetky svety", @@ -14943,6 +14943,54 @@ "zh_CN": "史上最佳 - 现已支持您的语言", "zh_TW": "史上最佳 - 現在可以用你的語言遊玩" }, + "challenge_2619913d-6504-4c74-9fc9-e03649a70efc.title": { + "neutral": "Tap Tap Tap", + "en_US": "Tap Tap Tap", + "en_GB": "Tap Tap Tap", + "it_IT": "Tocca Tocca Tocca" + }, + "challenge_2619913d-6504-4c74-9fc9-e03649a70efc.desc.single": { + "neutral": "Collect 3 tappables", + "en_US": "Collect 3 tappables", + "en_GB": "Collect 3 tappables", + "it_IT": "Raccogli 3 oggetti sulla mappa" + }, + "challenge_2b64c950-f80b-4491-b81d-bf90cee88db1.title": { + "neutral": "Treasure Hunt", + "en_US": "Treasure Hunt", + "en_GB": "Treasure Hunt", + "it_IT": "Caccia al tesoro" + }, + "challenge_2b64c950-f80b-4491-b81d-bf90cee88db1.desc.single": { + "neutral": "Collect an adventure chest", + "en_US": "Collect an adventure chest", + "en_GB": "Collect an adventure chest", + "it_IT": "Raccogli un forziere avventura" + }, + "challenge_06eb0e50-b18d-43e8-9aad-422203ffdf28.title": { + "neutral": "Best Defense", + "en_US": "Best Defense", + "en_GB": "Best Defense", + "it_IT": "La miglior difesa" + }, + "challenge_06eb0e50-b18d-43e8-9aad-422203ffdf28.desc.single": { + "neutral": "Defeat a hostile mob in an adventure", + "en_US": "Defeat a hostile mob in an adventure", + "en_GB": "Defeat a hostile mob in an adventure", + "it_IT": "Sconfiggi un mob ostile in un'avventura" + }, + "challenge_bd9d3fd7-12ef-49e0-91fa-c971795f8e35.title": { + "neutral": "Chop Chop", + "en_US": "Chop Chop", + "en_GB": "Chop Chop", + "it_IT": "Svelto svelto" + }, + "challenge_bd9d3fd7-12ef-49e0-91fa-c971795f8e35.desc.single": { + "neutral": "Collect an oak log", + "en_US": "Collect an oak log", + "en_GB": "Collect an oak log", + "it_IT": "Raccogli un tronco di quercia" + }, "editorial.row.raytracingworlds": { "neutral": "Ray Tracing Worlds", "en_US": "Ray Tracing Worlds", @@ -14974,5 +15022,153 @@ "uk_UA": "Світи з трасуванням променів", "zh_CN": "光线追踪世界", "zh_TW": "光線追蹤世界" + }, + "challenge_14e99996-0b42-4d2d-ad84-4ff279827ea6.title": { + "neutral": "Treasure time", + "en_US": "Treasure time", + "en_GB": "Treasure time", + "it_IT": "Treasure time" + }, + "challenge_14e99996-0b42-4d2d-ad84-4ff279827ea6.desc.single": { + "neutral": "Collect 1 chest", + "en_US": "Collect 1 chest", + "en_GB": "Collect 1 chest", + "it_IT": "Collect 1 chest" + }, + "challenge_170b8a07-e781-4509-8de9-ddcc0beb88ba.title": { + "neutral": "Mooooo!", + "en_US": "Mooooo!", + "en_GB": "Mooooo!", + "it_IT": "Mooooo!" + }, + "challenge_170b8a07-e781-4509-8de9-ddcc0beb88ba.desc.single": { + "neutral": "Collect 1 cow", + "en_US": "Collect 1 cow", + "en_GB": "Collect 1 cow", + "it_IT": "Collect 1 cow" + }, + "challenge_1d981b84-a03a-451d-82a6-9bfe0fc885fb.title": { + "neutral": "On the farm", + "en_US": "On the farm", + "en_GB": "On the farm", + "it_IT": "On the farm" + }, + "challenge_1d981b84-a03a-451d-82a6-9bfe0fc885fb.desc.single": { + "neutral": "Collect 1 mob", + "en_US": "Collect 1 mob", + "en_GB": "Collect 1 mob", + "it_IT": "Collect 1 mob" + }, + "challenge_252bb18b-5a96-4ac5-bca0-45c1a0d51269.title": { + "neutral": "Home on the range", + "en_US": "Home on the range", + "en_GB": "Home on the range", + "it_IT": "Home on the range" + }, + "challenge_252bb18b-5a96-4ac5-bca0-45c1a0d51269.desc.single": { + "neutral": "Collect 1 cow or sheep", + "en_US": "Collect 1 cow or sheep", + "en_GB": "Collect 1 cow or sheep", + "it_IT": "Collect 1 cow or sheep" + }, + "challenge_61e55110-e206-4752-95a3-aeb2b98ad6ad.title": { + "neutral": "Zoo keeper", + "en_US": "Zoo keeper", + "en_GB": "Zoo keeper", + "it_IT": "Zoo keeper" + }, + "challenge_61e55110-e206-4752-95a3-aeb2b98ad6ad.desc.single": { + "neutral": "Collect 1 mob", + "en_US": "Collect 1 mob", + "en_GB": "Collect 1 mob", + "it_IT": "Collect 1 mob" + }, + "challenge_6b0655aa-cc63-4876-a1e1-afb319403c1c.title": { + "neutral": "Tap tap away", + "en_US": "Tap tap away", + "en_GB": "Tap tap away", + "it_IT": "Tap tap away" + }, + "challenge_6b0655aa-cc63-4876-a1e1-afb319403c1c.desc.single": { + "neutral": "Collect 3 tappables", + "en_US": "Collect 3 tappables", + "en_GB": "Collect 3 tappables", + "it_IT": "Collect 3 tappables" + }, + "challenge_6d01c0d0-2ac9-4549-be82-acd7f5631950.title": { + "neutral": "Petting zoo", + "en_US": "Petting zoo", + "en_GB": "Petting zoo", + "it_IT": "Petting zoo" + }, + "challenge_6d01c0d0-2ac9-4549-be82-acd7f5631950.desc.single": { + "neutral": "Collect 1 mob", + "en_US": "Collect 1 mob", + "en_GB": "Collect 1 mob", + "it_IT": "Collect 1 mob" + }, + "challenge_d5cbfe47-504a-4e8a-a7b8-481de901c20f.title": { + "neutral": "Treasure trove", + "en_US": "Treasure trove", + "en_GB": "Treasure trove", + "it_IT": "Treasure trove" + }, + "challenge_d5cbfe47-504a-4e8a-a7b8-481de901c20f.desc.single": { + "neutral": "Collect 1 tappable chest", + "en_US": "Collect 1 tappable chest", + "en_GB": "Collect 1 tappable chest", + "it_IT": "Collect 1 tappable chest" + }, + "challenge_e7b9715a-6c27-4708-bab6-ca4c80397625.title": { + "neutral": "Cluck cluck", + "en_US": "Cluck cluck", + "en_GB": "Cluck cluck", + "it_IT": "Cluck cluck" + }, + "challenge_e7b9715a-6c27-4708-bab6-ca4c80397625.desc.single": { + "neutral": "Collect 1 chicken", + "en_US": "Collect 1 chicken", + "en_GB": "Collect 1 chicken", + "it_IT": "Collect 1 chicken" + }, + "challenge_2425a33a-8c73-48d9-9de9-2f11d66c8016.title": { + "neutral": "Ruby reward", + "en_US": "Ruby reward", + "en_GB": "Ruby reward", + "it_IT": "Ricompensa rubino" + }, + "challenge_2425a33a-8c73-48d9-9de9-2f11d66c8016.desc.single": { + "neutral": "Earn a ruby from a tappable", + "en_US": "Earn a ruby from a tappable", + "en_GB": "Earn a ruby from a tappable", + "it_IT": "Ottieni un rubino da un oggetto sulla mappa" + }, + "profile.activity_log_daily_gift": { + "en_US": "Daily Login Completed", + "neutral": "Daily Login Completed" + }, + "daily_login.title": { + "en_US": "Daily Goodies!", + "neutral": "Daily Goodies!" + }, + "daily_login.login_bonus": { + "en_US": "Login Bonuses", + "neutral": "Login Bonuses" + }, + "daily_login.day_7": { + "en_US": "Day 7", + "neutral": "Day 7" + }, + "daily_login.todo": { + "en_US": "Things to do Today", + "neutral": "Things to do Today" + }, + "daily_login.adventure_crystal": { + "en_US": "Daily Gift", + "neutral": "Daily Gift" + }, + "daily_login.slots_full": { + "en_US": "Slots full!", + "neutral": "Slots full!" } -} \ No newline at end of file +} diff --git a/src/Solace.Buildplate/Launcher/Instance.cs b/src/Solace.Buildplate/Launcher/Instance.cs index 0c931291..1b6d82e3 100644 --- a/src/Solace.Buildplate/Launcher/Instance.cs +++ b/src/Solace.Buildplate/Launcher/Instance.cs @@ -597,6 +597,12 @@ object Request if (response is null) { + if (!returnResponse) + { + Log.Warning($"Event bus request '{type}' returned no response for fire-and-forget message"); + return default; + } + Log.Error("Event bus request failed (no response)"); BeginShutdown(); return default; @@ -1110,4 +1116,4 @@ public enum BuildplateSource SHARED, ENCOUNTER } -} \ No newline at end of file +} diff --git a/src/Solace.Buildplate/Launcher/InstanceManager.cs b/src/Solace.Buildplate/Launcher/InstanceManager.cs index 55fa3e38..3ac569fa 100644 --- a/src/Solace.Buildplate/Launcher/InstanceManager.cs +++ b/src/Solace.Buildplate/Launcher/InstanceManager.cs @@ -26,6 +26,7 @@ private enum InstanceType SHARED_BUILD, SHARED_PLAY, ENCOUNTER, + PLAYER_ADVENTURE, } private sealed record StartRequest( @@ -134,6 +135,7 @@ public static async Task CreateAsync(EventBusClient eventBusCli break; case InstanceType.ENCOUNTER: + case InstanceType.PLAYER_ADVENTURE: { survival = true; saveEnabled = false; @@ -271,4 +273,4 @@ public async Task ShutdownAsync() await _publisher.FlushAsync(); await _publisher.CloseAsync(); } -} \ No newline at end of file +} diff --git a/src/Solace.Common/ConsoleProcess.cs b/src/Solace.Common/ConsoleProcess.cs index b4a1cbf9..064a78bd 100644 --- a/src/Solace.Common/ConsoleProcess.cs +++ b/src/Solace.Common/ConsoleProcess.cs @@ -317,7 +317,7 @@ private static bool IsLinuxTerminalAvailable() } } - private static async Task ResolveActualPidAsync(string pidFile, int timeout = 5000) + private static async Task ResolveActualPidAsync(string pidFile, int timeout = 30000) { using var cts = new CancellationTokenSource(timeout); @@ -364,4 +364,4 @@ public void Dispose() Process.Dispose(); _cachedActualProcess?.Dispose(); } -} \ No newline at end of file +} diff --git a/src/Solace.Common/Utils/TaskExtensions.cs b/src/Solace.Common/Utils/TaskExtensions.cs index 303900e7..6e7bf2f2 100644 --- a/src/Solace.Common/Utils/TaskExtensions.cs +++ b/src/Solace.Common/Utils/TaskExtensions.cs @@ -29,14 +29,13 @@ async static Task ForgetAwaited(Task task, Action? onException) { try { - // No need to resume on the original SynchronizationContext, so use ConfigureAwait(false) - await task.ConfigureAwait(false); + await task; } catch (Exception ex) { if (onException is null) { - Log.Error($"Unhandeled async exception: {ex}"); + Log.Error($"Unhandled async exception: {ex}"); } else { diff --git a/src/Solace.DB/Models/Player/Boosts.cs b/src/Solace.DB/Models/Player/Boosts.cs index 915cf867..d0814d5a 100644 --- a/src/Solace.DB/Models/Player/Boosts.cs +++ b/src/Solace.DB/Models/Player/Boosts.cs @@ -6,18 +6,24 @@ namespace Solace.DB.Models.Player; public sealed class BoostsEF : IEntityWithId, IVersionedEntity, IMergeable { - public Guid Id { get; set; } + public ActiveBoost?[] ActiveBoosts { get; init; } + public ActiveMiniFig?[] ActiveMiniFigs { get; init; } + public Dictionary MiniFigRecords { get; init; } - public int Version { get; set; } = 1; - - public Account Account { get; set; } = null!; - - public ActiveBoost?[] ActiveBoosts { get; set; } = new ActiveBoost[5]; + public Boosts() + { + ActiveBoosts = new ActiveBoost[5]; + ActiveMiniFigs = new ActiveMiniFig[5]; + MiniFigRecords = []; + } public ActiveBoost? Get(string instanceId) => ActiveBoosts.FirstOrDefault(activeBoost => activeBoost is not null && activeBoost.InstanceId == instanceId); - public IEnumerable Prune(long currentTime) + public ActiveMiniFig? GetMiniFig(string instanceId) + => ActiveMiniFigs.FirstOrDefault(activeMiniFig => activeMiniFig is not null && activeMiniFig.InstanceId == instanceId); + + public ActiveBoost[] Prune(long currentTime) { for (int index = 0; index < ActiveBoosts.Length; index++) { @@ -48,64 +54,41 @@ public async Task MergeWith(BoostsEF other, ValueMerger merger) } } - public sealed record ActiveBoost( - string InstanceId, - string ItemId, - long StartTime, - long Duration - ) : ICloneable + public ActiveMiniFig[] PruneMiniFigs(long currentTime) { - public ActiveBoost DeepCopy() - => new ActiveBoost(this); - - public sealed class Comparer : IEqualityComparer + LinkedList prunedMiniFigs = []; + for (int index = 0; index < ActiveMiniFigs.Length; index++) { - public static Comparer Instance { get; } = new Comparer(); - - private Comparer() + ActiveMiniFig? activeMiniFig = ActiveMiniFigs[index]; + if (activeMiniFig is not null && activeMiniFig.StartTime + activeMiniFig.Duration < currentTime) { + ActiveMiniFigs[index] = null; + prunedMiniFigs.AddLast(activeMiniFig); } - - public bool Equals(ActiveBoost? x, ActiveBoost? y) - => x == y || (x?.Equals(y) ?? false); - - public int GetHashCode([DisallowNull] ActiveBoost obj) - => obj.GetHashCode(); } - } - public sealed class Legacy : IEquatable - { - public ActiveBoost?[] ActiveBoosts { get; init; } - - public Legacy() - { - ActiveBoosts = new ActiveBoost[5]; - } - - public bool Equals(Legacy? other) - => other is not null && ActiveBoosts.SequenceEqual(other.ActiveBoosts); - - public override bool Equals(object? obj) - => Equals(obj as Legacy); - - public override int GetHashCode() - { - var hash = new HashCode(); - - foreach (var item in ActiveBoosts) - { - hash.Add(item); - } + return [.. prunedMiniFigs]; + } - return hash.ToHashCode(); - } + public sealed record ActiveBoost( + string InstanceId, + string ItemId, + long StartTime, + long Duration + ); - public sealed record ActiveBoost( - string InstanceId, - string ItemId, - long StartTime, - long Duration - ); - } + public sealed record ActiveMiniFig( + string InstanceId, + string ProductId, + string TagId, + long StartTime, + long Duration + ); + + public sealed record MiniFigRecord( + string ProductId, + string TagId, + long LastSeen, + int Activations + ); } diff --git a/src/Solace.DB/Models/Player/TokenClaims.cs b/src/Solace.DB/Models/Player/TokenClaims.cs new file mode 100644 index 00000000..ddde143a --- /dev/null +++ b/src/Solace.DB/Models/Player/TokenClaims.cs @@ -0,0 +1,24 @@ +using System.Text.Json.Serialization; + +namespace Solace.DB.Models.Player; + +public sealed class TokenClaims +{ + [JsonInclude] + public string? LastDailyLoginDate { get; set; } + + [JsonInclude] + public int DailyLoginStreak { get; set; } + + [JsonInclude] + public bool OobeAdventureCrystalGranted { get; set; } + + [JsonInclude] + public bool OobeAdventureCrystalRedeemed { get; set; } + + [JsonInclude] + public HashSet RedeemedDailyLoginDates { get; set; } = []; + + [JsonInclude] + public HashSet RedeemedChallengeRewardKeys { get; set; } = []; +} diff --git a/src/Solace.DB/Models/Player/Tokens.cs b/src/Solace.DB/Models/Player/Tokens.cs index 982d1a9d..755c269c 100644 --- a/src/Solace.DB/Models/Player/Tokens.cs +++ b/src/Solace.DB/Models/Player/Tokens.cs @@ -49,7 +49,10 @@ public async Task MergeWith(TokensEF other, ValueMerger merger) [JsonDerivedType(typeof(LevelUpToken), "LEVEL_UP")] [JsonDerivedType(typeof(JournalItemUnlockedToken), "JOURNAL_ITEM_UNLOCKED")] [JsonDerivedType(typeof(DailyLoginToken), "DAILY_LOGIN")] - public abstract class Token : IEquatable, ICloneable + [JsonDerivedType(typeof(OobeAdventureCrystalToken), "OOBE_ADVENTURE_CRYSTAL")] + [JsonDerivedType(typeof(ChallengeProgressToken), "CHALLENGE_PROGRESS")] + [JsonDerivedType(typeof(ChallengeCompletedToken), "CHALLENGE_COMPLETED")] + public abstract class Token { [JsonIgnore] public TypeE Type { get; init; } @@ -65,7 +68,10 @@ public enum TypeE #pragma warning disable CA1707 // Identifiers should not contain underscores LEVEL_UP, JOURNAL_ITEM_UNLOCKED, - DAILY_LOGIN + DAILY_LOGIN, + OOBE_ADVENTURE_CRYSTAL, + CHALLENGE_PROGRESS, + CHALLENGE_COMPLETED #pragma warning restore CA1707 // Identifiers should not contain underscores } @@ -136,32 +142,6 @@ public override JournalItemUnlockedToken DeepCopy() => new JournalItemUnlockedToken(ItemId); } - public sealed class DailyLoginToken : Token - { - public string Date { get; init; } - public Rewards Rewards { get; init; } - public bool Claimed { get; init; } - public long? ClaimedOn { get; init; } - - public DailyLoginToken(string date, Rewards rewards, bool claimed = false, long? claimedOn = null) - : base(TypeE.DAILY_LOGIN) - { - Date = date; - Rewards = rewards; - Claimed = claimed; - ClaimedOn = claimedOn; - } - - public override bool Equals(Token? other) - => other is DailyLoginToken dailyLogin && Date == dailyLogin.Date && Rewards.Equals(dailyLogin.Rewards) && Claimed == dailyLogin.Claimed && ClaimedOn == dailyLogin.ClaimedOn; - - public override int GetHashCode() - => HashCode.Combine(Date, Rewards, Claimed, ClaimedOn); - - public override DailyLoginToken DeepCopy() - => new DailyLoginToken(Date, Rewards.DeepCopy(), Claimed, ClaimedOn); - } - public sealed class Legacy : IEquatable { [JsonInclude, JsonPropertyName("tokens")] @@ -262,4 +242,56 @@ public override int GetHashCode() => HashCode.Combine(ItemId); } } + + public sealed class DailyLoginToken : Token + { + public string Date { get; init; } + public Rewards Rewards { get; init; } + + public DailyLoginToken(string date, Rewards rewards) + : base(TypeE.DAILY_LOGIN) + { + Date = date; + Rewards = rewards; + } + } + + public sealed class OobeAdventureCrystalToken : Token + { + public string ItemId { get; init; } + public Rewards Rewards { get; init; } + + public OobeAdventureCrystalToken(string itemId, Rewards rewards) + : base(TypeE.OOBE_ADVENTURE_CRYSTAL) + { + ItemId = itemId; + Rewards = rewards; + } + } + + public sealed class ChallengeProgressToken : Token + { + public string ChallengeId { get; init; } + public string ChallengeReferenceId { get; init; } + + public ChallengeProgressToken(string challengeId, string challengeReferenceId) + : base(TypeE.CHALLENGE_PROGRESS) + { + ChallengeId = challengeId; + ChallengeReferenceId = challengeReferenceId; + } + } + + public sealed class ChallengeCompletedToken : Token + { + public string ChallengeId { get; init; } + public string ChallengeReferenceId { get; init; } + + public ChallengeCompletedToken(string challengeId, string challengeReferenceId) + : base(TypeE.CHALLENGE_COMPLETED) + { + ChallengeId = challengeId; + ChallengeReferenceId = challengeReferenceId; + } + } } diff --git a/src/Solace.StaticData/AdventuresConfig.cs b/src/Solace.StaticData/AdventuresConfig.cs index 14a4aada..ceff11bc 100644 --- a/src/Solace.StaticData/AdventuresConfig.cs +++ b/src/Solace.StaticData/AdventuresConfig.cs @@ -7,7 +7,6 @@ namespace Solace.StaticData; public sealed class AdventuresConfig { - public const string CommonAdventureCrystalId = "4f16a053-4929-263a-c91a-29663e29df76"; private static readonly string[] DefaultFolders = ["common", "uncommon", "rare", "epic", "legendary", "oobe"]; public readonly AdventureSpawnConfig SpawnConfig; @@ -56,7 +55,7 @@ internal AdventuresConfig(string dir) public bool CanSpawn => SpawnConfig.CrystalTypes.Length > 0 && SpawnConfig.MaxCount > 0; public AdventureCrystalType? PickCrystalType(Random random) - => PickWeighted(SpawnConfig.CrystalTypes, item => int.Max(0, item.PickWeight), random); + => PickWeighted(SpawnConfig.CrystalTypes, item => item.PickWeight, random); public string? PickTemplateForFolder(string folder, Random random) { @@ -100,27 +99,23 @@ private static AdventureSpawnConfig LoadSpawnConfig(string dir) private static T? PickWeighted(IReadOnlyList items, Func weightSelector, Random random) { - var weightedItems = items - .Select(item => (Item: item, Weight: int.Max(0, weightSelector(item)))) - .Where(item => item.Weight > 0) - .ToArray(); - int totalWeight = weightedItems.Sum(item => item.Weight); + int totalWeight = items.Sum(weightSelector); if (totalWeight <= 0) { return default; } int roll = random.Next(0, totalWeight); - foreach (var item in weightedItems) + foreach (T item in items) { - roll -= item.Weight; + roll -= weightSelector(item); if (roll < 0) { - return item.Item; + return item; } } - return weightedItems[^1].Item; + return items[^1]; } public sealed record AdventureSpawnConfig( diff --git a/src/Solace.StaticData/Catalog.cs b/src/Solace.StaticData/Catalog.cs index 4080a58b..a91315d1 100644 --- a/src/Solace.StaticData/Catalog.cs +++ b/src/Solace.StaticData/Catalog.cs @@ -503,10 +503,12 @@ string ReturnItemId public sealed class NFCBoostsCatalogR { private sealed record NFCBoostsCatalogFile( - NFCBoost[] MiniFigs + MiniFig[] MiniFigs ); - public readonly NFCBoost[] MiniFigs; + public readonly ImmutableArray MiniFigs; + + private readonly Dictionary miniFigsById = []; internal NFCBoostsCatalogR(string file) { @@ -516,19 +518,36 @@ internal NFCBoostsCatalogR(string file) nfcBoostsCatalogFile = Json.Deserialize(stream); } - MiniFigs = nfcBoostsCatalogFile?.MiniFigs ?? []; + Debug.Assert(nfcBoostsCatalogFile is not null); + MiniFigs = ImmutableCollectionsMarshal.AsImmutableArray(nfcBoostsCatalogFile.MiniFigs); + + foreach (MiniFig miniFig in MiniFigs) + { + if (!miniFigsById.TryAdd(miniFig.Id, miniFig)) + { + throw new StaticDataException($"Duplicate NFC mini fig ID {miniFig.Id}"); + } + } } - public sealed record NFCBoost( + public MiniFig? GetMiniFig(string id) + => miniFigsById.GetValueOrDefault(id); + + public sealed record MiniFig( string Id, - BoostInfo BoostMetadata, + BoostMetadataR BoostMetadata, string Name, bool Deprecated, string ToolsVersion, - Rewards Rewards + RewardsR Rewards + ); + + public sealed record RewardsR( + int? Rubies, + int? ExperiencePoints ); - public sealed record BoostInfo( + public sealed record BoostMetadataR( string Name, string Attribute, bool CanBeDeactivated, @@ -536,12 +555,12 @@ public sealed record BoostInfo( string? ActiveDuration, bool Additive, int? Level, - Effect[] Effects, + EffectR[] Effects, string? Scenario, string? Cooldown ); - public sealed record Effect( + public sealed record EffectR( string Type, string? Duration, double? Value, @@ -552,28 +571,5 @@ public sealed record Effect( string Activation, string? ModifiesType ); - - public sealed record Rewards( - int? Rubies, - int? ExperiencePoints, - int? Level, - Rewards.RewardItem[] Inventory, - string[] Buildplates, - Rewards.RewardChallenge[] Challenges, - string[] PersonaItems, - Rewards.RewardUtilityBlock[] UtilityBlocks - ) - { - public sealed record RewardItem( - string Id, - int Amount - ); - - public sealed record RewardChallenge( - string Id - ); - - public sealed record RewardUtilityBlock(); - } } } diff --git a/src/Solace.StaticData/StaticData.cs b/src/Solace.StaticData/StaticData.cs index 24f98dd5..74d681ce 100644 --- a/src/Solace.StaticData/StaticData.cs +++ b/src/Solace.StaticData/StaticData.cs @@ -8,6 +8,7 @@ public sealed class StaticData private PlayerLevels? _levels; private TappablesConfig? _tappablesConfig; private EncountersConfig? _encountersConfig; + private AdventuresConfig? _adventuresConfig; private TileRenderer? _tileRenderer; private Buildplates? _buildplates; private Playfab? _playfab; @@ -25,9 +26,11 @@ public StaticData(string dir) public EncountersConfig EncountersConfig => _encountersConfig ??= new EncountersConfig(Path.Combine(Directory, "encounters")); + public AdventuresConfig AdventuresConfig => _adventuresConfig ??= new AdventuresConfig(Path.Combine(Directory, "adventures")); + public TileRenderer TileRenderer => _tileRenderer ??= new TileRenderer(Path.Combine(Directory, "tile_renderer")); public Buildplates Buildplates => _buildplates ??= new Buildplates(Path.Combine(Directory, "buildplates")); public Playfab Playfab => _playfab ??= new Playfab(Path.Combine(Directory, "playfab")); -} \ No newline at end of file +} diff --git a/src/Solace.TappablesGenerator/Spawner.cs b/src/Solace.TappablesGenerator/Spawner.cs index 4d355841..7f6aebde 100644 --- a/src/Solace.TappablesGenerator/Spawner.cs +++ b/src/Solace.TappablesGenerator/Spawner.cs @@ -28,7 +28,8 @@ public Spawner(ActiveTiles activeTiles, TappableGenerator tappableGenerator, Enc _encounterGenerator = encounterGenerator; _publisher = publisher; - _maxTappableLifetimeIntervals = (int)(long.Max(TappableGenerator.GetMaxTappableLifetime(), _encounterGenerator.GetMaxEncounterLifetime()) / SPAWN_INTERVAL + 1); + long maxLifetime = long.Max(TappableGenerator.GetMaxTappableLifetime(), _encounterGenerator.GetMaxEncounterLifetime()); + _maxTappableLifetimeIntervals = (int)(maxLifetime / SPAWN_INTERVAL + 1); _spawnCycleTime = U.CurrentTimeMillis(); _spawnCycleIndex = _maxTappableLifetimeIntervals; @@ -165,5 +166,6 @@ private async Task SendSpawnedTappables(List tappables, List