From 1e79360b6e0b2222c8a4baa26216fdfa386af43d Mon Sep 17 00:00:00 2001 From: neckfire Date: Mon, 13 Jul 2026 02:55:54 +0200 Subject: [PATCH 1/4] feat: rotation shops (daily/weekly) + items insurance + season pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RequestStoreRotationData: rotation daily (reset 00:00 UTC) + weekly (lundi) avec storeIDs rotatifs déterministes et timers d'expiration corrects. - EnterMatchmakingStation: traite purchaseInsuranceRequest au déploiement — marque les items assurés (Insurance + owner) et débite la prime (TitleData InventoryInsurance). - FYPurchaseInventoryInsuranceRequest: modèle complété (insuranceId + itemIds). - RequestClaimFortunaPassRewards: claim saison-correct (S2/S3 séparés, plus d'écrasement) + octroi des récompenses depuis catalogue TitleData optionnel (repli sûr sinon). Co-Authored-By: Claude Opus 4.8 --- .../Functions/EnterMatchmakingStation.cs | 99 ++++++++++++++++- .../RequestClaimFortunaPassRewards.cs | 104 +++++++++++++++--- .../Functions/RequestStoreRotationData.cs | 30 ++++- .../FYPurchaseInventoryInsuranceRequest.cs | 15 ++- 4 files changed, 220 insertions(+), 28 deletions(-) diff --git a/src/Prospect.Server.Api/Services/CloudScript/Functions/EnterMatchmakingStation.cs b/src/Prospect.Server.Api/Services/CloudScript/Functions/EnterMatchmakingStation.cs index 018f235..aad04d6 100644 --- a/src/Prospect.Server.Api/Services/CloudScript/Functions/EnterMatchmakingStation.cs +++ b/src/Prospect.Server.Api/Services/CloudScript/Functions/EnterMatchmakingStation.cs @@ -1,18 +1,107 @@ -using Prospect.Server.Api.Services.CloudScript.Models; +using System.Text.Json; +using Prospect.Server.Api.Models.Data; +using Prospect.Server.Api.Services.Auth.Extensions; +using Prospect.Server.Api.Services.CloudScript.Models; +using Prospect.Server.Api.Services.CloudScript.Models.Data; +using Prospect.Server.Api.Services.UserData; namespace Prospect.Server.Api.Services.CloudScript.Functions; +// Config d'une police d'assurance (TitleData "InventoryInsurance"). +public class InventoryInsurancePolicy +{ + public string InsuranceId { get; set; } + public double Cost { get; set; } + public double Payout { get; set; } +} + [CloudScriptFunction("EnterMatchmakingStation")] public class EnterMatchmakingStationFunction : ICloudScriptFunction { - public Task ExecuteAsync(FYEnterMatchAzureFunction request) + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly UserDataService _userDataService; + private readonly TitleDataService _titleDataService; + private readonly ILogger _logger; + + public EnterMatchmakingStationFunction( + IHttpContextAccessor httpContextAccessor, + UserDataService userDataService, + TitleDataService titleDataService, + ILogger logger) { - return Task.FromResult(new FYEnterMatchmakingResult + _httpContextAccessor = httpContextAccessor; + _userDataService = userDataService; + _titleDataService = titleDataService; + _logger = logger; + } + + public async Task ExecuteAsync(FYEnterMatchAzureFunction request) + { + // Au déploiement, le client peut demander d'assurer une partie de l'inventaire. + var insurance = request.PurchaseInventoryInsurances; + var context = _httpContextAccessor.HttpContext; + if (context != null && insurance?.ItemIds is { Count: > 0 }) + { + try + { + await ApplyInsuranceAsync(context.User.FindAuthUserId(), insurance); + } + catch (Exception e) + { + // Ne jamais bloquer le déploiement à cause de l'assurance. + _logger.LogWarning(e, "Failed to apply inventory insurance on deploy"); + } + } + + return new FYEnterMatchmakingResult { Success = true, ErrorMessage = "", SingleplayerStation = true, // NOTE: This will always travel to single player station IsMatchTravel = false, - }); + }; } -} \ No newline at end of file + + // Marque les items choisis comme assurés (Insurance + propriétaire) et débite la + // prime en InsuranceCurrency selon le coût de la police (TitleData InventoryInsurance). + private async Task ApplyInsuranceAsync(string userId, FYPurchaseInventoryInsuranceRequest req) + { + var titleData = _titleDataService.Find(new List { "InventoryInsurance" }); + var policies = JsonSerializer.Deserialize>(titleData["InventoryInsurance"]); + var insuranceId = string.IsNullOrEmpty(req.InsuranceId) ? "Default" : req.InsuranceId; + if (policies == null || !policies.TryGetValue(insuranceId, out var policy)) + { + _logger.LogWarning("Unknown insurance policy {InsuranceId}", insuranceId); + return; + } + + var userData = await _userDataService.FindAsync(userId, userId, new List { "Inventory", "Balance" }); + var inventory = JsonSerializer.Deserialize>(userData["Inventory"].Value); + var balance = JsonSerializer.Deserialize(userData["Balance"].Value); + + var toInsure = new HashSet(req.ItemIds); + var insuredCount = 0; + foreach (var item in inventory) + { + if (!toInsure.Contains(item.ItemId)) continue; + item.Insurance = insuranceId; + item.InsuranceOwnerPlayfabId = userId; + insuredCount++; + } + if (insuredCount == 0) return; + + // Prime = coût de la police × nombre d'items assurés (arrondi au supérieur), + // plafonnée au solde disponible pour ne jamais passer en négatif. + var premium = (int)Math.Ceiling(policy.Cost * insuredCount); + premium = Math.Min(premium, balance.InsuranceCurrency); + balance.InsuranceCurrency -= premium; + + await _userDataService.UpdateAsync(userId, userId, new Dictionary + { + ["Inventory"] = JsonSerializer.Serialize(inventory), + ["Balance"] = JsonSerializer.Serialize(balance), + }); + _logger.LogInformation("Insured {Count} item(s) under policy {Policy} for user {User} (premium {Premium})", + insuredCount, insuranceId, userId, premium); + } +} diff --git a/src/Prospect.Server.Api/Services/CloudScript/Functions/RequestClaimFortunaPassRewards.cs b/src/Prospect.Server.Api/Services/CloudScript/Functions/RequestClaimFortunaPassRewards.cs index fa7604b..1f0a7a2 100644 --- a/src/Prospect.Server.Api/Services/CloudScript/Functions/RequestClaimFortunaPassRewards.cs +++ b/src/Prospect.Server.Api/Services/CloudScript/Functions/RequestClaimFortunaPassRewards.cs @@ -1,5 +1,6 @@ using System.Text.Json; using System.Text.Json.Serialization; +using Prospect.Server.Api.Models.Data; using Prospect.Server.Api.Services.Auth.Extensions; using Prospect.Server.Api.Services.CloudScript; using Prospect.Server.Api.Services.UserData; @@ -9,6 +10,15 @@ public class FYFortunaPassClaimedRewards { public List RewardsIDs { get; set; } }; +// Récompense d'un palier de la passe (catalogue optionnel TitleData "FortunaPass2_Rewards"). +// itemId "SoftCurrency"/"Aurum"/"InsuranceCurrency" = monnaie, sinon item d'inventaire. +public class FortunaPassRewardDef { + [JsonPropertyName("itemId")] + public string ItemId { get; set; } + [JsonPropertyName("amount")] + public int Amount { get; set; } +} + public class RequestClaimFortunaPassRewardsRequest { [JsonPropertyName("rewardsIds")] @@ -34,11 +44,13 @@ public class RequestClaimFortunaPassRewardsFunction : ICloudScriptFunction ExecuteAsync(RequestClaimFortunaPassRewardsRequest request) @@ -49,28 +61,92 @@ public class RequestClaimFortunaPassRewardsFunction : ICloudScriptFunction{"FortunaPass2_ClaimedRewards"}); - var claimedRewards = JsonSerializer.Deserialize(userData["FortunaPass2_ClaimedRewards"].Value); - foreach (var rewardId in request.RewardsIDs) { + + // Clé de la saison courante (déterminée par la config de build) — évite d'écraser + // la progression de l'autre saison (l'ancien code écrasait S2 ET S3). +#if SEASON_3_RELEASE || SEASON_3_DEBUG + const string claimedKey = "FortunaPass3_ClaimedRewards"; + const string rewardsCatalogKey = "FortunaPass3_Rewards"; +#else + const string claimedKey = "FortunaPass2_ClaimedRewards"; + const string rewardsCatalogKey = "FortunaPass2_Rewards"; +#endif + + var userData = await _userDataService.FindAsync(userId, userId, new List{ claimedKey, "Inventory", "Balance" }); + var claimedRewards = JsonSerializer.Deserialize(userData[claimedKey].Value) + ?? new FYFortunaPassClaimedRewards { RewardsIDs = new List() }; + claimedRewards.RewardsIDs ??= new List(); + + // Catalogue des récompenses (optionnel) : rewardId -> {itemId, amount}. + Dictionary catalog = null; + try + { + var td = _titleDataService.Find(new List { rewardsCatalogKey }); + if (td.TryGetValue(rewardsCatalogKey, out var raw) && !string.IsNullOrWhiteSpace(raw)) + catalog = JsonSerializer.Deserialize>(raw); + } + catch { /* pas de catalogue → on enregistre seulement la réclamation */ } + + var inventory = JsonSerializer.Deserialize>(userData["Inventory"].Value) ?? new List(); + var balance = JsonSerializer.Deserialize(userData["Balance"].Value); + var grantedItems = new List(); + var changedCurrencies = new List(); + + foreach (var rewardId in request.RewardsIDs) + { + // Ne pas re-créditer un palier déjà réclamé. + if (claimedRewards.RewardsIDs.Contains(rewardId)) continue; claimedRewards.RewardsIDs.Add(rewardId); + + if (catalog == null || !catalog.TryGetValue(rewardId, out var def) || def == null) continue; + + switch (def.ItemId) + { + case "SoftCurrency": + balance.SoftCurrency += def.Amount; + changedCurrencies.Add(new FYCurrencyItem { CurrencyName = "SoftCurrency", Amount = balance.SoftCurrency }); + break; + case "Aurum": + balance.HardCurrency += def.Amount; + changedCurrencies.Add(new FYCurrencyItem { CurrencyName = "Aurum", Amount = balance.HardCurrency }); + break; + case "InsuranceCurrency": + balance.InsuranceCurrency += def.Amount; + changedCurrencies.Add(new FYCurrencyItem { CurrencyName = "InsuranceCurrency", Amount = balance.InsuranceCurrency }); + break; + default: + var item = new FYCustomItemInfo { + ItemId = Guid.NewGuid().ToString(), + Amount = def.Amount <= 0 ? 1 : def.Amount, + BaseItemId = def.ItemId, + Durability = 0, + Insurance = "", + InsuranceOwnerPlayfabId = "", + ModData = new FYModItems { M = [] }, + Origin = new FYItemOriginBackend { G = "", P = "", T = "" }, + InsuredAttachmentId = "", + PrimaryVanityId = 0, + SecondaryVanityId = 0, + RolledPerks = [], + }; + inventory.Add(item); + grantedItems.Add(item); + break; + } } - var output = JsonSerializer.Serialize(claimedRewards); - await _userDataService.UpdateAsync( - userId, userId, - new Dictionary{ - ["FortunaPass2_ClaimedRewards"] = output, - ["FortunaPass3_ClaimedRewards"] = output, // TODO: Separate logic for S2 and S3 - } - ); + var changes = new Dictionary { [claimedKey] = JsonSerializer.Serialize(claimedRewards) }; + if (grantedItems.Count > 0) changes["Inventory"] = JsonSerializer.Serialize(inventory); + if (changedCurrencies.Count > 0) changes["Balance"] = JsonSerializer.Serialize(balance); + await _userDataService.UpdateAsync(userId, userId, changes); return new RequestClaimFortunaPassRewardsResponse { UserId = userId, ErrorType = 3, // EYFortunaPassToastReponseType::Ok Error = "", - GrantedItems = [], - ChangedCurrencies = [], + GrantedItems = grantedItems.ToArray(), + ChangedCurrencies = changedCurrencies.ToArray(), }; } } diff --git a/src/Prospect.Server.Api/Services/CloudScript/Functions/RequestStoreRotationData.cs b/src/Prospect.Server.Api/Services/CloudScript/Functions/RequestStoreRotationData.cs index 64d4598..1af67ae 100644 --- a/src/Prospect.Server.Api/Services/CloudScript/Functions/RequestStoreRotationData.cs +++ b/src/Prospect.Server.Api/Services/CloudScript/Functions/RequestStoreRotationData.cs @@ -35,6 +35,11 @@ public class RequestStoreRotationDataFunction : ICloudScriptFunction ExecuteAsync(RequestStoreRotationDataRequest request) { var context = _httpContextAccessor.HttpContext; @@ -43,18 +48,33 @@ public class RequestStoreRotationDataFunction : ICloudScriptFunction ItemIds { get; set; } +} From 345a822a2e16154c4fc17496bd1611fd9ecf2a37 Mon Sep 17 00:00:00 2001 From: neckfire Date: Tue, 14 Jul 2026 02:20:27 +0200 Subject: [PATCH 2/4] =?UTF-8?q?feat(preprod):=20contrats=20fonctionnels,?= =?UTF-8?q?=20r=C3=A9putation=20factions,=20gating=20tech-tree=20&=20shop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RequestPlayerContracts: renvoie les vrais contrats actifs + job boards du joueur (peuplement initial des boards vides) au lieu d'une liste hardcodée. - RequestFactionProgression: renvoie la réputation réelle des 3 factions (au lieu de 0). - StartTechTreeNodeUpgradeClient: dependency check PlayerQuarterLevelRequired. - PurchaseWeaponShopItem: critère de déblocage par contrat (UnlockData.ContractLockPurchase). Co-Authored-By: Claude Opus 4.8 --- .../Functions/PurchaseWeaponShopItem.cs | 19 +- .../Functions/RequestFactionProgression.cs | 55 +++--- .../Functions/RequestPlayerContracts.cs | 167 ++++++++++-------- .../StartTechTreeNodeUpgradeClient.cs | 12 +- 4 files changed, 149 insertions(+), 104 deletions(-) diff --git a/src/Prospect.Server.Api/Services/CloudScript/Functions/PurchaseWeaponShopItem.cs b/src/Prospect.Server.Api/Services/CloudScript/Functions/PurchaseWeaponShopItem.cs index f450267..8df29de 100644 --- a/src/Prospect.Server.Api/Services/CloudScript/Functions/PurchaseWeaponShopItem.cs +++ b/src/Prospect.Server.Api/Services/CloudScript/Functions/PurchaseWeaponShopItem.cs @@ -3,6 +3,7 @@ using System.Text.Json.Serialization; using Prospect.Server.Api.Models.Data; using Prospect.Server.Api.Services.Auth.Extensions; using Prospect.Server.Api.Services.CloudScript; +using Prospect.Server.Api.Services.CloudScript.Functions; using Prospect.Server.Api.Services.UserData; public class PurchaseWeaponShopItemRequest @@ -137,7 +138,7 @@ public class PurchaseWeaponShopItemFunction : ICloudScriptFunction{"Inventory", "Balance"}); + var userData = await _userDataService.FindAsync(userId, userId, new List{"Inventory", "Balance", "ContractsOneTimeCompleted"}); var inventory = JsonSerializer.Deserialize>(userData["Inventory"].Value); var balance = JsonSerializer.Deserialize(userData["Balance"].Value); var blueprintsData = _titleDataService.Find(new List{"Blueprints"}); @@ -153,7 +154,21 @@ public class PurchaseWeaponShopItemFunction : ICloudScriptFunction(userData["ContractsOneTimeCompleted"].Value); + if (completed?.ContractsIDs == null || !completed.ContractsIDs.Contains(blueprintData.UnlockData.ContractLockPurchase)) + { + return new PurchaseWeaponShopItemResponse + { + UserID = userId, + Error = "Item is locked: complete the required contract first", + }; + } + } // Validate price var shopCraftingData = blueprintData.ItemShopsCraftingData[request.ShopItemBelongsTo]; diff --git a/src/Prospect.Server.Api/Services/CloudScript/Functions/RequestFactionProgression.cs b/src/Prospect.Server.Api/Services/CloudScript/Functions/RequestFactionProgression.cs index 4f4a667..16912b8 100644 --- a/src/Prospect.Server.Api/Services/CloudScript/Functions/RequestFactionProgression.cs +++ b/src/Prospect.Server.Api/Services/CloudScript/Functions/RequestFactionProgression.cs @@ -1,5 +1,7 @@ -using Prospect.Server.Api.Services.Auth.Extensions; +using System.Text.Json; +using Prospect.Server.Api.Services.Auth.Extensions; using Prospect.Server.Api.Services.CloudScript.Models; +using Prospect.Server.Api.Services.UserData; namespace Prospect.Server.Api.Services.CloudScript.Functions; @@ -7,42 +9,43 @@ namespace Prospect.Server.Api.Services.CloudScript.Functions; public class RequestFactionProgression : ICloudScriptFunction { private readonly IHttpContextAccessor _httpContextAccessor; + private readonly UserDataService _userDataService; - public RequestFactionProgression(IHttpContextAccessor httpContextAccessor) + public RequestFactionProgression(IHttpContextAccessor httpContextAccessor, UserDataService userDataService) { _httpContextAccessor = httpContextAccessor; + _userDataService = userDataService; } - - public Task ExecuteAsync(FYQueryFactionProgressionRequest request) + + public async Task ExecuteAsync(FYQueryFactionProgressionRequest request) { var context = _httpContextAccessor.HttpContext; if (context == null) { - return Task.FromResult(null); + return null; } - - return Task.FromResult(new + var userId = context.User.FindAuthUserId(); + + var userData = await _userDataService.FindAsync( + userId, userId, + new List { "FactionProgressionICA", "FactionProgressionKorolev", "FactionProgressionOsiris" } + ); + + int Progression(string faction) => + userData.TryGetValue("FactionProgression" + faction, out var v) && v.Value != null + ? JsonSerializer.Deserialize(v.Value) + : 0; + + return new { - factions = new [] + factions = new[] { - new - { - factionId = "ICA", - currentProgression = 0 - }, - new - { - factionId = "Korolev", - currentProgression = 0 - }, - new - { - factionId = "Osiris", - currentProgression = 0 - }, + new { factionId = "ICA", currentProgression = Progression("ICA") }, + new { factionId = "Korolev", currentProgression = Progression("Korolev") }, + new { factionId = "Osiris", currentProgression = Progression("Osiris") }, }, - userId = context.User.FindAuthUserId(), + userId, error = "" - }); + }; } -} \ No newline at end of file +} diff --git a/src/Prospect.Server.Api/Services/CloudScript/Functions/RequestPlayerContracts.cs b/src/Prospect.Server.Api/Services/CloudScript/Functions/RequestPlayerContracts.cs index c3fd37f..c2963b9 100644 --- a/src/Prospect.Server.Api/Services/CloudScript/Functions/RequestPlayerContracts.cs +++ b/src/Prospect.Server.Api/Services/CloudScript/Functions/RequestPlayerContracts.cs @@ -1,100 +1,119 @@ -using Prospect.Server.Api.Services.Auth.Extensions; +using System.Text.Json; +using Prospect.Server.Api.Models.Data; +using Prospect.Server.Api.Services.Auth.Extensions; using Prospect.Server.Api.Services.CloudScript.Models; using Prospect.Server.Api.Services.CloudScript.Models.Data; +using Prospect.Server.Api.Services.UserData; namespace Prospect.Server.Api.Services.CloudScript.Functions; [CloudScriptFunction("RequestPlayerContracts")] public class RequestPlayerContracts : ICloudScriptFunction { - private readonly IHttpContextAccessor _httpContextAccessor; + // Board slots are ordered Easy / Medium / Hard (same convention as ClaimActiveContract). + private static readonly string[] Difficulties = ["Easy", "Medium", "Hard"]; + private const int RefreshHours = 12; - public RequestPlayerContracts(IHttpContextAccessor httpContextAccessor) + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly UserDataService _userDataService; + private readonly TitleDataService _titleDataService; + private readonly Random _rnd = new(); + + public RequestPlayerContracts(IHttpContextAccessor httpContextAccessor, UserDataService userDataService, TitleDataService titleDataService) { _httpContextAccessor = httpContextAccessor; + _userDataService = userDataService; + _titleDataService = titleDataService; } - public Task ExecuteAsync(FYGetPlayerContractsRequest request) + public async Task ExecuteAsync(FYGetPlayerContractsRequest request) { var context = _httpContextAccessor.HttpContext; if (context == null) { throw new CloudScriptException("CloudScript was not called within a http request"); } + var userId = context.User.FindAuthUserId(); - return Task.FromResult(new FYGetPlayerContractsResult + var userData = await _userDataService.FindAsync( + userId, userId, + new List + { + "ContractsActive", "JobBoardsData", + "FactionProgressionKorolev", "FactionProgressionICA", "FactionProgressionOsiris", + } + ); + + var contractsActive = JsonSerializer.Deserialize(userData["ContractsActive"].Value)!; + var jobBoardsData = JsonSerializer.Deserialize(userData["JobBoardsData"].Value)!; + + var titleData = _titleDataService.Find(new List { "Contracts", "Jobs", "LevelData" }); + var contracts = JsonSerializer.Deserialize>(titleData["Contracts"])!; + var jobs = JsonSerializer.Deserialize>>(titleData["Jobs"])!; + var levelsData = JsonSerializer.Deserialize>(titleData["LevelData"])!; + + var now = (int)DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + var lastRefresh = jobBoardsData.LastBoardRefreshTimeUtc?.Seconds ?? 0; + + // Initial fill only: populate boards that were seeded empty / never refreshed. + // The periodic rotation is owned by RequestActiveObjectivesAndBoardsData, so we + // don't refresh on a timer here to avoid two competing refresh policies. + var boardsEmpty = jobBoardsData.Boards == null + || jobBoardsData.Boards.Any(b => b.Contracts == null + || b.Contracts.Any(c => string.IsNullOrEmpty(c.ContractId))); + + if (jobBoardsData.Boards != null && (lastRefresh == 0 || boardsEmpty)) { - UserId = context.User.FindAuthUserId(), + foreach (var board in jobBoardsData.Boards) + { + if (board.Contracts == null || !jobs.TryGetValue(board.FactionId, out var factionJobs)) + { + continue; + } + + var factionKey = "FactionProgression" + board.FactionId; + var factionProgression = userData.TryGetValue(factionKey, out var fp) + ? JsonSerializer.Deserialize(fp.Value) + : 0; + + var level = levelsData.TryGetValue(board.FactionId, out var levels) + ? levels.Length - Array.FindIndex(levels, xp => factionProgression >= xp) + : 0; + + for (var idx = 0; idx < board.Contracts.Count && idx < Difficulties.Length; idx++) + { + if (!factionJobs.TryGetValue(Difficulties[idx], out var difficultyJobs)) + { + continue; + } + var available = Array.FindAll(difficultyJobs, + jobId => contracts.TryGetValue(jobId, out var c) && level >= c.UnlockData.Level); + if (available.Length > 0) + { + board.Contracts[idx].ContractId = available[_rnd.Next(available.Length)]; + } + } + } + + jobBoardsData.LastBoardRefreshTimeUtc = new FYTimestamp { Seconds = now }; + + await _userDataService.UpdateAsync( + userId, userId, + new Dictionary { ["JobBoardsData"] = JsonSerializer.Serialize(jobBoardsData) } + ); + } + + return new FYGetPlayerContractsResult + { + UserId = userId, Error = null, - ActiveContracts = new List(), + ActiveContracts = contractsActive.Contracts, FactionsContracts = new FYFactionsContractsData { - Boards = new List - { - new FYFactionContractsData - { - FactionId = "ICA", - Contracts = new List - { - new FYFactionContractData - { - ContractId = "NEW-Easy-ICA-Gather-1", - }, - new FYFactionContractData - { - ContractId = "NEW-Medium-ICA-Uplink-1", - }, - new FYFactionContractData - { - ContractId = "NEW-Hard-ICA-Uplink-1", - } - } - }, - new FYFactionContractsData - { - FactionId = "Korolev", - Contracts = new List - { - new FYFactionContractData - { - ContractId = "NEW-Easy-KOR-Mine-4", - }, - new FYFactionContractData - { - ContractId = "NEW-Medium-KOR-Mine-1", - }, - new FYFactionContractData - { - ContractId = "NEW-Hard-KOR-PvP-6", - } - } - }, - new FYFactionContractsData - { - FactionId = "Osiris", - Contracts = new List - { - new FYFactionContractData - { - ContractId = "NEW-Easy-Osiris-Brightcaps-1", - }, - new FYFactionContractData - { - ContractId = "NEW-Medium-Osiris-Gather-1", - }, - new FYFactionContractData - { - ContractId = "NEW-Hard-Osiris-Gather-7", - } - } - } - }, - LastBoardRefreshTimeUtc = new FYTimestamp - { - Seconds = (int)DateTimeOffset.UtcNow.ToUnixTimeSeconds() - } + Boards = jobBoardsData.Boards, + LastBoardRefreshTimeUtc = jobBoardsData.LastBoardRefreshTimeUtc ?? new FYTimestamp { Seconds = now }, }, - RefreshHours24UtcFromBackend = 12 - }); + RefreshHours24UtcFromBackend = RefreshHours, + }; } -} \ No newline at end of file +} diff --git a/src/Prospect.Server.Api/Services/CloudScript/Functions/StartTechTreeNodeUpgradeClient.cs b/src/Prospect.Server.Api/Services/CloudScript/Functions/StartTechTreeNodeUpgradeClient.cs index 61b8713..ac382ef 100644 --- a/src/Prospect.Server.Api/Services/CloudScript/Functions/StartTechTreeNodeUpgradeClient.cs +++ b/src/Prospect.Server.Api/Services/CloudScript/Functions/StartTechTreeNodeUpgradeClient.cs @@ -54,7 +54,7 @@ public class StartTechTreeNodeUpgradeClient : ICloudScriptFunction{"TechTreeNodeData", "Balance", "Inventory"} + new List{"TechTreeNodeData", "Balance", "Inventory", "PlayerQuartersLevel"} ); var userTechTree = JsonSerializer.Deserialize(userData["TechTreeNodeData"].Value); @@ -79,7 +79,15 @@ public class StartTechTreeNodeUpgradeClient : ICloudScriptFunction(userData["PlayerQuartersLevel"].Value); + if (playerQuarters != null && playerQuarters.Level < techTreeNodeData.PlayerQuarterLevelRequired) { + return new FYStartTechTreeNodeUpgradeClientResult + { + UserID = userId, + Error = $"Requires player quarters level {techTreeNodeData.PlayerQuarterLevelRequired}", + }; + } // Validate price // TODO: Refactor From 6f3410b37d0df7d49a34fe19a3b33c34317f0773 Mon Sep 17 00:00:00 2001 From: neckfire Date: Tue, 14 Jul 2026 11:56:14 +0200 Subject: [PATCH 3/4] fix(contracts): auto-credit kill objectives in client-hosted raids Kill-type contract objectives were never persisted: the client-hosted raid has no dedicated game server to report per-kill progress, so creature kills stayed at 0 and contracts reported "no kills" at claim. - EnterMatchmakingMatch: auto-credit Kills objectives to MaxProgress on deploy (mirrors the item-ownership pre-compute already done there and the player-kill auto-credit in ActivateContract). - ClaimActiveContract: treat Kills objectives as met as a safety net for contracts activated before deploy. Co-Authored-By: Claude Opus 4.8 --- .gitignore | 1 + .../Services/CloudScript/Functions/ClaimActiveContract.cs | 6 ++++++ .../CloudScript/Functions/EnterMatchmakingMatch.cs | 8 ++++++++ 3 files changed, 15 insertions(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c4fff02 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +_out/ diff --git a/src/Prospect.Server.Api/Services/CloudScript/Functions/ClaimActiveContract.cs b/src/Prospect.Server.Api/Services/CloudScript/Functions/ClaimActiveContract.cs index df111ad..5796102 100644 --- a/src/Prospect.Server.Api/Services/CloudScript/Functions/ClaimActiveContract.cs +++ b/src/Prospect.Server.Api/Services/CloudScript/Functions/ClaimActiveContract.cs @@ -131,6 +131,12 @@ public class ClaimActiveContract : ICloudScriptFunction Date: Tue, 14 Jul 2026 12:30:11 +0200 Subject: [PATCH 4/4] feat(insurance,fortuna): payout on death + Fortuna Pass claim safety - CompleteInventoryUpdate: insured items lost in a raid (present in ItemsToRemove) are recovered back to the stash and the insurance is consumed. This is the payout counterpart to the premium already debited on deploy (EnterMatchmakingStation). Gated on the policy declaring a positive Payout; never fails the inventory commit. - RequestClaimFortunaPassRewards: log the client-sent reward IDs so a real FortunaPass{2,3}_Rewards catalog can be built to match, and stop recording a reward as claimed when there is no catalog entry to grant (previously it was burned permanently and became unclaimable). Co-Authored-By: Claude Opus 4.8 --- .../Functions/CompleteInventoryUpdate.cs | 44 ++++++++++++++++++- .../RequestClaimFortunaPassRewards.cs | 19 ++++++-- 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/src/Prospect.Server.Api/Services/CloudScript/Functions/CompleteInventoryUpdate.cs b/src/Prospect.Server.Api/Services/CloudScript/Functions/CompleteInventoryUpdate.cs index d8bb2f4..b4b3398 100644 --- a/src/Prospect.Server.Api/Services/CloudScript/Functions/CompleteInventoryUpdate.cs +++ b/src/Prospect.Server.Api/Services/CloudScript/Functions/CompleteInventoryUpdate.cs @@ -2,6 +2,7 @@ using System.Text.Json; using System.Text.Json.Serialization; using Prospect.Server.Api.Services.Auth.Extensions; using Prospect.Server.Api.Services.CloudScript; +using Prospect.Server.Api.Services.CloudScript.Functions; using Prospect.Server.Api.Services.CloudScript.Models; using Prospect.Server.Api.Services.UserData; @@ -45,12 +46,14 @@ public class CompleteInventoryUpdateFunction : ICloudScriptFunction _logger; private readonly IHttpContextAccessor _httpContextAccessor; private readonly UserDataService _userDataService; + private readonly TitleDataService _titleDataService; - public CompleteInventoryUpdateFunction(ILogger logger, IHttpContextAccessor httpContextAccessor, UserDataService userDataService) + public CompleteInventoryUpdateFunction(ILogger logger, IHttpContextAccessor httpContextAccessor, UserDataService userDataService, TitleDataService titleDataService) { _logger = logger; _httpContextAccessor = httpContextAccessor; _userDataService = userDataService; + _titleDataService = titleDataService; } public async Task ExecuteAsync(CompleteInventoryUpdateRequest request) @@ -64,6 +67,42 @@ public class CompleteInventoryUpdateFunction : ICloudScriptFunction{"Inventory"}); var inventory = JsonSerializer.Deserialize>(userData["Inventory"].Value); + // Insurance payout: this request also reports the end-of-match inventory, so items + // the player lost (death / left behind) arrive in ItemsToRemove. Any of those that + // were insured on deploy (EnterMatchmakingStation) are recovered back to the stash — + // the premium was already paid up-front — and the insurance is consumed (one raid). + // Recovery is only performed if the item's policy declares a positive Payout. + var recoveredInsured = new HashSet(); + if (request.ItemsToRemove is { Count: > 0 }) + { + try + { + var td = _titleDataService.Find(new List { "InventoryInsurance" }); + if (td.TryGetValue("InventoryInsurance", out var rawPolicies) && !string.IsNullOrEmpty(rawPolicies)) + { + var policies = JsonSerializer.Deserialize>(rawPolicies); + foreach (var item in inventory) + { + if (!request.ItemsToRemove.Contains(item.ItemId)) continue; + if (string.IsNullOrEmpty(item.Insurance) || item.InsuranceOwnerPlayfabId != userId) continue; + if (policies == null || !policies.TryGetValue(item.Insurance, out var policy) || policy.Payout <= 0) continue; + recoveredInsured.Add(item.ItemId); + item.Insurance = ""; // consumed for this raid + item.InsuranceOwnerPlayfabId = ""; + } + if (recoveredInsured.Count > 0) + { + _logger.LogInformation("Recovered {Count} insured item(s) for user {User}", recoveredInsured.Count, userId); + } + } + } + catch (Exception e) + { + // Never fail the inventory commit because of insurance handling. + _logger.LogWarning(e, "Failed to process insurance payout"); + } + } + // TODO: Optimize // TODO: Check deleted items to see if other stacks/mods were updated correctly // Process inventory update. The player may: @@ -74,7 +113,8 @@ public class CompleteInventoryUpdateFunction : ICloudScriptFunction(inventory.Count); foreach (var item in inventory) { - if (!request.ItemsToRemove.Contains(item.ItemId)) { + // Keep items that weren't removed, plus insured items recovered above. + if (!request.ItemsToRemove.Contains(item.ItemId) || recoveredInsured.Contains(item.ItemId)) { newInventory.Add(item); } } diff --git a/src/Prospect.Server.Api/Services/CloudScript/Functions/RequestClaimFortunaPassRewards.cs b/src/Prospect.Server.Api/Services/CloudScript/Functions/RequestClaimFortunaPassRewards.cs index 1f0a7a2..59529d7 100644 --- a/src/Prospect.Server.Api/Services/CloudScript/Functions/RequestClaimFortunaPassRewards.cs +++ b/src/Prospect.Server.Api/Services/CloudScript/Functions/RequestClaimFortunaPassRewards.cs @@ -45,12 +45,14 @@ public class RequestClaimFortunaPassRewardsFunction : ICloudScriptFunction _logger; - public RequestClaimFortunaPassRewardsFunction(IHttpContextAccessor httpContextAccessor, UserDataService userDataService, TitleDataService titleDataService) + public RequestClaimFortunaPassRewardsFunction(IHttpContextAccessor httpContextAccessor, UserDataService userDataService, TitleDataService titleDataService, ILogger logger) { _httpContextAccessor = httpContextAccessor; _userDataService = userDataService; _titleDataService = titleDataService; + _logger = logger; } public async Task ExecuteAsync(RequestClaimFortunaPassRewardsRequest request) @@ -96,9 +98,20 @@ public class RequestClaimFortunaPassRewardsFunction : ICloudScriptFunction {ItemId} x{Amount}", rewardId, def.ItemId, def.Amount); switch (def.ItemId) {