From 7ae084b37e0f67eb69720257ba965c6baf0e8edd Mon Sep 17 00:00:00 2001 From: LNLenost Date: Mon, 1 Jun 2026 13:04:28 +0200 Subject: [PATCH 01/10] Add challenge support and fix tappable objective progress --- .../Controllers/EarthApi/BoostsController.cs | 227 ++- .../EarthApi/BuildplatesController.cs | 311 +++- .../Controllers/EarthApi/CatalogController.cs | 43 +- .../Controllers/EarthApi/CdnTileController.cs | 4 +- .../EarthApi/ChallengeActionsController.cs | 157 ++ .../EarthApi/ChallengesController.cs | 1478 ++++++++++++++++- .../EarthApi/DailyGoodiesController.cs | 211 +++ .../EarthApi/EnvironmentSettingsController.cs | 56 +- .../Controllers/EarthApi/SeasonsController.cs | 101 ++ .../Controllers/EarthApi/SigninController.cs | 3 + .../Controllers/EarthApi/SummaryController.cs | 21 + .../EarthApi/TappablesController.cs | 324 +++- .../Controllers/EarthApi/TokensController.cs | 62 +- .../EarthApi/TutorialController.cs | 63 + .../PlayfabApi/ClientController.cs | 101 +- .../Controllers/PlayfabApi/EventController.cs | 17 +- src/Solace.ApiServer/Types/Boost/Boosts.cs | 15 +- .../Types/Buildplates/BuildplateInstance.cs | 3 +- src/Solace.ApiServer/Types/Common/Token.cs | 10 +- .../Utils/BuildplateInstanceRequestHandler.cs | 65 +- .../Utils/BuildplateInstancesManager.cs | 62 +- .../Utils/ChallengeProgressVersion.cs | 56 + .../Utils/EarthApiResponse.cs | 6 +- .../Utils/TappablesManager.cs | 296 +++- src/Solace.ApiServer/Utils/TileUtils.cs | 93 +- src/Solace.ApiServer/Utils/TokenUtils.cs | 174 +- .../wwwroot/playfab/master_loc_contents.json | 200 ++- src/Solace.Buildplate/Launcher/Instance.cs | 8 +- .../Launcher/InstanceManager.cs | 4 +- src/Solace.Common/ConsoleProcess.cs | 15 +- src/Solace.DB/Models/Player/Boosts.cs | 38 + src/Solace.DB/Models/Player/TokenClaims.cs | 24 + src/Solace.DB/Models/Player/Tokens.cs | 62 +- src/Solace.StaticData/AdventureMapIcons.cs | 20 + src/Solace.StaticData/AdventuresConfig.cs | 162 ++ src/Solace.StaticData/Catalog.cs | 60 +- src/Solace.StaticData/StaticData.cs | 5 +- src/Solace.TappablesGenerator/Spawner.cs | 4 +- 38 files changed, 4345 insertions(+), 216 deletions(-) create mode 100644 src/Solace.ApiServer/Controllers/EarthApi/ChallengeActionsController.cs create mode 100644 src/Solace.ApiServer/Controllers/EarthApi/DailyGoodiesController.cs create mode 100644 src/Solace.ApiServer/Controllers/EarthApi/SeasonsController.cs create mode 100644 src/Solace.ApiServer/Controllers/EarthApi/SummaryController.cs create mode 100644 src/Solace.ApiServer/Controllers/EarthApi/TutorialController.cs create mode 100644 src/Solace.ApiServer/Utils/ChallengeProgressVersion.cs create mode 100644 src/Solace.DB/Models/Player/TokenClaims.cs create mode 100644 src/Solace.StaticData/AdventureMapIcons.cs create mode 100644 src/Solace.StaticData/AdventuresConfig.cs diff --git a/src/Solace.ApiServer/Controllers/EarthApi/BoostsController.cs b/src/Solace.ApiServer/Controllers/EarthApi/BoostsController.cs index 08d9e853..4276b9a0 100644 --- a/src/Solace.ApiServer/Controllers/EarthApi/BoostsController.cs +++ b/src/Solace.ApiServer/Controllers/EarthApi/BoostsController.cs @@ -28,6 +28,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 cancellation) { @@ -52,13 +57,24 @@ public async Task> GetBoosts(Cancellation Boosts boosts = results1.Get("boosts"); Profile profile = results1.Get("profile"); - return PruneBoostsAndUpdateProfile(boosts, profile, requestStartedOn, catalog.ItemsCatalog) - ? new EarthDB.Query(true) - .Update("boosts", playerId, boosts) - .Update("profile", playerId, profile) - .Extra("boosts", boosts) - : new EarthDB.Query(false) + 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); } @@ -140,32 +156,155 @@ public async Task> GetBoosts(Cancellation scenarioBoosts["death"] = [.. triggeredOnDeathBoosts]; } + // 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(); + } + + Catalog.NFCBoostsCatalogR.MiniFig? miniFig = catalog.NfcBoostsCatalog.GetMiniFig(productId); + string resolvedProductId = productId; + string tagId = id; + if (miniFig is null) + { + Catalog.NFCBoostsCatalogR.MiniFig? swappedMiniFig = catalog.NfcBoostsCatalog.GetMiniFig(id); + if (swappedMiniFig is not null) + { + miniFig = swappedMiniFig; + resolvedProductId = id; + 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) { @@ -277,6 +416,7 @@ public async Task> ActivateBoost(string i } [HttpDelete("boosts/{instanceId}")] + [HttpDelete("boosts/{instanceId}/deactivate")] public async Task> DeactivateBoost(string instanceId, CancellationToken cancellationToken) { string? playerId = User.FindFirstValue(ClaimTypes.NameIdentifier); @@ -304,13 +444,20 @@ public async Task> DeactivateBoost(string } Boosts.ActiveBoost? activeBoost = boosts.Get(instanceId); - if (activeBoost is null) + Boosts.ActiveMiniFig? activeMiniFig = boosts.GetMiniFig(instanceId); + if (activeBoost is null && activeMiniFig is null) { return new EarthDB.Query(false); } - Catalog.ItemsCatalogR.Item? item = catalog.ItemsCatalog.GetItem(activeBoost.ItemId); - if (item is null || item.BoostInfo is null || !item.BoostInfo.CanBeRemoved) + 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); } @@ -325,7 +472,18 @@ public async Task> DeactivateBoost(string } } - if (item.BoostInfo.Effects.Any(effect => effect.Type is Catalog.ItemsCatalogR.Item.BoostInfoR.Effect.TypeE.HEALTH)) + 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); @@ -372,4 +530,27 @@ private static bool PruneBoostsAndUpdateProfile(Boosts boosts, Profile profile, 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 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 1d050dcb..68a97ffd 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; @@ -27,6 +28,7 @@ namespace Solace.ApiServer.Controllers.EarthApi; [Route("1/api/v{version:apiVersion}")] internal sealed class BuildplatesController : SolaceControllerBase { + 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; @@ -329,6 +331,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(string encounterId, CancellationToken cancellationToken) { @@ -345,6 +356,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 @@ -363,22 +534,25 @@ public async Task> GetInstanceS return TypedResults.NotFound(); } - Buildplates.Buildplate? buildplate; - try + if (instanceInfo.Type is BuildplateInstancesManager.InstanceType.BUILD or BuildplateInstancesManager.InstanceType.PLAY) { - 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); - } + 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(); + 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 @@ -441,7 +615,7 @@ private static 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 { @@ -545,6 +819,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(), }; @@ -641,7 +916,7 @@ private enum Source return new BuildplateInstance( instanceInfo.InstanceId, "00000000-0000-0000-0000-000000000000", - "d.projectearth.dev", // TODO + "67e.duckdns.org", instanceInfo.Address, instanceInfo.Port, instanceInfo.Ready, diff --git a/src/Solace.ApiServer/Controllers/EarthApi/CatalogController.cs b/src/Solace.ApiServer/Controllers/EarthApi/CatalogController.cs index 912b5d6c..f815ddfc 100644 --- a/src/Solace.ApiServer/Controllers/EarthApi/CatalogController.cs +++ b/src/Solace.ApiServer/Controllers/EarthApi/CatalogController.cs @@ -397,8 +397,45 @@ private static JournalCatalog MakeJournalCatalogApiResponse(Catalog catalog) return new JournalCatalog(items); } -#pragma warning disable IDE0060 // Remove unused parameter private static NFCBoost[] MakeNFCBoostsCatalogApiResponse(Catalog catalog) -#pragma warning restore IDE0060 // Remove unused parameter - => []; // TODO + => [.. catalog.NfcBoostsCatalog.MiniFigs.Select(miniFig => new NFCBoost( + miniFig.Id, + miniFig.Name, + "MiniFig", + new Types.Common.Rewards( + miniFig.Rewards.Rubies, + miniFig.Rewards.ExperiencePoints, + null, + [], + [], + [], + [], + [] + ), + new BoostMetadata( + miniFig.BoostMetadata.Name, + "MiniFig", + miniFig.BoostMetadata.Attribute, + miniFig.BoostMetadata.CanBeDeactivated, + miniFig.BoostMetadata.CanBeRemoved, + miniFig.BoostMetadata.ActiveDuration, + miniFig.BoostMetadata.Additive, + miniFig.BoostMetadata.Level, + [.. miniFig.BoostMetadata.Effects.Select(effect => new Types.Common.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 + ))], + miniFig.BoostMetadata.Scenario, + miniFig.BoostMetadata.Cooldown + ), + miniFig.Deprecated, + miniFig.ToolsVersion + ))]; } diff --git a/src/Solace.ApiServer/Controllers/EarthApi/CdnTileController.cs b/src/Solace.ApiServer/Controllers/EarthApi/CdnTileController.cs index 5a42d3de..5ed70f15 100644 --- a/src/Solace.ApiServer/Controllers/EarthApi/CdnTileController.cs +++ b/src/Solace.ApiServer/Controllers/EarthApi/CdnTileController.cs @@ -7,12 +7,13 @@ 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 { [HttpGet] public async Task> GetTile(int _, int tilePos1, int tilePos2, CancellationToken cancellationToken) // _ used because we dont care :| { + Response.Headers.ContentType = "image/png"; if (!await TileUtils.TryWriteTile(tilePos1, tilePos2, Response.Body, cancellationToken)) { return TypedResults.NotFound(); @@ -20,7 +21,6 @@ public async Task> GetTile(int _, int tilePos 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 new file mode 100644 index 00000000..4d3ce6f5 --- /dev/null +++ b/src/Solace.ApiServer/Controllers/EarthApi/ChallengeActionsController.cs @@ -0,0 +1,157 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Solace.DB; +using Solace.ApiServer.Utils; +using Solace.Common; +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; + +[Authorize] +[ApiVersion("1.1")] +[Route("1/api/v{version:apiVersion}/challenges")] +internal sealed class ChallengeActionsController : ControllerBase +{ + [HttpPost("{challengeId}/modifyState")] + [HttpPut("{challengeId}/modifyState")] + public async Task ModifyState(string challengeId, CancellationToken cancellationToken) + { + string? playerId = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (string.IsNullOrWhiteSpace(playerId)) + { + return BadRequest(); + } + + long now = HttpContext.GetTimestamp(); + 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 Content(Json.Serialize(new EarthApiResponse(new Dictionary + { + ["challengeId"] = challengeId, + ["state"] = "Claimed", + ["rewards"] = apiRewards ?? rewards.ToApiResponse(), + ["updates"] = new Dictionary() + }, updates)), "application/json"); + } + + [HttpPost("timed/generate")] + [HttpPut("timed/generate")] + public IActionResult GenerateTimedChallenges() + => Content(Json.Serialize(new EarthApiResponse(new Dictionary + { + ["updates"] = new Dictionary() + })), "application/json"); + + [HttpPost("reset")] + [HttpPut("reset")] + public IActionResult ResetChallenges() + => Content(Json.Serialize(new EarthApiResponse(new Dictionary + { + ["updates"] = new Dictionary() + })), "application/json"); + + [HttpPost("continuous/{id}/remove")] + [HttpDelete("continuous/{id}/remove")] + public async Task RemoveContinuousChallenge(string id, CancellationToken cancellationToken) + { + string? playerId = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (string.IsNullOrWhiteSpace(playerId)) + { + return BadRequest(); + } + + long now = HttpContext.GetTimestamp(); + 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 Content(Json.Serialize(new EarthApiResponse(new Dictionary + { + ["challengeId"] = id, + ["updates"] = new Dictionary() + }, updates)), "application/json"); + } + + 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..8372d71f 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,1420 @@ 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); + 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++) + { + 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; + challenges[challenge.Key] = new ChallengeRecord( + challenge.ReferenceId, + null, + DailyGroupId, + "PersonalTimed", + "Regular", + "retention", + Rarity.COMMON, + index + 1, + dailyEndTime, + isClaimed ? "Claimed" : isComplete ? "Completed" : "Active", + isComplete, + currentCount * 100 / threshold, + currentCount, + threshold, + [], + "And", + new Rewards(0, 10, null, [], [], [], [], []), + new object() + ); + } + + for (int index = 0; index < ContinuousChallengePool.Length; index++) { - { "challenges", new Dictionary() + DailyChallengeDefinition challenge = ContinuousChallengePool[index]; + if (progress.RemovedContinuousChallengeIds?.Contains(challenge.Key) == true || + progress.ClaimedChallengeIds?.Contains(challenge.Key) == true) { - // 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; + } + + 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 new file mode 100644 index 00000000..c563d5bc --- /dev/null +++ b/src/Solace.ApiServer/Controllers/EarthApi/DailyGoodiesController.cs @@ -0,0 +1,211 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; +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 DBRewards = Solace.DB.Models.Common.Rewards; + +namespace Solace.ApiServer.Controllers.EarthApi; + +[Authorize] +[ApiVersion("1.1")] +[Route("1/api/v{version:apiVersion}")] +internal sealed class DailyGoodiesController : SolaceControllerBase +{ + 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")] + [HttpGet("player/daily-login")] + [HttpGet("player/dailyrewards")] + [HttpGet("dailygoodies")] + [HttpGet("daily-goodies")] + [HttpGet("daily-login")] + [HttpGet("dailyrewards")] + public async Task> Get(CancellationToken cancellationToken) + { + if (!TryGetPlayerId(out string playerId)) + { + return TypedResults.BadRequest(); + } + + await TokenUtils.EnsureDailyLoginToken(playerId, cancellationToken); + string today = TodayUtc(); + + 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/daily-goodies/claim")] + [HttpPost("player/daily-login/claim")] + [HttpPost("player/dailyrewards/claim")] + [HttpPost("dailygoodies/claim")] + [HttpPost("daily-goodies/claim")] + [HttpPost("daily-login/claim")] + [HttpPost("dailyrewards/claim")] + [HttpPost("player/dailygoodies/collect")] + [HttpPost("player/daily-goodies/collect")] + [HttpPost("player/daily-login/collect")] + [HttpPost("player/dailyrewards/collect")] + [HttpPost("dailygoodies/collect")] + [HttpPost("daily-goodies/collect")] + [HttpPost("daily-login/collect")] + [HttpPost("dailyrewards/collect")] + [HttpPost("player/dailygoodies/redeem")] + [HttpPost("player/daily-goodies/redeem")] + [HttpPost("player/daily-login/redeem")] + [HttpPost("player/dailyrewards/redeem")] + [HttpPost("dailygoodies/redeem")] + [HttpPost("daily-goodies/redeem")] + [HttpPost("daily-login/redeem")] + [HttpPost("dailyrewards/redeem")] + public async Task> Claim(CancellationToken cancellationToken) + { + if (!TryGetPlayerId(out string playerId)) + { + return TypedResults.BadRequest(); + } + + await TokenUtils.EnsureDailyLoginToken(playerId, cancellationToken); + + long requestStartedOn = HttpContext.GetTimestamp(); + string today = TodayUtc(); + + 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"); + + 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(); + } + + TokenClaims latestClaims = (await new EarthDB.Query(false) + .Get("tokenClaims", playerId, typeof(TokenClaims)) + .Get("tokens", playerId, typeof(Tokens)) + .ExecuteAsync(earthDB, cancellationToken)) + .Get("tokenClaims"); + + var updates = new EarthApiResponse.UpdatesResponse(results); + updates.Map["tokens"] = (int)DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + + return EarthJson(BuildDailyGoodiesResponse(today, latestClaims, null, false, true), 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 string? FindDailyLoginTokenId(Tokens tokens, string today) + => tokens.GetTokens() + .FirstOrDefault(token => token.Token is Tokens.DailyLoginToken dailyLoginToken && dailyLoginToken.Date == today) + ?.Id; + + private static Dictionary BuildDailyGoodiesResponse(string today, TokenClaims tokenClaims, string? tokenId, bool hasToken, bool claimed) + { + DBRewards rewards = DailyLoginRewards(); + + var rewardResponse = Utils.Rewards.FromDBRewardsModel(rewards).ToApiResponse(); + int streak = Math.Max(1, tokenClaims.DailyLoginStreak); + int currentDay = ((streak - 1) % 7) + 1; + string state = claimed ? "Completed" : hasToken ? "Available" : "Locked"; + + return new Dictionary + { + ["id"] = tokenId ?? "", + ["date"] = today, + ["state"] = state, + ["claimed"] = claimed, + ["available"] = hasToken && !claimed, + ["streak"] = streak, + ["currentDay"] = currentDay, + ["tokenId"] = tokenId ?? "", + ["rewards"] = rewardResponse, + ["dailyGift"] = 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 }, + new Dictionary { ["challengeId"] = "1d981b84-a03a-451d-82a6-9bfe0fc885fb", ["reward"] = 45 }, + new Dictionary { ["challengeId"] = "2619913d-6504-4c74-9fc9-e03649a70efc", ["reward"] = 50 } + }, + ["calendar"] = new[] + { + new Dictionary + { + ["day"] = 1, + ["state"] = "Available", + ["rewards"] = rewardResponse + } + } + }; + } + + private static DBRewards DailyLoginRewards() + => 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 new file mode 100644 index 00000000..f6365376 --- /dev/null +++ b/src/Solace.ApiServer/Controllers/EarthApi/SeasonsController.cs @@ -0,0 +1,101 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Http.HttpResults; +using System.Security.Claims; +using Solace.ApiServer.Utils; +using Solace.Common; +using Solace.Common.Utils; +using Solace.DB; + +namespace Solace.ApiServer.Controllers.EarthApi; + +[Authorize] +[ApiVersion("1.1")] +[Route("1/api/v{version:apiVersion}")] +internal sealed class SeasonsController : ControllerBase +{ + [HttpGet("player/season")] + [HttpGet("player/seasons")] + [HttpGet("player/seasonpass")] + [HttpGet("season")] + [HttpGet("seasons")] + public IActionResult GetSeason() + { + long now = HttpContext.GetTimestamp(); + long endsAt = new DateTimeOffset(DateTimeOffset.FromUnixTimeMilliseconds(now).UtcDateTime.Date.AddDays(30)).ToUnixTimeMilliseconds(); + + return Content(Json.Serialize(new EarthApiResponse(new Dictionary + { + ["activeSeasonId"] = ChallengesController.ActiveSeasonId, + ["seasonId"] = ChallengesController.ActiveSeasonId, + ["title"] = "Season 17", + ["startTimeUtc"] = TimeFormatter.FormatTime(now - 24 * 60 * 60 * 1000), + ["endTimeUtc"] = TimeFormatter.FormatTime(endsAt), + ["premiumPassOwned"] = true, + ["currentTier"] = 1, + ["currentXp"] = 0, + ["tiers"] = new[] + { + new Dictionary + { + ["tier"] = 1, + ["xpRequired"] = 0, + ["freeRewards"] = Array.Empty(), + ["premiumRewards"] = Array.Empty() + } + } + })), "application/json"); + } + + [HttpPost("player/seasonpass/purchase")] + [HttpPost("seasonpass/purchase")] + public IActionResult PurchaseSeasonPass() + => Content(Json.Serialize(new EarthApiResponse(new Dictionary + { + ["premiumPassOwned"] = true + })), "application/json"); + + [HttpPost("challenges/season/active/{id}")] + [HttpPut("challenges/season/active/{id}")] + [HttpPost("player/challenges/season/active/{id}")] + [HttpPut("player/challenges/season/active/{id}")] + public async Task> SetActiveSeasonChallenge(string id, CancellationToken cancellationToken) + { + 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(); + + 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 TypedResults.Content(Json.Serialize(new EarthApiResponse(new Dictionary + { + ["activeSeasonChallenge"] = selectedChallengeId, + ["activeChallengeId"] = selectedChallengeId, + ["activeSeasonId"] = ChallengesController.ActiveSeasonId, + ["seasonId"] = ChallengesController.ActiveSeasonId, + }, updates)), "application/json"); + } +} diff --git a/src/Solace.ApiServer/Controllers/EarthApi/SigninController.cs b/src/Solace.ApiServer/Controllers/EarthApi/SigninController.cs index 919f2a92..85ef50d0 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; namespace Solace.ApiServer.Controllers; @@ -40,6 +41,8 @@ public async Task> Post(string profileID, // TODO: check credentials + await TokenUtils.EnsureDailyLoginToken(userId.ToLowerInvariant(), cancellationToken); + // TODO: generate secure session token string token = userId.ToUpperInvariant(); diff --git a/src/Solace.ApiServer/Controllers/EarthApi/SummaryController.cs b/src/Solace.ApiServer/Controllers/EarthApi/SummaryController.cs new file mode 100644 index 00000000..1c4f8947 --- /dev/null +++ b/src/Solace.ApiServer/Controllers/EarthApi/SummaryController.cs @@ -0,0 +1,21 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Solace.ApiServer.Utils; +using Solace.Common; + +namespace Solace.ApiServer.Controllers.EarthApi; + +[AllowAnonymous] +[ApiVersion("1.1")] +[Route("1")] +internal sealed class SummaryController : ControllerBase +{ + [HttpGet("summary")] + public IActionResult Get() + => Content(Json.Serialize(new EarthApiResponse(new Dictionary + { + ["status"] = "ok", + ["updates"] = new Dictionary() + })), "application/json"); +} diff --git a/src/Solace.ApiServer/Controllers/EarthApi/TappablesController.cs b/src/Solace.ApiServer/Controllers/EarthApi/TappablesController.cs index 95233982..9559e48a 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,8 +25,66 @@ internal sealed class TappablesController : SolaceControllerBase 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 + ); + + 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) { string? playerId = User.FindFirstValue(ClaimTypes.NameIdentifier); @@ -36,8 +97,9 @@ public async Task> GetTappables(double la await tappablesManager.NotifyTileActiveAsync(playerId, 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); try { @@ -47,7 +109,9 @@ public async Task> GetTappables(double la RedeemedTappables redeemedTappables = results.Get("redeemedTappables"); IEnumerable activeLocationTappables = tappables - .Where(tappable => tappable.SpawnTime + tappable.ValidFor > requestStartedOn && !redeemedTappables.IsRedeemed(tappable.Id)) + .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), @@ -62,7 +126,9 @@ public async Task> GetTappables(double la )); IEnumerable activeLocationEncounters = encounters - .Where(encounter => encounter.SpawnTime + encounter.ValidFor > requestStartedOn) + .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, TappablesManager.LocationToTileId(encounter.Lat, encounter.Lon), @@ -84,7 +150,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() { @@ -98,6 +187,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) { @@ -127,10 +223,16 @@ public async Task> RedeemTappable(string 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"); RedeemedTappables redeemedTappables = results1.Get("redeemedTappables"); @@ -162,9 +264,11 @@ public async Task> RedeemTappable(string } 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); @@ -178,9 +282,33 @@ public async Task> RedeemTappable(string 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)); @@ -191,6 +319,9 @@ public async Task> RedeemTappable(string if ((bool)results.GetExtra("success")) { + var updates = new EarthApiResponse.UpdatesResponse(results); + updates.Map["challenges"] = (int)(requestStartedOn / 1000); + return EarthJson(new Dictionary() { { "token", new Token( @@ -200,7 +331,7 @@ public async Task> RedeemTappable(string Token.LifetimeE.PERSISTENT ) }, { "updates", null } - }, new EarthApiResponse.UpdatesResponse(results)); + }, updates); } else { @@ -248,4 +379,185 @@ private sealed record TappableRequest( string 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, BestDefenseReferenceId); + 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); + shouldReward = true; + } + } + + string dailyGroupRewardKey = $"{today}:{DailyGroupId}"; + if (completedDailyChallenges >= DailyChallengeCount && tokenClaims.RedeemedChallengeRewardKeys.Add(dailyGroupRewardKey)) + { + rewards.AddExperiencePoints(25).AddItem(CommonAdventureCrystalId, 1); + 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 93fb4c5b..54b27558 100644 --- a/src/Solace.ApiServer/Controllers/EarthApi/TokensController.cs +++ b/src/Solace.ApiServer/Controllers/EarthApi/TokensController.cs @@ -18,18 +18,24 @@ namespace Solace.ApiServer.Controllers.EarthApi; [Route("1/api/v{version:apiVersion}/player/tokens")] internal sealed class TokensController : SolaceControllerBase { + 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) { + DisableClientCache(); + string? playerId = User.FindFirstValue(ClaimTypes.NameIdentifier); if (string.IsNullOrEmpty(playerId)) { return TypedResults.BadRequest(); } + await TokenUtils.EnsureDailyLoginToken(playerId, cancellationToken); + Tokens tokens = (await new EarthDB.Query(false) .Get("tokens", playerId, typeof(Tokens)) .ExecuteAsync(earthDB, cancellationToken)) @@ -47,6 +53,8 @@ public async Task> Get(CancellationToken [HttpPost("{tokenId}/redeem")] public async Task> Redeem(string tokenId, CancellationToken cancellationToken) { + DisableClientCache(); + string? playerId = User.FindFirstValue(ClaimTypes.NameIdentifier); if (string.IsNullOrEmpty(playerId)) { @@ -57,9 +65,10 @@ public async Task> Redeem(string tokenId, long requestStartedOn = HttpContext.GetTimestamp(); Tokens.Token? token; + EarthDB.Results results; try { - EarthDB.Results results = await new EarthDB.Query(true) + results = await new EarthDB.Query(true) .Get("tokens", playerId, typeof(Tokens)) .Then(results1 => { @@ -89,7 +98,13 @@ public async Task> Redeem(string tokenId, if (token is not null) { - return EarthJson(TokenToApiResponse(token)); + 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 { @@ -97,7 +112,7 @@ public async Task> Redeem(string tokenId, } } - private static Token TokenToApiResponse(Tokens.Token token) + internal static Token TokenToApiResponse(Tokens.Token token) { Dictionary properties = []; switch (token) @@ -105,11 +120,30 @@ private static Token TokenToApiResponse(Tokens.Token token) case Tokens.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 { 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(), }; @@ -117,14 +151,34 @@ private static Token TokenToApiResponse(Tokens.Token token) { 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( - Enum.Parse(token.Type.ToString()), + 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/EarthApi/TutorialController.cs b/src/Solace.ApiServer/Controllers/EarthApi/TutorialController.cs new file mode 100644 index 00000000..d2a9e657 --- /dev/null +++ b/src/Solace.ApiServer/Controllers/EarthApi/TutorialController.cs @@ -0,0 +1,63 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Solace.ApiServer.Utils; +using Solace.Common; + +namespace Solace.ApiServer.Controllers.EarthApi; + +[Authorize] +[ApiVersion("1.1")] +[Route("1/api/v{version:apiVersion}")] +internal sealed class TutorialController : ControllerBase +{ + [HttpGet("player/tutorial")] + [HttpGet("player/tutorials")] + [HttpGet("player/oobe")] + [HttpGet("player/outofboxexperience")] + [HttpGet("tutorial")] + [HttpGet("tutorials")] + [HttpGet("oobe")] + [HttpGet("outofboxexperience")] + public IActionResult GetTutorialState() + => Content(Json.Serialize(new EarthApiResponse(new Dictionary + { + ["completed"] = new Dictionary + { + ["map_permission"] = true, + ["tappable"] = true, + ["adventure"] = true, + ["adventure_crystal_activation"] = true, + ["adventure_preview"] = true, + ["ar_placement"] = true, + ["ar_gameplay"] = true, + ["journal"] = true, + ["challenge"] = true, + ["challenges"] = true, + ["freedom"] = true + }, + ["available"] = Array.Empty() + })), "application/json"); + + [HttpPost("player/tutorial")] + [HttpPost("player/tutorials")] + [HttpPost("player/tutorial/{tutorialId}")] + [HttpPost("player/oobe")] + [HttpPost("player/oobe/{tutorialId}")] + [HttpPost("player/outofboxexperience")] + [HttpPost("player/outofboxexperience/{tutorialId}")] + [HttpPost("tutorial")] + [HttpPost("tutorials")] + [HttpPost("tutorial/{tutorialId}")] + [HttpPost("oobe")] + [HttpPost("oobe/{tutorialId}")] + [HttpPost("outofboxexperience")] + [HttpPost("outofboxexperience/{tutorialId}")] + public IActionResult CompleteTutorial(string? tutorialId = null) + => Content(Json.Serialize(new EarthApiResponse(new Dictionary + { + ["tutorialId"] = tutorialId, + ["completed"] = true, + ["updates"] = null + })), "application/json"); +} diff --git a/src/Solace.ApiServer/Controllers/PlayfabApi/ClientController.cs b/src/Solace.ApiServer/Controllers/PlayfabApi/ClientController.cs index 3bdb0e8b..0d9bc404 100644 --- a/src/Solace.ApiServer/Controllers/PlayfabApi/ClientController.cs +++ b/src/Solace.ApiServer/Controllers/PlayfabApi/ClientController.cs @@ -5,6 +5,10 @@ using Solace.ApiServer.Models.Playfab; using Solace.ApiServer.Utils; using Solace.Common.Utils; +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; @@ -126,30 +130,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 { @@ -160,6 +162,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 371f78a6..13c6afd4 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 38c34123..b44cbcf7 100644 --- a/src/Solace.ApiServer/Types/Common/Token.cs +++ b/src/Solace.ApiServer/Types/Common/Token.cs @@ -19,7 +19,15 @@ public enum Type [JsonStringEnumMemberName("redeemtappable")] TAPPABLE, [JsonStringEnumMemberName("item.unlocked")] - JOURNAL_ITEM_UNLOCKED + JOURNAL_ITEM_UNLOCKED, + [JsonStringEnumMemberName("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 } diff --git a/src/Solace.ApiServer/Utils/BuildplateInstanceRequestHandler.cs b/src/Solace.ApiServer/Utils/BuildplateInstanceRequestHandler.cs index b22e7d3e..60f314bf 100644 --- a/src/Solace.ApiServer/Utils/BuildplateInstanceRequestHandler.cs +++ b/src/Solace.ApiServer/Utils/BuildplateInstanceRequestHandler.cs @@ -184,6 +184,11 @@ public static async Task CreateAsync(EarthDB e 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 () => { @@ -273,7 +278,23 @@ string ServerDataBase64 EncounterBuildplates.EncounterBuildplate? encounterBuildplate = encounterBuildplates.GetEncounterBuildplate(encounterBuildplateId); 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); @@ -509,6 +530,7 @@ [.. Enumerable.Concat( break; case BuildplateInstancesManager.InstanceType.ENCOUNTER: + case BuildplateInstancesManager.InstanceType.PLAYER_ADVENTURE: { EarthDB.Results results = await new EarthDB.Query(true) .Get("inventory", playerConnectedRequest.Uuid, typeof(Inventory)) @@ -528,13 +550,25 @@ [.. Enumerable.Concat( { if (item.InstanceId is null) { - inventory.TakeItems(item.Uuid, item.Count); + 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 { - int wear = inventory.TakeItems(item.Uuid, [item.InstanceId])![0].Wear; + 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); } @@ -586,14 +620,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(); } EarthDB.Results results = await new EarthDB.Query(true) @@ -605,8 +639,14 @@ .. inventoryResponseNonStackableItems Journal journal = results1.Get("journal"); LinkedList unlockedJournalItems = []; - foreach (InventoryResponse.Item item in backpackContents.Items) + InventoryResponse.Item[] backpackItems = backpackContents.Items ?? []; + foreach (InventoryResponse.Item item in backpackItems) { + if (item is null || item.Count <= 0) + { + continue; + } + Catalog.ItemsCatalogR.Item? catalogItem = _catalog.ItemsCatalog.GetItem(item.Id); if (catalogItem is null) { @@ -642,10 +682,11 @@ .. inventoryResponseNonStackableItems } var hotbar = new Hotbar(); - for (int index = 0; index < 7; index++) + InventoryResponse.HotbarItem?[] backpackHotbar = backpackContents.Hotbar ?? []; + for (int index = 0; index < 7 && index < backpackHotbar.Length; index++) { - InventoryResponse.HotbarItem? hotbarItem = backpackContents.Hotbar[index]; - if (hotbarItem is not null) + 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); } @@ -700,6 +741,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), }; @@ -924,9 +966,10 @@ private async Task HandleInventorySetHotbar(string instanceId, InventorySe Inventory inventory = results1.Get("inventory"); var hotbar = new Hotbar(); + InventorySetHotbarMessage.Item[] requestedItems = inventorySetHotbarMessage.Items ?? []; for (int index = 0; index < hotbar.Items.Length; index++) { - InventorySetHotbarMessage.Item item = inventorySetHotbarMessage.Items[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; } diff --git a/src/Solace.ApiServer/Utils/BuildplateInstancesManager.cs b/src/Solace.ApiServer/Utils/BuildplateInstancesManager.cs index 9954714a..560a4f0c 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(string? playerId, string? encounterId, string 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 InstanceInfo? instanceInfo = _instances.GetOrDefault(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,7 +99,7 @@ public static async Task CreateAsync(EventBusClient } Log.Information("Did not find existing instance, starting new instance"); - string? instanceId = await _requestSender.RequestAsync("buildplates", "start", Json.Serialize(new StartRequest(playerId, encounterId, buildplateId, night, type, shutdownTime))); + string? instanceId = await SendStartRequestWithTimeoutAsync(playerId, encounterId, buildplateId, night, type, shutdownTime); if (instanceId is null) { Log.Error("Buildplate start request was rejected/ignored"); @@ -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(string 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; @@ -300,6 +347,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/ChallengeProgressVersion.cs b/src/Solace.ApiServer/Utils/ChallengeProgressVersion.cs new file mode 100644 index 00000000..2b7dbefe --- /dev/null +++ b/src/Solace.ApiServer/Utils/ChallengeProgressVersion.cs @@ -0,0 +1,56 @@ +namespace Solace.ApiServer.Utils; + +public sealed class ChallengeProgressVersion +{ + public long UpdatedAt { get; set; } + public string? DailyDateUtc { get; set; } + public string? ActiveSeasonId { get; set; } + public string? ActiveSeasonChallengeId { get; set; } + public int TappablesRedeemed { get; set; } + public Dictionary ObjectiveCounts { get; set; } = []; + public HashSet ClaimedChallengeIds { get; set; } = []; + public HashSet RemovedContinuousChallengeIds { get; set; } = []; + + public void EnsureDate(long timestamp) + { + string today = DateTimeOffset.FromUnixTimeMilliseconds(timestamp) + .UtcDateTime + .ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture); + + ObjectiveCounts ??= []; + ClaimedChallengeIds ??= []; + RemovedContinuousChallengeIds ??= []; + + if (DailyDateUtc == today) + { + return; + } + + DailyDateUtc = today; + TappablesRedeemed = 0; + ObjectiveCounts = []; + RemovedContinuousChallengeIds = []; + } + + public int RecordTappable(long timestamp) + { + EnsureDate(timestamp); + UpdatedAt = timestamp; + TappablesRedeemed++; + return TappablesRedeemed; + } + + public void AddObjectiveProgress(long timestamp, string objectiveId, int amount = 1) + { + EnsureDate(timestamp); + UpdatedAt = timestamp; + ObjectiveCounts ??= []; + ObjectiveCounts[objectiveId] = ObjectiveCounts.GetValueOrDefault(objectiveId) + amount; + } + + public int GetObjectiveProgress(string objectiveId) + { + ObjectiveCounts ??= []; + return ObjectiveCounts.GetValueOrDefault(objectiveId); + } +} diff --git a/src/Solace.ApiServer/Utils/EarthApiResponse.cs b/src/Solace.ApiServer/Utils/EarthApiResponse.cs index 7e28daba..69a4fd27 100644 --- a/src/Solace.ApiServer/Utils/EarthApiResponse.cs +++ b/src/Solace.ApiServer/Utils/EarthApiResponse.cs @@ -30,6 +30,10 @@ public sealed class UpdatesResponse { public Dictionary Map = []; + public UpdatesResponse() + { + } + public UpdatesResponse(EarthDB.Results results) { Dictionary updates = results.GetUpdates(); @@ -53,4 +57,4 @@ private void set(Dictionary updates, string name, 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 2dbec6be..9529519e 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> _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(string playerId, 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, playerId))); - 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) @@ -269,6 +519,19 @@ private void Prune(long currentTime) } _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 a152da7b..d965b921 100644 --- a/src/Solace.ApiServer/Utils/TileUtils.cs +++ b/src/Solace.ApiServer/Utils/TileUtils.cs @@ -9,55 +9,90 @@ namespace Solace.ApiServer.Utils; internal static class TileUtils { private static EarthDB db => Program.DB; + private static EventBusClient eventBus => Program.eventBus; + private static readonly byte[] EmptyTilePng = Convert.FromBase64String( + "iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAABOklEQVR4nO3SMQ0AAAwCoNm/9HI83BLIOQmtnpnZB4CjEwABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEgABEoAB1XQB3P+pKnEAAAAASUVORK5CYII="); private static RequestSender? _requestSender; + private static readonly SemaphoreSlim _requestSenderLock = new(1, 1); public static async Task TryWriteTile(int tileX, int tileY, Stream dest, CancellationToken cancellationToken) { - ulong dbPos = ToDbPos(tileX, tileY); - - var results = await new EarthDB.ObjectQuery(false) - .GetTile(dbPos) - .ExecuteAsync(db, cancellationToken); + if (await TryWriteRenderedTile(tileX, tileY, dest, cancellationToken)) + { + return true; + } - string? tileObjectId = results.GetTile(dbPos); + Log.Warning("Serving fallback tile {TileX},{TileY}", tileX, tileY); + await dest.WriteAsync(EmptyTilePng, cancellationToken); + return true; + } - await using var objectStoreClient = await Program.GetObjectStoreClient(); + private static async Task TryWriteRenderedTile(int tileX, int tileY, Stream dest, CancellationToken cancellationToken) + { + string? response; - if (!string.IsNullOrEmpty(tileObjectId)) + await _requestSenderLock.WaitAsync(cancellationToken); + try { - return await TryWriteTileFromObject(tileObjectId, dest, objectStoreClient, cancellationToken); + _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; } - - Log.Information("Rendering tile"); - _requestSender ??= await Program.eventBus.AddRequestSenderAsync(); - string? tilePng64 = await _requestSender.RequestAsync("tile", "renderTile", Json.Serialize(new RenderTileRequest(tileX, tileY, 16))); - - if (tilePng64 is null) + catch (Exception ex) when (ex is EventBusClientException or InvalidOperationException) { - Log.Warning("Could not get tile (tile renderer did not respond to event bus request)"); + 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); - - tileObjectId = await objectStoreClient.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; } - Log.Debug($"Stored tile ({tileX}, {tileY}) to object store under id {tileObjectId}"); - - _ = await new EarthDB.ObjectQuery(true) - .UpdateTile(dbPos, tileObjectId) - .ExecuteAsync(db, cancellationToken); + try + { + 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 263c4800..ce9d1238 100644 --- a/src/Solace.ApiServer/Utils/TokenUtils.cs +++ b/src/Solace.ApiServer/Utils/TokenUtils.cs @@ -1,4 +1,6 @@ -using Solace.Common.Utils; +using Serilog; +using Solace.Common.Utils; +using Solace.ApiServer.Controllers.EarthApi; using Solace.DB; using Solace.DB.Models.Player; @@ -6,6 +8,8 @@ namespace Solace.ApiServer.Utils; public static class TokenUtils { + private const string CommonAdventureCrystalId = "4f16a053-4929-263a-c91a-29663e29df76"; + public static EarthDB.Query AddToken(string playerId, Tokens.Token token) { var getQuery = new EarthDB.Query(true); @@ -23,6 +27,86 @@ public static EarthDB.Query AddToken(string playerId, Tokens.Token token) return getQuery; } + 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 EarthDB.Query DoActionsOnRedeemedToken(Tokens.Token token, string playerId, long currentTime, StaticData.StaticData staticData) { @@ -66,6 +150,63 @@ public static EarthDB.Query DoActionsOnRedeemedToken(Tokens.Token token, string }, false); } + break; + 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: + { + 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; } @@ -73,4 +214,35 @@ public static EarthDB.Query DoActionsOnRedeemedToken(Tokens.Token token, string return getQuery; } + + 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 b54ffe47..e3dd8425 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 9aab4195..b2bb0e32 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 fdd25e3f..1e2e6b08 100644 --- a/src/Solace.Common/ConsoleProcess.cs +++ b/src/Solace.Common/ConsoleProcess.cs @@ -268,7 +268,7 @@ private static bool IsXTerminalEmulatorPresent() } } - 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); while (!cts.IsCancellationRequested) @@ -286,8 +286,19 @@ private static bool IsXTerminalEmulatorPresent() catch (IOException) { } + catch (OperationCanceledException) + { + break; + } - await Task.Delay(100, cts.Token); + try + { + await Task.Delay(100, cts.Token); + } + catch (OperationCanceledException) + { + break; + } } if (File.Exists(pidFile)) diff --git a/src/Solace.DB/Models/Player/Boosts.cs b/src/Solace.DB/Models/Player/Boosts.cs index ab36cf7c..74cffba8 100644 --- a/src/Solace.DB/Models/Player/Boosts.cs +++ b/src/Solace.DB/Models/Player/Boosts.cs @@ -3,15 +3,22 @@ public sealed class Boosts { public ActiveBoost?[] ActiveBoosts { get; init; } + public ActiveMiniFig?[] ActiveMiniFigs { get; init; } + public Dictionary MiniFigRecords { get; init; } 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 ActiveMiniFig? GetMiniFig(string instanceId) + => ActiveMiniFigs.FirstOrDefault(activeMiniFig => activeMiniFig is not null && activeMiniFig.InstanceId == instanceId); + public ActiveBoost[] Prune(long currentTime) { LinkedList prunedBoosts = []; @@ -28,10 +35,41 @@ public ActiveBoost[] Prune(long currentTime) return [.. prunedBoosts]; } + public ActiveMiniFig[] PruneMiniFigs(long currentTime) + { + LinkedList prunedMiniFigs = []; + for (int index = 0; index < ActiveMiniFigs.Length; index++) + { + ActiveMiniFig? activeMiniFig = ActiveMiniFigs[index]; + if (activeMiniFig is not null && activeMiniFig.StartTime + activeMiniFig.Duration < currentTime) + { + ActiveMiniFigs[index] = null; + prunedMiniFigs.AddLast(activeMiniFig); + } + } + + return [.. prunedMiniFigs]; + } + 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 05df8098..ff433d59 100644 --- a/src/Solace.DB/Models/Player/Tokens.cs +++ b/src/Solace.DB/Models/Player/Tokens.cs @@ -48,6 +48,10 @@ public void AddToken(string id, Token token) [JsonPolymorphic(TypeDiscriminatorPropertyName = "type")] [JsonDerivedType(typeof(LevelUpToken), "LEVEL_UP")] [JsonDerivedType(typeof(JournalItemUnlockedToken), "JOURNAL_ITEM_UNLOCKED")] + [JsonDerivedType(typeof(DailyLoginToken), "DAILY_LOGIN")] + [JsonDerivedType(typeof(OobeAdventureCrystalToken), "OOBE_ADVENTURE_CRYSTAL")] + [JsonDerivedType(typeof(ChallengeProgressToken), "CHALLENGE_PROGRESS")] + [JsonDerivedType(typeof(ChallengeCompletedToken), "CHALLENGE_COMPLETED")] public abstract class Token { [JsonIgnore] @@ -63,7 +67,11 @@ public enum TypeE { #pragma warning disable CA1707 // Identifiers should not contain underscores LEVEL_UP, - JOURNAL_ITEM_UNLOCKED + JOURNAL_ITEM_UNLOCKED, + DAILY_LOGIN, + OOBE_ADVENTURE_CRYSTAL, + CHALLENGE_PROGRESS, + CHALLENGE_COMPLETED #pragma warning restore CA1707 // Identifiers should not contain underscores } } @@ -91,4 +99,56 @@ public JournalItemUnlockedToken(string itemId) ItemId = 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/AdventureMapIcons.cs b/src/Solace.StaticData/AdventureMapIcons.cs new file mode 100644 index 00000000..1e937d89 --- /dev/null +++ b/src/Solace.StaticData/AdventureMapIcons.cs @@ -0,0 +1,20 @@ +namespace Solace.StaticData; + +public static class AdventureMapIcons +{ + public static string ToClientMapIcon(string icon, string rarity) + { + if (icon.StartsWith("genoa:adventure_generic_map", StringComparison.OrdinalIgnoreCase)) + { + return icon; + } + + return rarity.ToUpperInvariant() switch + { + "COMMON" => "genoa:adventure_generic_map", + "UNCOMMON" or "RARE" => "genoa:adventure_generic_map_b", + "EPIC" or "LEGENDARY" or "OOBE" => "genoa:adventure_generic_map_c", + _ => "genoa:adventure_generic_map" + }; + } +} diff --git a/src/Solace.StaticData/AdventuresConfig.cs b/src/Solace.StaticData/AdventuresConfig.cs new file mode 100644 index 00000000..ceff11bc --- /dev/null +++ b/src/Solace.StaticData/AdventuresConfig.cs @@ -0,0 +1,162 @@ +using System.Collections.Immutable; +using System.Diagnostics; +using System.Text.Json.Serialization; +using Solace.Common; + +namespace Solace.StaticData; + +public sealed class AdventuresConfig +{ + private static readonly string[] DefaultFolders = ["common", "uncommon", "rare", "epic", "legendary", "oobe"]; + + public readonly AdventureSpawnConfig SpawnConfig; + private readonly Dictionary> _buildplatesByFolder = []; + + internal AdventuresConfig(string dir) + { + try + { + SpawnConfig = LoadSpawnConfig(dir); + + HashSet folders = [.. DefaultFolders]; + foreach (AdventureCrystalType crystalType in SpawnConfig.CrystalTypes) + { + folders.Add(crystalType.Folder); + } + + foreach (string folder in folders) + { + string buildplatesFile = Path.Combine(dir, folder, $"{folder}-buildplates.json"); + if (!File.Exists(buildplatesFile)) + { + continue; + } + + using var stream = File.OpenRead(buildplatesFile); + AdventureBuildplatesFile? buildplates = Json.Deserialize(stream); + Debug.Assert(buildplates is not null); + + _buildplatesByFolder[folder] = [.. buildplates.Buildplates + .Where(buildplate => !string.IsNullOrWhiteSpace(buildplate.TemplateId)) + .Select(buildplate => buildplate with + { + TemplateId = Path.GetFileNameWithoutExtension(buildplate.TemplateId), + Weight = int.Max(0, buildplate.Weight) + }) + .Where(buildplate => buildplate.Weight > 0)]; + } + } + catch (Exception exception) + { + throw new StaticDataException(null, exception); + } + } + + public bool CanSpawn => SpawnConfig.CrystalTypes.Length > 0 && SpawnConfig.MaxCount > 0; + + public AdventureCrystalType? PickCrystalType(Random random) + => PickWeighted(SpawnConfig.CrystalTypes, item => item.PickWeight, random); + + public string? PickTemplateForFolder(string folder, Random random) + { + if (!_buildplatesByFolder.TryGetValue(folder, out ImmutableArray buildplates) || buildplates.Length == 0) + { + return null; + } + + return PickWeighted(buildplates, buildplate => buildplate.Weight, random)?.TemplateId; + } + + public string? TryPickTemplateForCrystalItem(string itemName, Random random) + { + string normalizedName = itemName.StartsWith("minecraft:", StringComparison.OrdinalIgnoreCase) + ? itemName["minecraft:".Length..] + : itemName; + + const string prefix = "adventure_crystal_"; + if (!normalizedName.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + string folder = normalizedName[prefix.Length..]; + return PickTemplateForFolder(folder, random); + } + + private static AdventureSpawnConfig LoadSpawnConfig(string dir) + { + string spawnConfigFile = Path.Combine(dir, "adventures-spawn.json"); + if (!File.Exists(spawnConfigFile)) + { + return AdventureSpawnConfig.Disabled; + } + + using var stream = File.OpenRead(spawnConfigFile); + AdventureSpawnConfig? spawnConfig = Json.Deserialize(stream); + Debug.Assert(spawnConfig is not null); + return spawnConfig; + } + + private static T? PickWeighted(IReadOnlyList items, Func weightSelector, Random random) + { + int totalWeight = items.Sum(weightSelector); + if (totalWeight <= 0) + { + return default; + } + + int roll = random.Next(0, totalWeight); + foreach (T item in items) + { + roll -= weightSelector(item); + if (roll < 0) + { + return item; + } + } + + return items[^1]; + } + + public sealed record AdventureSpawnConfig( + int MinCount, + int MaxCount, + long MinSpawnDelayMs, + long MaxSpawnDelayMs, + long MinDurationMs, + long MaxDurationMs, + int ChancePerSpawnCycle, + AdventureCrystalType[] CrystalTypes + ) + { + public static AdventureSpawnConfig Disabled => new(0, 0, 0, 0, 0, 0, 0, []); + } + + public sealed record AdventureCrystalType( + string Folder, + string Icon, + AdventureCrystalType.RarityE Rarity, + int PickWeight + ) + { + [JsonConverter(typeof(JsonStringEnumConverter))] + public enum RarityE + { + COMMON, + UNCOMMON, + RARE, + EPIC, + LEGENDARY, + OOBE + } + } + + private sealed record AdventureBuildplatesFile( + AdventureBuildplate[] Buildplates + ); + + private sealed record AdventureBuildplate( + string TemplateId, + int Weight + ); +} diff --git a/src/Solace.StaticData/Catalog.cs b/src/Solace.StaticData/Catalog.cs index dd16bf24..4cdde973 100644 --- a/src/Solace.StaticData/Catalog.cs +++ b/src/Solace.StaticData/Catalog.cs @@ -503,9 +503,13 @@ string ReturnItemId public sealed class NFCBoostsCatalogR { private sealed record NFCBoostsCatalogFile( - // TODO + MiniFig[] MiniFigs ); + public readonly ImmutableArray MiniFigs; + + private readonly Dictionary miniFigsById = []; + internal NFCBoostsCatalogR(string file) { NFCBoostsCatalogFile? nfcBoostsCatalogFile; @@ -514,12 +518,58 @@ internal NFCBoostsCatalogR(string file) nfcBoostsCatalogFile = Json.Deserialize(stream); } - // TODO + 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 BoostInfo - { + public MiniFig? GetMiniFig(string id) + => miniFigsById.GetValueOrDefault(id); - } + public sealed record MiniFig( + string Id, + BoostMetadataR BoostMetadata, + string Name, + bool Deprecated, + string ToolsVersion, + RewardsR Rewards + ); + + public sealed record RewardsR( + int? Rubies, + int? ExperiencePoints + ); + + public sealed record BoostMetadataR( + string Name, + string Attribute, + bool CanBeDeactivated, + bool CanBeRemoved, + string? ActiveDuration, + bool Additive, + int? Level, + EffectR[] Effects, + string? Scenario, + string? Cooldown + ); + + public sealed record EffectR( + string Type, + string? Duration, + double? Value, + string? Unit, + string Targets, + string[] Items, + string[] ItemScenarios, + string Activation, + string? ModifiesType + ); } } 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 81e0bd41..11ef866f 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 Date: Mon, 1 Jun 2026 13:30:00 +0200 Subject: [PATCH 02/10] Fix animal tappables advancing hostile mob challenge --- src/Solace.ApiServer/Controllers/EarthApi/TappablesController.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Solace.ApiServer/Controllers/EarthApi/TappablesController.cs b/src/Solace.ApiServer/Controllers/EarthApi/TappablesController.cs index 9559e48a..3e356ec2 100644 --- a/src/Solace.ApiServer/Controllers/EarthApi/TappablesController.cs +++ b/src/Solace.ApiServer/Controllers/EarthApi/TappablesController.cs @@ -403,7 +403,6 @@ private static void AddDailyObjectiveProgress(ChallengeProgressVersion challenge if (isMob) { - challengeProgress.AddObjectiveProgress(requestStartedOn, BestDefenseReferenceId); challengeProgress.AddObjectiveProgress(requestStartedOn, MobReferenceId); challengeProgress.AddObjectiveProgress(requestStartedOn, ZooKeeperReferenceId); challengeProgress.AddObjectiveProgress(requestStartedOn, PettingZooReferenceId); From 799737f77e1d1806477a1262ec807df85dc7c0ea Mon Sep 17 00:00:00 2001 From: LNLenost Date: Mon, 1 Jun 2026 14:25:37 +0200 Subject: [PATCH 03/10] Fix completed challenge visibility and NFC minifig decoding --- .../Controllers/EarthApi/BoostsController.cs | 70 +++++++++++++++++-- .../EarthApi/ChallengesController.cs | 52 ++++++++------ .../EarthApi/TappablesController.cs | 4 ++ 3 files changed, 100 insertions(+), 26 deletions(-) diff --git a/src/Solace.ApiServer/Controllers/EarthApi/BoostsController.cs b/src/Solace.ApiServer/Controllers/EarthApi/BoostsController.cs index 4276b9a0..232595a3 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; @@ -216,16 +217,18 @@ public async Task> ActivateMiniFig(string return TypedResults.BadRequest(); } - Catalog.NFCBoostsCatalogR.MiniFig? miniFig = catalog.NfcBoostsCatalog.GetMiniFig(productId); - string resolvedProductId = productId; + 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 = catalog.NfcBoostsCatalog.GetMiniFig(id); + Catalog.NFCBoostsCatalogR.MiniFig? swappedMiniFig = normalizedTagProductId is null ? null : catalog.NfcBoostsCatalog.GetMiniFig(normalizedTagProductId); if (swappedMiniFig is not null) { miniFig = swappedMiniFig; - resolvedProductId = id; + resolvedProductId = normalizedTagProductId!; tagId = productId; } } @@ -541,6 +544,65 @@ private static long GetMiniFigDuration(Catalog.NFCBoostsCatalogR.MiniFig miniFig : 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, diff --git a/src/Solace.ApiServer/Controllers/EarthApi/ChallengesController.cs b/src/Solace.ApiServer/Controllers/EarthApi/ChallengesController.cs index 8372d71f..79cbdad1 100644 --- a/src/Solace.ApiServer/Controllers/EarthApi/ChallengesController.cs +++ b/src/Solace.ApiServer/Controllers/EarthApi/ChallengesController.cs @@ -164,26 +164,29 @@ public async Task> Get(CancellationToken string activeSeasonChallengeId = SelectActiveSeasonChallengeId(progress, progress.ActiveSeasonChallengeId); var challenges = BuildSeasonChallenges(seasonEndTime, progress, activeSeasonChallengeId); - 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() - ); + 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++) { @@ -192,6 +195,11 @@ public async Task> Get(CancellationToken int currentCount = Math.Min(progress.GetObjectiveProgress(challenge.ReferenceId), threshold); bool isComplete = currentCount >= threshold; bool isClaimed = progress.ClaimedChallengeIds?.Contains(challenge.Key) == true; + if (isComplete || isClaimed) + { + continue; + } + challenges[challenge.Key] = new ChallengeRecord( challenge.ReferenceId, null, @@ -202,8 +210,8 @@ public async Task> Get(CancellationToken Rarity.COMMON, index + 1, dailyEndTime, - isClaimed ? "Claimed" : isComplete ? "Completed" : "Active", - isComplete, + "Active", + false, currentCount * 100 / threshold, currentCount, threshold, diff --git a/src/Solace.ApiServer/Controllers/EarthApi/TappablesController.cs b/src/Solace.ApiServer/Controllers/EarthApi/TappablesController.cs index 3e356ec2..f3d90da2 100644 --- a/src/Solace.ApiServer/Controllers/EarthApi/TappablesController.cs +++ b/src/Solace.ApiServer/Controllers/EarthApi/TappablesController.cs @@ -506,6 +506,8 @@ private static void AddCompletedDailyChallengeRewards(EarthDB.Query query, strin if (tokenClaims.RedeemedChallengeRewardKeys.Add(rewardKey)) { rewards.AddExperiencePoints(10); + challengeProgress.ClaimedChallengeIds ??= []; + challengeProgress.ClaimedChallengeIds.Add(challenge.Key); shouldReward = true; } } @@ -514,6 +516,8 @@ private static void AddCompletedDailyChallengeRewards(EarthDB.Query query, strin if (completedDailyChallenges >= DailyChallengeCount && tokenClaims.RedeemedChallengeRewardKeys.Add(dailyGroupRewardKey)) { rewards.AddExperiencePoints(25).AddItem(CommonAdventureCrystalId, 1); + challengeProgress.ClaimedChallengeIds ??= []; + challengeProgress.ClaimedChallengeIds.Add(DailyGroupId); shouldReward = true; } From 7a431b0dca913580dcf9ef406080ad6540892b06 Mon Sep 17 00:00:00 2001 From: LNLenost Date: Mon, 1 Jun 2026 17:30:56 +0200 Subject: [PATCH 04/10] Fix adventure join responses --- .../EarthApi/BuildplatesController.cs | 53 ++++++++++++------- .../EarthApi/TappablesController.cs | 2 + 2 files changed, 37 insertions(+), 18 deletions(-) diff --git a/src/Solace.ApiServer/Controllers/EarthApi/BuildplatesController.cs b/src/Solace.ApiServer/Controllers/EarthApi/BuildplatesController.cs index 68a97ffd..8cf87137 100644 --- a/src/Solace.ApiServer/Controllers/EarthApi/BuildplatesController.cs +++ b/src/Solace.ApiServer/Controllers/EarthApi/BuildplatesController.cs @@ -884,28 +884,15 @@ private enum Source break; case Source.ENCOUNTER: { - EncounterBuildplates.EncounterBuildplate? encounterBuildplate; - - try - { - EarthDB.Results results = await new EarthDB.Query(false) - .Get("encounterBuildplates", "", typeof(EncounterBuildplates)) - .ExecuteAsync(earthDB, cancellationToken); - encounterBuildplate = results.Get("encounterBuildplates").GetEncounterBuildplate(instanceInfo.BuildplateId); - } - catch (EarthDB.DatabaseException exception) - { - throw new ServerErrorException(exception); - } - - if (encounterBuildplate is null) + BuildplateGeometry? geometry = await GetEncounterBuildplateGeometry(instanceInfo.BuildplateId, cancellationToken); + if (geometry is null) { return null; } - size = encounterBuildplate.Size; - offset = encounterBuildplate.Offset; - scale = encounterBuildplate.Scale; + size = geometry.Size; + offset = geometry.Offset; + scale = geometry.Scale; } break; @@ -948,6 +935,36 @@ private enum Source ); } + private sealed record BuildplateGeometry(int Size, int Offset, int Scale); + + private static async Task GetEncounterBuildplateGeometry(string buildplateId, CancellationToken cancellationToken) + { + try + { + EarthDB.Results results = await new EarthDB.Query(false) + .Get("encounterBuildplates", "", typeof(EncounterBuildplates)) + .ExecuteAsync(earthDB, cancellationToken); + + 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( bool FullSize ); diff --git a/src/Solace.ApiServer/Controllers/EarthApi/TappablesController.cs b/src/Solace.ApiServer/Controllers/EarthApi/TappablesController.cs index f3d90da2..f776bb1a 100644 --- a/src/Solace.ApiServer/Controllers/EarthApi/TappablesController.cs +++ b/src/Solace.ApiServer/Controllers/EarthApi/TappablesController.cs @@ -345,6 +345,8 @@ public async Task> RedeemTappable(string } [HttpPost("multiplayer/encounters/state")] + [HttpPost("multiplayer/adventures/state")] + [HttpPost("multiplayer/player/adventures/state")] public async Task> EncountersState(CancellationToken cancellationToken) { var requestedIds = await Request.Body.AsJsonAsync>(cancellationToken); From 1306f03db9483e81ca8f4f32bb41333979085bd3 Mon Sep 17 00:00:00 2001 From: LNLenost Date: Mon, 1 Jun 2026 18:02:32 +0200 Subject: [PATCH 05/10] Fix adventure port reuse and NFC product type --- .../Controllers/EarthApi/CatalogController.cs | 2 +- src/Solace.Buildplate/Launcher/Starter.cs | 21 +++++++++++++++++-- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/Solace.ApiServer/Controllers/EarthApi/CatalogController.cs b/src/Solace.ApiServer/Controllers/EarthApi/CatalogController.cs index f815ddfc..9afedf6b 100644 --- a/src/Solace.ApiServer/Controllers/EarthApi/CatalogController.cs +++ b/src/Solace.ApiServer/Controllers/EarthApi/CatalogController.cs @@ -401,7 +401,7 @@ private static NFCBoost[] MakeNFCBoostsCatalogApiResponse(Catalog catalog) => [.. catalog.NfcBoostsCatalog.MiniFigs.Select(miniFig => new NFCBoost( miniFig.Id, miniFig.Name, - "MiniFig", + "NfcMiniFig", new Types.Common.Rewards( miniFig.Rewards.Rubies, miniFig.Rewards.ExperiencePoints, diff --git a/src/Solace.Buildplate/Launcher/Starter.cs b/src/Solace.Buildplate/Launcher/Starter.cs index 63e2796c..ca8107ee 100644 --- a/src/Solace.Buildplate/Launcher/Starter.cs +++ b/src/Solace.Buildplate/Launcher/Starter.cs @@ -1,4 +1,6 @@ using System.Diagnostics; +using System.Net; +using System.Net.Sockets; using Serilog; using Solace.Buildplate.Connector.Model; using Solace.Common.Utils; @@ -67,7 +69,7 @@ private static int FindPort(HashSet portsInUse, int basePort) lock (portsInUse) { int port = basePort; - while (portsInUse.Contains(port)) + while (portsInUse.Contains(port) || !CanBindPort(port)) { port++; } @@ -77,6 +79,21 @@ private static int FindPort(HashSet portsInUse, int basePort) } } + private static bool CanBindPort(int port) + { + try + { + using var listener = new TcpListener(IPAddress.Any, port); + listener.Start(); + using var udpClient = new UdpClient(port); + return true; + } + catch (SocketException) + { + return false; + } + } + private static void ReleasePort(HashSet portsInUse, int port) { lock (portsInUse) @@ -100,4 +117,4 @@ private static void ReleasePort(HashSet portsInUse, int port) Log.Debug($"Created instance base directory {file.FullName}"); return file; } -} \ No newline at end of file +} From e7bb18bcbd6de41c89826ee98911085feb18156e Mon Sep 17 00:00:00 2001 From: LNLenost Date: Mon, 1 Jun 2026 23:47:32 +0200 Subject: [PATCH 06/10] Address PR feedback and product info lookup --- .../Controllers/EarthApi/CatalogController.cs | 106 ++++++++++++++++++ .../EarthApi/ChallengeActionsController.cs | 34 +++--- .../EarthApi/DailyGoodiesController.cs | 18 --- .../Controllers/EarthApi/SeasonsController.cs | 22 ++-- .../Controllers/EarthApi/SummaryController.cs | 11 +- .../EarthApi/TutorialController.cs | 19 ++-- 6 files changed, 148 insertions(+), 62 deletions(-) diff --git a/src/Solace.ApiServer/Controllers/EarthApi/CatalogController.cs b/src/Solace.ApiServer/Controllers/EarthApi/CatalogController.cs index 9afedf6b..48cb4357 100644 --- a/src/Solace.ApiServer/Controllers/EarthApi/CatalogController.cs +++ b/src/Solace.ApiServer/Controllers/EarthApi/CatalogController.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; using System.Diagnostics; +using System.Text.Json; using Solace.ApiServer.Types.Catalog; using Solace.ApiServer.Utils; using Solace.StaticData; @@ -42,6 +43,33 @@ public ContentHttpResult GetJournalCatalog() public ContentHttpResult GetNFCBoostsCatalog() => EarthJson(MakeNFCBoostsCatalogApiResponse(catalog)); + [HttpGet("products/getProductInfo")] + [HttpPost("products/getProductInfo")] + public async Task GetProductInfo(CancellationToken cancellationToken) + { + HashSet requestedProductIds = ProductIdsFromQuery(); + if (requestedProductIds.Count == 0 && Request.ContentLength is not null and not 0) + { + requestedProductIds = await ReadRequestedProductIdsAsync(Request.Body, cancellationToken); + } + + NFCBoost[] products = MakeNFCBoostsCatalogApiResponse(catalog); + NFCBoost[] matchingProducts = requestedProductIds.Count == 0 + ? products + : [.. products.Where(product => requestedProductIds.Contains(product.Id))]; + string[] invalidProductIds = requestedProductIds.Count == 0 + ? [] + : [.. requestedProductIds.Except(matchingProducts.Select(product => product.Id))]; + + return EarthJson(new Dictionary + { + ["products"] = matchingProducts, + ["productInfos"] = matchingProducts, + ["recentlyViewedProductIds"] = matchingProducts.Select(product => product.Id).ToArray(), + ["invalidProductIds"] = invalidProductIds + }); + } + // TODO: cache these? private static ItemsCatalog MakeItemsCatalogApiResponse(Catalog catalog) { @@ -438,4 +466,82 @@ private static NFCBoost[] MakeNFCBoostsCatalogApiResponse(Catalog catalog) miniFig.Deprecated, miniFig.ToolsVersion ))]; + + private HashSet ProductIdsFromQuery() + { + var productIds = new HashSet(StringComparer.Ordinal); + foreach (string key in new[] { "productId", "id", "productIds", "recentlyViewedProductIds", "ids" }) + { + foreach (string? value in Request.Query[key]) + { + if (value is null) + { + continue; + } + + foreach (string productId in value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + productIds.Add(productId); + } + } + } + + return productIds; + } + + private static async Task> ReadRequestedProductIdsAsync(Stream body, CancellationToken cancellationToken) + { + var productIds = new HashSet(StringComparer.Ordinal); + + try + { + using JsonDocument document = await JsonDocument.ParseAsync(body, cancellationToken: cancellationToken); + AddProductIds(document.RootElement, productIds); + } + catch (JsonException) + { + } + + return productIds; + } + + private static void AddProductIds(JsonElement element, HashSet productIds) + { + switch (element.ValueKind) + { + case JsonValueKind.Object: + foreach (JsonProperty property in element.EnumerateObject()) + { + if (property.Name.Equals("productId", StringComparison.OrdinalIgnoreCase) + || property.Name.Equals("id", StringComparison.OrdinalIgnoreCase) + || property.Name.Equals("productIds", StringComparison.OrdinalIgnoreCase) + || property.Name.Equals("recentlyViewedProductIds", StringComparison.OrdinalIgnoreCase) + || property.Name.Equals("ids", StringComparison.OrdinalIgnoreCase)) + { + AddProductIds(property.Value, productIds); + } + else if (property.Value.ValueKind is JsonValueKind.Object or JsonValueKind.Array) + { + AddProductIds(property.Value, productIds); + } + } + + break; + case JsonValueKind.Array: + foreach (JsonElement item in element.EnumerateArray()) + { + AddProductIds(item, productIds); + } + + break; + case JsonValueKind.String: + string? productId = element.GetString(); + if (!string.IsNullOrWhiteSpace(productId)) + { + productIds.Add(productId); + } + + break; + } + } } diff --git a/src/Solace.ApiServer/Controllers/EarthApi/ChallengeActionsController.cs b/src/Solace.ApiServer/Controllers/EarthApi/ChallengeActionsController.cs index 4d3ce6f5..e550a7dc 100644 --- a/src/Solace.ApiServer/Controllers/EarthApi/ChallengeActionsController.cs +++ b/src/Solace.ApiServer/Controllers/EarthApi/ChallengeActionsController.cs @@ -1,9 +1,9 @@ -using Asp.Versioning; +using Asp.Versioning; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; using Solace.DB; using Solace.ApiServer.Utils; -using Solace.Common; using Solace.Common.Utils; using System.Security.Claims; using ApiRewards = Solace.ApiServer.Types.Common.Rewards; @@ -14,16 +14,16 @@ namespace Solace.ApiServer.Controllers.EarthApi; [Authorize] [ApiVersion("1.1")] [Route("1/api/v{version:apiVersion}/challenges")] -internal sealed class ChallengeActionsController : ControllerBase +internal sealed class ChallengeActionsController : SolaceControllerBase { [HttpPost("{challengeId}/modifyState")] [HttpPut("{challengeId}/modifyState")] - public async Task ModifyState(string challengeId, CancellationToken cancellationToken) + public async Task> ModifyState(string challengeId, CancellationToken cancellationToken) { string? playerId = User.FindFirstValue(ClaimTypes.NameIdentifier); if (string.IsNullOrWhiteSpace(playerId)) { - return BadRequest(); + return TypedResults.BadRequest(); } long now = HttpContext.GetTimestamp(); @@ -58,39 +58,39 @@ public async Task ModifyState(string challengeId, CancellationTok var updates = new EarthApiResponse.UpdatesResponse(results); updates.Map["challenges"] = (int)(now / 1000); - return Content(Json.Serialize(new EarthApiResponse(new Dictionary + return EarthJson(new Dictionary { ["challengeId"] = challengeId, ["state"] = "Claimed", ["rewards"] = apiRewards ?? rewards.ToApiResponse(), ["updates"] = new Dictionary() - }, updates)), "application/json"); + }, updates); } [HttpPost("timed/generate")] [HttpPut("timed/generate")] - public IActionResult GenerateTimedChallenges() - => Content(Json.Serialize(new EarthApiResponse(new Dictionary + public ContentHttpResult GenerateTimedChallenges() + => EarthJson(new Dictionary { ["updates"] = new Dictionary() - })), "application/json"); + }); [HttpPost("reset")] [HttpPut("reset")] - public IActionResult ResetChallenges() - => Content(Json.Serialize(new EarthApiResponse(new Dictionary + public ContentHttpResult ResetChallenges() + => EarthJson(new Dictionary { ["updates"] = new Dictionary() - })), "application/json"); + }); [HttpPost("continuous/{id}/remove")] [HttpDelete("continuous/{id}/remove")] - public async Task RemoveContinuousChallenge(string id, CancellationToken cancellationToken) + public async Task> RemoveContinuousChallenge(string id, CancellationToken cancellationToken) { string? playerId = User.FindFirstValue(ClaimTypes.NameIdentifier); if (string.IsNullOrWhiteSpace(playerId)) { - return BadRequest(); + return TypedResults.BadRequest(); } long now = HttpContext.GetTimestamp(); @@ -112,11 +112,11 @@ public async Task RemoveContinuousChallenge(string id, Cancellati var updates = new EarthApiResponse.UpdatesResponse(results); updates.Map["challenges"] = (int)(now / 1000); - return Content(Json.Serialize(new EarthApiResponse(new Dictionary + return EarthJson(new Dictionary { ["challengeId"] = id, ["updates"] = new Dictionary() - }, updates)), "application/json"); + }, updates); } private static RedeemRewards ToRedeemRewards(ApiRewards? rewards) diff --git a/src/Solace.ApiServer/Controllers/EarthApi/DailyGoodiesController.cs b/src/Solace.ApiServer/Controllers/EarthApi/DailyGoodiesController.cs index c563d5bc..733d3637 100644 --- a/src/Solace.ApiServer/Controllers/EarthApi/DailyGoodiesController.cs +++ b/src/Solace.ApiServer/Controllers/EarthApi/DailyGoodiesController.cs @@ -63,29 +63,11 @@ public async Task> Get(CancellationToken [HttpPost("daily-login")] [HttpPost("dailyrewards")] [HttpPost("player/dailygoodies/claim")] - [HttpPost("player/daily-goodies/claim")] - [HttpPost("player/daily-login/claim")] [HttpPost("player/dailyrewards/claim")] - [HttpPost("dailygoodies/claim")] - [HttpPost("daily-goodies/claim")] - [HttpPost("daily-login/claim")] - [HttpPost("dailyrewards/claim")] [HttpPost("player/dailygoodies/collect")] - [HttpPost("player/daily-goodies/collect")] - [HttpPost("player/daily-login/collect")] [HttpPost("player/dailyrewards/collect")] - [HttpPost("dailygoodies/collect")] - [HttpPost("daily-goodies/collect")] - [HttpPost("daily-login/collect")] - [HttpPost("dailyrewards/collect")] [HttpPost("player/dailygoodies/redeem")] - [HttpPost("player/daily-goodies/redeem")] - [HttpPost("player/daily-login/redeem")] [HttpPost("player/dailyrewards/redeem")] - [HttpPost("dailygoodies/redeem")] - [HttpPost("daily-goodies/redeem")] - [HttpPost("daily-login/redeem")] - [HttpPost("dailyrewards/redeem")] public async Task> Claim(CancellationToken cancellationToken) { if (!TryGetPlayerId(out string playerId)) diff --git a/src/Solace.ApiServer/Controllers/EarthApi/SeasonsController.cs b/src/Solace.ApiServer/Controllers/EarthApi/SeasonsController.cs index f6365376..b1540b10 100644 --- a/src/Solace.ApiServer/Controllers/EarthApi/SeasonsController.cs +++ b/src/Solace.ApiServer/Controllers/EarthApi/SeasonsController.cs @@ -4,7 +4,6 @@ using Microsoft.AspNetCore.Http.HttpResults; using System.Security.Claims; using Solace.ApiServer.Utils; -using Solace.Common; using Solace.Common.Utils; using Solace.DB; @@ -13,19 +12,20 @@ namespace Solace.ApiServer.Controllers.EarthApi; [Authorize] [ApiVersion("1.1")] [Route("1/api/v{version:apiVersion}")] -internal sealed class SeasonsController : ControllerBase +internal sealed class SeasonsController : SolaceControllerBase { [HttpGet("player/season")] [HttpGet("player/seasons")] [HttpGet("player/seasonpass")] [HttpGet("season")] [HttpGet("seasons")] - public IActionResult GetSeason() + public ContentHttpResult GetSeason() { long now = HttpContext.GetTimestamp(); - long endsAt = new DateTimeOffset(DateTimeOffset.FromUnixTimeMilliseconds(now).UtcDateTime.Date.AddDays(30)).ToUnixTimeMilliseconds(); + DateTime endDate = DateTimeOffset.FromUnixTimeMilliseconds(now).UtcDateTime.Date.AddDays(30); + long endsAt = new DateTimeOffset(endDate, TimeSpan.Zero).ToUnixTimeMilliseconds(); - return Content(Json.Serialize(new EarthApiResponse(new Dictionary + return EarthJson(new Dictionary { ["activeSeasonId"] = ChallengesController.ActiveSeasonId, ["seasonId"] = ChallengesController.ActiveSeasonId, @@ -45,16 +45,16 @@ public IActionResult GetSeason() ["premiumRewards"] = Array.Empty() } } - })), "application/json"); + }); } [HttpPost("player/seasonpass/purchase")] [HttpPost("seasonpass/purchase")] - public IActionResult PurchaseSeasonPass() - => Content(Json.Serialize(new EarthApiResponse(new Dictionary + public ContentHttpResult PurchaseSeasonPass() + => EarthJson(new Dictionary { ["premiumPassOwned"] = true - })), "application/json"); + }); [HttpPost("challenges/season/active/{id}")] [HttpPut("challenges/season/active/{id}")] @@ -90,12 +90,12 @@ public async Task> SetActiveSeasonChallen updates.Map["challenges"] = (int)(now / 1000); string selectedChallengeId = (string)results.GetExtra("activeSeasonChallenge"); - return TypedResults.Content(Json.Serialize(new EarthApiResponse(new Dictionary + return EarthJson(new Dictionary { ["activeSeasonChallenge"] = selectedChallengeId, ["activeChallengeId"] = selectedChallengeId, ["activeSeasonId"] = ChallengesController.ActiveSeasonId, ["seasonId"] = ChallengesController.ActiveSeasonId, - }, updates)), "application/json"); + }, updates); } } diff --git a/src/Solace.ApiServer/Controllers/EarthApi/SummaryController.cs b/src/Solace.ApiServer/Controllers/EarthApi/SummaryController.cs index 1c4f8947..70c0b6d0 100644 --- a/src/Solace.ApiServer/Controllers/EarthApi/SummaryController.cs +++ b/src/Solace.ApiServer/Controllers/EarthApi/SummaryController.cs @@ -1,21 +1,20 @@ using Asp.Versioning; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; -using Solace.ApiServer.Utils; -using Solace.Common; namespace Solace.ApiServer.Controllers.EarthApi; [AllowAnonymous] [ApiVersion("1.1")] [Route("1")] -internal sealed class SummaryController : ControllerBase +internal sealed class SummaryController : SolaceControllerBase { [HttpGet("summary")] - public IActionResult Get() - => Content(Json.Serialize(new EarthApiResponse(new Dictionary + public ContentHttpResult Get() + => EarthJson(new Dictionary { ["status"] = "ok", ["updates"] = new Dictionary() - })), "application/json"); + }); } diff --git a/src/Solace.ApiServer/Controllers/EarthApi/TutorialController.cs b/src/Solace.ApiServer/Controllers/EarthApi/TutorialController.cs index d2a9e657..b8dcf057 100644 --- a/src/Solace.ApiServer/Controllers/EarthApi/TutorialController.cs +++ b/src/Solace.ApiServer/Controllers/EarthApi/TutorialController.cs @@ -1,15 +1,14 @@ -using Asp.Versioning; +using Asp.Versioning; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; -using Solace.ApiServer.Utils; -using Solace.Common; namespace Solace.ApiServer.Controllers.EarthApi; [Authorize] [ApiVersion("1.1")] [Route("1/api/v{version:apiVersion}")] -internal sealed class TutorialController : ControllerBase +internal sealed class TutorialController : SolaceControllerBase { [HttpGet("player/tutorial")] [HttpGet("player/tutorials")] @@ -19,8 +18,8 @@ internal sealed class TutorialController : ControllerBase [HttpGet("tutorials")] [HttpGet("oobe")] [HttpGet("outofboxexperience")] - public IActionResult GetTutorialState() - => Content(Json.Serialize(new EarthApiResponse(new Dictionary + public ContentHttpResult GetTutorialState() + => EarthJson(new Dictionary { ["completed"] = new Dictionary { @@ -37,7 +36,7 @@ public IActionResult GetTutorialState() ["freedom"] = true }, ["available"] = Array.Empty() - })), "application/json"); + }); [HttpPost("player/tutorial")] [HttpPost("player/tutorials")] @@ -53,11 +52,11 @@ public IActionResult GetTutorialState() [HttpPost("oobe/{tutorialId}")] [HttpPost("outofboxexperience")] [HttpPost("outofboxexperience/{tutorialId}")] - public IActionResult CompleteTutorial(string? tutorialId = null) - => Content(Json.Serialize(new EarthApiResponse(new Dictionary + public ContentHttpResult CompleteTutorial(string? tutorialId = null) + => EarthJson(new Dictionary { ["tutorialId"] = tutorialId, ["completed"] = true, ["updates"] = null - })), "application/json"); + }); } From aa1da3ac7bd2739728510099780d2132cf1956bb Mon Sep 17 00:00:00 2001 From: LNLenost Date: Tue, 9 Jun 2026 09:22:14 +0200 Subject: [PATCH 07/10] Update submodule: add adventure static data and readable shop tabs --- staticdata | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/staticdata b/staticdata index 9efc7e8a..fd8aa4f3 160000 --- a/staticdata +++ b/staticdata @@ -1 +1 @@ -Subproject commit 9efc7e8acab703cdd1c5b41f5bf62e82eeb671ab +Subproject commit fd8aa4f3ea03e73c5e37c87e2639b11cbf5eabec From ec42572f4e927ac72a0cb0d55c17933e6f8ad189 Mon Sep 17 00:00:00 2001 From: LNLenost Date: Tue, 9 Jun 2026 09:22:46 +0200 Subject: [PATCH 08/10] Update submodule URL to LNLenost fork (temp) --- .gitmodules | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 4c0547ad40d8aaa17d294c8acf954ad4f975b3ac Mon Sep 17 00:00:00 2001 From: LNLenost Date: Tue, 9 Jun 2026 09:22:46 +0200 Subject: [PATCH 09/10] Update submodule pointer to include adventure static data --- staticdata | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/staticdata b/staticdata index fd8aa4f3..a6979b52 160000 --- a/staticdata +++ b/staticdata @@ -1 +1 @@ -Subproject commit fd8aa4f3ea03e73c5e37c87e2639b11cbf5eabec +Subproject commit a6979b52262eaae1d1d7fad0edc7f401589c45a7 From d064cbcf394156140a192eeaedc983a08c4ad712 Mon Sep 17 00:00:00 2001 From: LNLenost Date: Tue, 9 Jun 2026 17:50:09 +0200 Subject: [PATCH 10/10] fix: await task in TaskExtensions.Forget to fix CS0103 build error --- src/Solace.Common/Utils/TaskExtensions.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Solace.Common/Utils/TaskExtensions.cs b/src/Solace.Common/Utils/TaskExtensions.cs index 164268cb..6e7bf2f2 100644 --- a/src/Solace.Common/Utils/TaskExtensions.cs +++ b/src/Solace.Common/Utils/TaskExtensions.cs @@ -29,13 +29,13 @@ async static Task ForgetAwaited(Task task, Action? onException) { try { - Log.Error($"Unhandled async exception: {ex}"); + await task; } catch (Exception ex) { if (onException is null) { - Log.Error($"Unhandeled async exception: {ex}"); + Log.Error($"Unhandled async exception: {ex}"); } else {