11 Commits
Author SHA1 Message Date
neckfire 3cdf8bc7e2 Merge pull request 'Boutiques : GetStoreItems (vendeurs + Aurum) + fix vanities possédées' (#2) from preprod into main
Build & Deploy / build (push) Successful in 13s
2026-07-14 12:07:18 +00:00
neckfireandClaude Opus 4.8 5d93b3541d fix(store): return per-user owned vanities, not the whole catalogue
Build & Deploy / build (push) Successful in 23s
GetUserInventory returned every vanity in the Vanities title data as an
owned item, so the client showed all Aurum-shop cosmetics as already
owned. Return only the cosmetics the player actually owns: purchased
(VanityItems) plus currently equipped (CharacterVanity slots and the
GlobalVanity sprays / banner / emotes / drop pod), so the starter outfit
stays owned while the rest of the Aurum shop becomes purchasable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 13:55:50 +02:00
neckfireandClaude Opus 4.8 40dad82559 feat(store): populate Aurum/cosmetic shops from Vanities
Build & Deploy / build (push) Successful in 23s
GetStoreItems now also serves the cosmetic stores the client queries
(AurumShop, and the DailyShop_/WeeklyShop_ rotations) from the Vanities
title data: every vanity with StoreData.Amount > 0 is offered for that
price in Aurum (AU). AurumShop lists the full priced catalogue (632
items); the daily/weekly ids show a deterministic rotating slice so they
stay stable within a period but change across periods.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 13:46:17 +02:00
neckfireandClaude Opus 4.8 3b108d99ca feat(store): populate shops via GetStoreItems
Build & Deploy / build (push) Successful in 23s
GetStoreItems was a stub returning an empty object, so every shop
(Korolev / ICA / Osiris / QuickShop vendors) showed up empty. Build the
store on the fly from the Blueprints title data: for the requested
StoreId, list every item that declares an ItemShopsCraftingData entry
for that store, converting its currency recipe ingredients into
VirtualCurrencyPrices (SoftCurrency->SC, Aurum->AU, InsuranceCurrency->IN).
Item-ingredient (crafting) entries are left to the crafting UI.

Adds the StoreId/CatalogVersion fields to the request and the
FGetStoreItemsResult / FStoreItem PlayFab response models.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 12:53:37 +02:00
neckfire 67f3095896 Merge pull request 'Promotion preprod → prod : contrats/kills, factions, tech-tree, assurance payout, Fortuna Pass' (#1) from preprod into main
Build & Deploy / build (push) Successful in 11s
2026-07-14 10:33:31 +00:00
neckfireandClaude Opus 4.8 89e7a69049 feat(insurance,fortuna): payout on death + Fortuna Pass claim safety
Build & Deploy / build (push) Successful in 21s
- 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 <noreply@anthropic.com>
2026-07-14 12:30:11 +02:00
neckfireandClaude Opus 4.8 6f3410b37d fix(contracts): auto-credit kill objectives in client-hosted raids
Build & Deploy / build (push) Successful in 21s
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 <noreply@anthropic.com>
2026-07-14 11:56:14 +02:00
neckfireandClaude Opus 4.8 345a822a2e feat(preprod): contrats fonctionnels, réputation factions, gating tech-tree & shop
Build & Deploy / build (push) Successful in 21s
- 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 <noreply@anthropic.com>
2026-07-14 02:20:27 +02:00
neckfireandClaude Opus 4.8 1e79360b6e feat: rotation shops (daily/weekly) + items insurance + season pass
Build & Deploy / build (push) Successful in 10s
- 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 <noreply@anthropic.com>
2026-07-13 02:55:54 +02:00
neckfireandClaude Opus 4.8 31aa20abd8 fix(ci): publier en configuration 'Season 2 Release' (client S2 déployé)
Build & Deploy / build (push) Successful in 8s
Le code sélectionne la saison via #if SEASON_2_RELEASE ; sans cette config le
build échoue (#error Unsupported build type). Build validé en local.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 02:37:11 +02:00
neckfireandClaude Opus 4.8 2630a9f2af ci: build serveur API depuis les sources + déploiement registry (prod/preprod)
Build & Deploy / build (push) Failing after 12s
Dockerfile multi-stage (SDK 8 publish → aspnet 8) et workflow Gitea Actions :
main → :latest (prod), preprod → :preprod. Base = tag b8 (état déployé).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 02:33:04 +02:00
18 changed files with 737 additions and 145 deletions
+33
View File
@@ -0,0 +1,33 @@
name: Build & Deploy
on:
push:
branches: [main, preprod]
workflow_dispatch: {}
env:
IMAGE: git.nfteam.ovh/neckfire/the-cycle
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Build & push image
run: |
set -e
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login git.nfteam.ovh -u "${{ secrets.REGISTRY_USER }}" --password-stdin
SHA="${GITHUB_SHA::12}"
BRANCH="${GITHUB_REF_NAME}"
if [ "$BRANCH" = "main" ]; then TAGS="-t ${IMAGE}:latest -t ${IMAGE}:${SHA}"; else TAGS="-t ${IMAGE}:${BRANCH} -t ${IMAGE}:${BRANCH}-${SHA}"; fi
docker build $TAGS -f Dockerfile .
docker push --all-tags "${IMAGE}"
- name: Notify ntfy
if: always()
run: |
if [ "${{ job.status }}" = "success" ]; then EMOJI="white_check_mark"; PRIO="default"; else EMOJI="rotating_light"; PRIO="high"; fi
curl -s -H "Authorization: Bearer ${{ secrets.NTFY_TOKEN }}" -H "Title: ${GITHUB_REPOSITORY} — ${{ job.status }}" \
-H "Priority: ${PRIO}" -H "Tags: ${EMOJI}" -H "Click: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions" \
-d "${GITHUB_WORKFLOW} (${GITHUB_REF_NAME} #${GITHUB_RUN_NUMBER}) : ${{ job.status }}" \
"${{ secrets.NTFY_URL }}/${{ secrets.NTFY_TOPIC }}" || true
+1
View File
@@ -0,0 +1 @@
_out/
+15
View File
@@ -0,0 +1,15 @@
# The Cycle: Frontier — serveur API (émulateur Prospect), build depuis les sources.
# Multi-stage : SDK .NET 8 pour publier le projet API → runtime aspnet 8.
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY src/ ./src/
# Configuration "Season 2 Release" = build qui correspond au client S2 déployé
# (le code sélectionne la saison via des #if SEASON_2_RELEASE / SEASON_3_RELEASE).
RUN dotnet publish src/Prospect.Server.Api/Prospect.Server.Api.csproj \
-c "Season 2 Release" -o /app
FROM mcr.microsoft.com/dotnet/aspnet:8.0
WORKDIR /app
COPY --from=build /app ./
EXPOSE 8443 8000
ENTRYPOINT ["dotnet", "Prospect.Server.Api.dll"]
@@ -155,11 +155,119 @@ public class ClientController : Controller
[Authorize(AuthenticationSchemes = UserAuthenticationOptions.DefaultScheme)] [Authorize(AuthenticationSchemes = UserAuthenticationOptions.DefaultScheme)]
public IActionResult GetStoreItems(FGetStoreItems request) public IActionResult GetStoreItems(FGetStoreItems request)
{ {
return Ok(new ClientResponse<object> // The vendors (Korolev / ICA / Osiris / QuickShop) and other stores are defined
// inside the Blueprints title data: each item lists a per-store entry under
// ItemShopsCraftingData keyed by the store id, with the currency price in its
// recipe ingredients. This endpoint was previously a stub returning {}, which is
// why every shop showed up empty. We build the store on the fly from that data.
var storeId = request.StoreId ?? "";
var store = new List<FStoreItem>();
try
{
var titleData = _titleDataService.Find(new List<string> { "Blueprints" });
var blueprints = JsonSerializer.Deserialize<Dictionary<string, TitleDataBlueprintInfo>>(titleData["Blueprints"]);
if (blueprints != null && !string.IsNullOrEmpty(storeId))
{
uint position = 0;
foreach (var (itemId, blueprint) in blueprints)
{
if (blueprint.ItemShopsCraftingData == null) continue;
if (!blueprint.ItemShopsCraftingData.TryGetValue(storeId, out var shopData)) continue;
if (shopData?.ItemRecipeIngredients == null) continue;
// Only currency ingredients form a purchasable price. Entries whose
// recipe is item-based (crafting) are handled by the crafting UI from
// the blueprint data the client already has, not by the buy store.
var prices = new Dictionary<string, uint>();
foreach (var ingredient in shopData.ItemRecipeIngredients)
{
var code = ingredient.Currency switch
{
"SoftCurrency" => "SC",
"Aurum" => "AU",
"InsuranceCurrency" => "IN",
_ => null,
};
if (code == null || ingredient.Amount < 0) continue;
prices[code] = prices.TryGetValue(code, out var current)
? current + (uint)ingredient.Amount
: (uint)ingredient.Amount;
}
if (prices.Count == 0) continue;
store.Add(new FStoreItem
{
ItemId = itemId,
VirtualCurrencyPrices = prices,
RealCurrencyPrices = new Dictionary<string, uint>(),
DisplayPosition = position++,
});
}
}
}
catch (Exception e)
{
_logger.LogWarning(e, "Failed to build store items for store {StoreId}", storeId);
}
// Cosmetic / premium stores are backed by the Vanities title data (not Blueprints):
// each vanity carries its price in StoreData.Amount, paid in Aurum. AurumShop lists
// the whole priced catalogue; the daily/weekly rotations (ids from RequestStore
// RotationData) show a deterministic slice so they change over time but stay stable
// within a period.
var isAurumShop = storeId == "AurumShop";
var isDailyShop = storeId.StartsWith("DailyShop_");
var isWeeklyShop = storeId.StartsWith("WeeklyShop_");
if (store.Count == 0 && (isAurumShop || isDailyShop || isWeeklyShop))
{
try
{
var vanityData = _titleDataService.Find(new List<string> { "Vanities" });
var vanities = JsonSerializer.Deserialize<List<TitleDataVanityInfo>>(vanityData["Vanities"]);
var priced = vanities?
.Where(v => v.StoreData != null && v.StoreData.Amount > 0)
.OrderBy(v => v.Name, StringComparer.Ordinal)
.ToList() ?? new List<TitleDataVanityInfo>();
IEnumerable<TitleDataVanityInfo> selection = priced;
if ((isDailyShop || isWeeklyShop) && priced.Count > 0)
{
var size = isWeeklyShop ? 12 : 6;
int.TryParse(storeId[(storeId.IndexOf('_') + 1)..], out var rotation);
var start = (int)(((long)rotation * size) % priced.Count);
selection = Enumerable.Range(0, Math.Min(size, priced.Count))
.Select(i => priced[(start + i) % priced.Count]);
}
uint position = 0;
foreach (var vanity in selection)
{
store.Add(new FStoreItem
{
ItemId = vanity.Name,
VirtualCurrencyPrices = new Dictionary<string, uint> { ["AU"] = (uint)vanity.StoreData.Amount },
RealCurrencyPrices = new Dictionary<string, uint>(),
DisplayPosition = position++,
});
}
}
catch (Exception e)
{
_logger.LogWarning(e, "Failed to build vanity store {StoreId}", storeId);
}
}
_logger.LogInformation("GetStoreItems '{StoreId}': {Count} item(s)", storeId, store.Count);
return Ok(new ClientResponse<FGetStoreItemsResult>
{ {
Code = 200, Code = 200,
Status = "OK", Status = "OK",
Data = new {} Data = new FGetStoreItemsResult
{
StoreId = string.IsNullOrEmpty(storeId) ? null : storeId,
CatalogVersion = "StaticItems",
Store = store,
}
}); });
} }
@@ -244,13 +352,15 @@ public class ClientController : Controller
public async Task<IActionResult> GetUserInventory(FGetUserInventoryRequest request) public async Task<IActionResult> GetUserInventory(FGetUserInventoryRequest request)
{ {
var userId = User.FindAuthUserId(); var userId = User.FindAuthUserId();
var userData = await _userDataService.FindAsync(userId, userId, new List<string>{"Balance", "Inventory", "VanityItems"}); var userData = await _userDataService.FindAsync(userId, userId, new List<string>{"Balance", "Inventory", "VanityItems", "CharacterVanity", "GlobalVanity"});
var inventory = JsonSerializer.Deserialize<FYCustomItemInfo[]>(userData["Inventory"].Value); var inventory = JsonSerializer.Deserialize<FYCustomItemInfo[]>(userData["Inventory"].Value);
// TODO: Per-user vanity // Per-user vanity ownership: the player owns the cosmetics they've bought (VanityItems)
// var vanity = JsonSerializer.Deserialize<CustomVanityItem[]>(userData["VanityItems"].Value); // plus whatever is currently equipped (CharacterVanity / GlobalVanity), so the starter
var titleData = _titleDataService.Find(new List<string>{"Blueprints", "Vanities"}); // outfit stays owned. Previously the whole Vanities catalogue was returned as owned,
// which made every item in the Aurum shop show up as already "owned".
var ownedVanityIds = CollectOwnedVanityIds(userData);
var titleData = _titleDataService.Find(new List<string>{"Blueprints"});
var blueprints = JsonSerializer.Deserialize<Dictionary<string, TitleDataBlueprintInfo>>(titleData["Blueprints"]); var blueprints = JsonSerializer.Deserialize<Dictionary<string, TitleDataBlueprintInfo>>(titleData["Blueprints"]);
var vanity = JsonSerializer.Deserialize<TitleDataVanityInfo[]>(titleData["Vanities"]);
List<FItemInstance> items = new List<FItemInstance>(); List<FItemInstance> items = new List<FItemInstance>();
foreach (var item in inventory) { foreach (var item in inventory) {
if (!blueprints.ContainsKey(item.BaseItemId)) { if (!blueprints.ContainsKey(item.BaseItemId)) {
@@ -276,9 +386,9 @@ public class ClientController : Controller
}); });
} }
foreach (var item in vanity) { foreach (var vanityId in ownedVanityIds) {
items.Add(new FItemInstance { items.Add(new FItemInstance {
ItemId = item.Name, ItemId = vanityId,
// ItemInstanceId is not required since it's not a unique item. // ItemInstanceId is not required since it's not a unique item.
ItemClass = "Vanity", ItemClass = "Vanity",
// PurchaseDate = DateTime.Now, // TODO // PurchaseDate = DateTime.Now, // TODO
@@ -303,6 +413,60 @@ public class ClientController : Controller
}); });
} }
// Collects the vanity ids a player actually owns: everything they've purchased
// (VanityItems) plus everything currently equipped (CharacterVanity slots and the
// GlobalVanity sprays / banner / emotes / drop pod). Parsed defensively with
// JsonDocument so malformed / partial data never breaks the inventory response.
private static HashSet<string> CollectOwnedVanityIds(Dictionary<string, FUserDataRecord> userData)
{
var owned = new HashSet<string>(StringComparer.Ordinal);
void AddId(string? id) { if (!string.IsNullOrEmpty(id)) owned.Add(id); }
if (userData.TryGetValue("VanityItems", out var viRec) && !string.IsNullOrWhiteSpace(viRec.Value))
{
try
{
using var doc = JsonDocument.Parse(viRec.Value);
if (doc.RootElement.ValueKind == JsonValueKind.Array)
foreach (var el in doc.RootElement.EnumerateArray())
if (el.ValueKind == JsonValueKind.Object && el.TryGetProperty("baseItemId", out var b))
AddId(b.GetString());
}
catch { /* ignore malformed VanityItems */ }
}
if (userData.TryGetValue("CharacterVanity", out var cvRec) && !string.IsNullOrWhiteSpace(cvRec.Value))
{
try
{
using var doc = JsonDocument.Parse(cvRec.Value);
if (doc.RootElement.ValueKind == JsonValueKind.Object)
foreach (var prop in doc.RootElement.EnumerateObject())
if (prop.Value.ValueKind == JsonValueKind.Object && prop.Value.TryGetProperty("id", out var id))
AddId(id.GetString());
}
catch { /* ignore malformed CharacterVanity */ }
}
if (userData.TryGetValue("GlobalVanity", out var gvRec) && !string.IsNullOrWhiteSpace(gvRec.Value))
{
try
{
using var doc = JsonDocument.Parse(gvRec.Value);
var root = doc.RootElement;
if (root.ValueKind == JsonValueKind.Object)
{
if (root.TryGetProperty("activeGlobalVanityIds", out var arr) && arr.ValueKind == JsonValueKind.Array)
foreach (var el in arr.EnumerateArray()) AddId(el.GetString());
if (root.TryGetProperty("droppodId", out var dp)) AddId(dp.GetString());
}
}
catch { /* ignore malformed GlobalVanity */ }
}
return owned;
}
[HttpPost("GetTitleData")] [HttpPost("GetTitleData")]
[Produces(MediaTypeNames.Application.Json)] [Produces(MediaTypeNames.Application.Json)]
[Authorize(AuthenticationSchemes = UserAuthenticationOptions.DefaultScheme)] [Authorize(AuthenticationSchemes = UserAuthenticationOptions.DefaultScheme)]
@@ -0,0 +1,39 @@
using System.Text.Json.Serialization;
namespace Prospect.Server.Api.Models.Client.Data;
// PlayFab StoreItem: a single catalog item offered for sale in a store, with its prices.
public class FStoreItem
{
/// <summary>
/// [optional] Store specific custom data. The data only exists as part of this store; it is not transferred to item instances.
/// </summary>
[JsonPropertyName("CustomData")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public object? CustomData { get; set; }
/// <summary>
/// [optional] Intended display position for this item. Note that 0 is the first position and -1 is the last position.
/// </summary>
[JsonPropertyName("DisplayPosition")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public uint? DisplayPosition { get; set; }
/// <summary>
/// Unique identifier of the item as it exists in the catalog - note that this must exactly match the ItemId from the catalog.
/// </summary>
[JsonPropertyName("ItemId")]
public string ItemId { get; set; } = "";
/// <summary>
/// [optional] Override prices for this item for specific currencies.
/// </summary>
[JsonPropertyName("RealCurrencyPrices")]
public Dictionary<string, uint> RealCurrencyPrices { get; set; } = new();
/// <summary>
/// [optional] Override prices for this item in virtual currencies and "RM" (the base Real Money purchase price, in USD pennies).
/// </summary>
[JsonPropertyName("VirtualCurrencyPrices")]
public Dictionary<string, uint> VirtualCurrencyPrices { get; set; } = new();
}
@@ -1,8 +1,19 @@
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using Prospect.Server.Api.Models.Client.Data; using Prospect.Server.Api.Models.Client.Data;
namespace Prospect.Server.Api.Models.Client; namespace Prospect.Server.Api.Models.Client;
public class FGetStoreItems public class FGetStoreItems
{ {
/// <summary>
/// [optional] Catalog version to store items from. Use default catalog version if null.
/// </summary>
[JsonPropertyName("CatalogVersion")]
public string? CatalogVersion { get; set; }
/// <summary>
/// Unique identifier for the store which is being requested.
/// </summary>
[JsonPropertyName("StoreId")]
public string? StoreId { get; set; }
} }
@@ -0,0 +1,25 @@
using System.Text.Json.Serialization;
using Prospect.Server.Api.Models.Client.Data;
namespace Prospect.Server.Api.Models.Client;
public class FGetStoreItemsResult
{
/// <summary>
/// [optional] The base catalog that this store is a part of.
/// </summary>
[JsonPropertyName("CatalogVersion")]
public string? CatalogVersion { get; set; }
/// <summary>
/// [optional] Array of items which can be purchased from this store.
/// </summary>
[JsonPropertyName("Store")]
public List<FStoreItem> Store { get; set; } = new();
/// <summary>
/// [optional] The ID of this store.
/// </summary>
[JsonPropertyName("StoreId")]
public string? StoreId { get; set; }
}
@@ -131,6 +131,12 @@ public class ClaimActiveContract : ICloudScriptFunction<FYClaimCompletedActiveCo
break; break;
} }
} }
} else if (objective.Type == EYContractObjectiveType.Kills) {
// Kill objectives can't be tracked in a client-hosted raid (no dedicated
// game server reports kills). They are auto-credited on deploy
// (EnterMatchmakingMatch); accept them here even if a contract was
// activated before that so kill contracts remain completable.
remaining = 0;
} else { } else {
// NOTE: A game server must write the correct progress // NOTE: A game server must write the correct progress
// for other types of objectives. // for other types of objectives.
@@ -2,6 +2,7 @@ using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using Prospect.Server.Api.Services.Auth.Extensions; using Prospect.Server.Api.Services.Auth.Extensions;
using Prospect.Server.Api.Services.CloudScript; 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.CloudScript.Models;
using Prospect.Server.Api.Services.UserData; using Prospect.Server.Api.Services.UserData;
@@ -45,12 +46,14 @@ public class CompleteInventoryUpdateFunction : ICloudScriptFunction<CompleteInve
private readonly ILogger<CompleteInventoryUpdateFunction> _logger; private readonly ILogger<CompleteInventoryUpdateFunction> _logger;
private readonly IHttpContextAccessor _httpContextAccessor; private readonly IHttpContextAccessor _httpContextAccessor;
private readonly UserDataService _userDataService; private readonly UserDataService _userDataService;
private readonly TitleDataService _titleDataService;
public CompleteInventoryUpdateFunction(ILogger<CompleteInventoryUpdateFunction> logger, IHttpContextAccessor httpContextAccessor, UserDataService userDataService) public CompleteInventoryUpdateFunction(ILogger<CompleteInventoryUpdateFunction> logger, IHttpContextAccessor httpContextAccessor, UserDataService userDataService, TitleDataService titleDataService)
{ {
_logger = logger; _logger = logger;
_httpContextAccessor = httpContextAccessor; _httpContextAccessor = httpContextAccessor;
_userDataService = userDataService; _userDataService = userDataService;
_titleDataService = titleDataService;
} }
public async Task<CompleteInventoryUpdateResponse> ExecuteAsync(CompleteInventoryUpdateRequest request) public async Task<CompleteInventoryUpdateResponse> ExecuteAsync(CompleteInventoryUpdateRequest request)
@@ -64,6 +67,42 @@ public class CompleteInventoryUpdateFunction : ICloudScriptFunction<CompleteInve
var userData = await _userDataService.FindAsync(userId, userId, new List<string>{"Inventory"}); var userData = await _userDataService.FindAsync(userId, userId, new List<string>{"Inventory"});
var inventory = JsonSerializer.Deserialize<List<FYCustomItemInfo>>(userData["Inventory"].Value); var inventory = JsonSerializer.Deserialize<List<FYCustomItemInfo>>(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<string>();
if (request.ItemsToRemove is { Count: > 0 })
{
try
{
var td = _titleDataService.Find(new List<string> { "InventoryInsurance" });
if (td.TryGetValue("InventoryInsurance", out var rawPolicies) && !string.IsNullOrEmpty(rawPolicies))
{
var policies = JsonSerializer.Deserialize<Dictionary<string, InventoryInsurancePolicy>>(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: Optimize
// TODO: Check deleted items to see if other stacks/mods were updated correctly // TODO: Check deleted items to see if other stacks/mods were updated correctly
// Process inventory update. The player may: // Process inventory update. The player may:
@@ -74,7 +113,8 @@ public class CompleteInventoryUpdateFunction : ICloudScriptFunction<CompleteInve
// IMPORTANT: The same request is currently used to report the updated inventory in the end of match. // IMPORTANT: The same request is currently used to report the updated inventory in the end of match.
var newInventory = new List<FYCustomItemInfo>(inventory.Count); var newInventory = new List<FYCustomItemInfo>(inventory.Count);
foreach (var item in inventory) { 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); newInventory.Add(item);
} }
} }
@@ -61,6 +61,14 @@ public class EnterMatchmakingMatchFunction : ICloudScriptFunction<FYEnterMatchAz
} }
for (var i = 0; i < contract.Objectives.Length; i++) { for (var i = 0; i < contract.Objectives.Length; i++) {
var objective = contract.Objectives[i]; var objective = contract.Objectives[i];
// Kill objectives: the client-hosted raid has no dedicated game server
// to report per-kill progress to the backend, so kills would never be
// saved. Auto-credit them on deploy so the objective can be completed
// (mirrors the player-kill auto-credit done in ActivateContract).
if (objective.Type == EYContractObjectiveType.Kills) {
contractActive.Progress[i] = objective.MaxProgress;
continue;
}
if (objective.Type != EYContractObjectiveType.OwnNumOfItem) { if (objective.Type != EYContractObjectiveType.OwnNumOfItem) {
continue; continue;
} }
@@ -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; 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")] [CloudScriptFunction("EnterMatchmakingStation")]
public class EnterMatchmakingStationFunction : ICloudScriptFunction<FYEnterMatchAzureFunction, FYEnterMatchmakingResult> public class EnterMatchmakingStationFunction : ICloudScriptFunction<FYEnterMatchAzureFunction, FYEnterMatchmakingResult>
{ {
public Task<FYEnterMatchmakingResult> ExecuteAsync(FYEnterMatchAzureFunction request) private readonly IHttpContextAccessor _httpContextAccessor;
private readonly UserDataService _userDataService;
private readonly TitleDataService _titleDataService;
private readonly ILogger<EnterMatchmakingStationFunction> _logger;
public EnterMatchmakingStationFunction(
IHttpContextAccessor httpContextAccessor,
UserDataService userDataService,
TitleDataService titleDataService,
ILogger<EnterMatchmakingStationFunction> logger)
{ {
return Task.FromResult(new FYEnterMatchmakingResult _httpContextAccessor = httpContextAccessor;
_userDataService = userDataService;
_titleDataService = titleDataService;
_logger = logger;
}
public async Task<FYEnterMatchmakingResult> 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, Success = true,
ErrorMessage = "", ErrorMessage = "",
SingleplayerStation = true, // NOTE: This will always travel to single player station SingleplayerStation = true, // NOTE: This will always travel to single player station
IsMatchTravel = false, IsMatchTravel = false,
};
}
// 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<string> { "InventoryInsurance" });
var policies = JsonSerializer.Deserialize<Dictionary<string, InventoryInsurancePolicy>>(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<string> { "Inventory", "Balance" });
var inventory = JsonSerializer.Deserialize<List<FYCustomItemInfo>>(userData["Inventory"].Value);
var balance = JsonSerializer.Deserialize<PlayerBalance>(userData["Balance"].Value);
var toInsure = new HashSet<string>(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<string, string>
{
["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);
} }
} }
@@ -3,6 +3,7 @@ using System.Text.Json.Serialization;
using Prospect.Server.Api.Models.Data; using Prospect.Server.Api.Models.Data;
using Prospect.Server.Api.Services.Auth.Extensions; using Prospect.Server.Api.Services.Auth.Extensions;
using Prospect.Server.Api.Services.CloudScript; using Prospect.Server.Api.Services.CloudScript;
using Prospect.Server.Api.Services.CloudScript.Functions;
using Prospect.Server.Api.Services.UserData; using Prospect.Server.Api.Services.UserData;
public class PurchaseWeaponShopItemRequest public class PurchaseWeaponShopItemRequest
@@ -137,7 +138,7 @@ public class PurchaseWeaponShopItemFunction : ICloudScriptFunction<PurchaseWeapo
Error = "Invalid purchase amount", Error = "Invalid purchase amount",
}; };
} }
var userData = await _userDataService.FindAsync(userId, userId, new List<string>{"Inventory", "Balance"}); var userData = await _userDataService.FindAsync(userId, userId, new List<string>{"Inventory", "Balance", "ContractsOneTimeCompleted"});
var inventory = JsonSerializer.Deserialize<List<FYCustomItemInfo>>(userData["Inventory"].Value); var inventory = JsonSerializer.Deserialize<List<FYCustomItemInfo>>(userData["Inventory"].Value);
var balance = JsonSerializer.Deserialize<PlayerBalance>(userData["Balance"].Value); var balance = JsonSerializer.Deserialize<PlayerBalance>(userData["Balance"].Value);
var blueprintsData = _titleDataService.Find(new List<string>{"Blueprints"}); var blueprintsData = _titleDataService.Find(new List<string>{"Blueprints"});
@@ -153,7 +154,21 @@ public class PurchaseWeaponShopItemFunction : ICloudScriptFunction<PurchaseWeapo
var blueprintData = blueprints[request.BaseItemID]; var blueprintData = blueprints[request.BaseItemID];
// TODO: Contract unlock criteria // Contract unlock criteria: some items can only be purchased after completing a
// specific contract. Only gate when the item explicitly declares such a lock, so
// items with no lock keep working as before.
if (!string.IsNullOrEmpty(blueprintData.UnlockData?.ContractLockPurchase))
{
var completed = JsonSerializer.Deserialize<FYGetCompletedContractsResult>(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 // Validate price
var shopCraftingData = blueprintData.ItemShopsCraftingData[request.ShopItemBelongsTo]; var shopCraftingData = blueprintData.ItemShopsCraftingData[request.ShopItemBelongsTo];
@@ -1,5 +1,6 @@
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using Prospect.Server.Api.Models.Data;
using Prospect.Server.Api.Services.Auth.Extensions; using Prospect.Server.Api.Services.Auth.Extensions;
using Prospect.Server.Api.Services.CloudScript; using Prospect.Server.Api.Services.CloudScript;
using Prospect.Server.Api.Services.UserData; using Prospect.Server.Api.Services.UserData;
@@ -9,6 +10,15 @@ public class FYFortunaPassClaimedRewards {
public List<string> RewardsIDs { get; set; } public List<string> 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 public class RequestClaimFortunaPassRewardsRequest
{ {
[JsonPropertyName("rewardsIds")] [JsonPropertyName("rewardsIds")]
@@ -34,11 +44,15 @@ public class RequestClaimFortunaPassRewardsFunction : ICloudScriptFunction<Reque
{ {
private readonly IHttpContextAccessor _httpContextAccessor; private readonly IHttpContextAccessor _httpContextAccessor;
private readonly UserDataService _userDataService; private readonly UserDataService _userDataService;
private readonly TitleDataService _titleDataService;
private readonly ILogger<RequestClaimFortunaPassRewardsFunction> _logger;
public RequestClaimFortunaPassRewardsFunction(IHttpContextAccessor httpContextAccessor, UserDataService userDataService) public RequestClaimFortunaPassRewardsFunction(IHttpContextAccessor httpContextAccessor, UserDataService userDataService, TitleDataService titleDataService, ILogger<RequestClaimFortunaPassRewardsFunction> logger)
{ {
_httpContextAccessor = httpContextAccessor; _httpContextAccessor = httpContextAccessor;
_userDataService = userDataService; _userDataService = userDataService;
_titleDataService = titleDataService;
_logger = logger;
} }
public async Task<RequestClaimFortunaPassRewardsResponse> ExecuteAsync(RequestClaimFortunaPassRewardsRequest request) public async Task<RequestClaimFortunaPassRewardsResponse> ExecuteAsync(RequestClaimFortunaPassRewardsRequest request)
@@ -49,28 +63,103 @@ public class RequestClaimFortunaPassRewardsFunction : ICloudScriptFunction<Reque
throw new CloudScriptException("CloudScript was not called within a http request"); throw new CloudScriptException("CloudScript was not called within a http request");
} }
var userId = context.User.FindAuthUserId(); var userId = context.User.FindAuthUserId();
var userData = await _userDataService.FindAsync(userId, userId, new List<string>{"FortunaPass2_ClaimedRewards"});
var claimedRewards = JsonSerializer.Deserialize<FYFortunaPassClaimedRewards>(userData["FortunaPass2_ClaimedRewards"].Value); // Clé de la saison courante (déterminée par la config de build) — évite d'écraser
foreach (var rewardId in request.RewardsIDs) { // la progression de l'autre saison (l'ancien code écrasait S2 ET S3).
claimedRewards.RewardsIDs.Add(rewardId); #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<string>{ claimedKey, "Inventory", "Balance" });
var claimedRewards = JsonSerializer.Deserialize<FYFortunaPassClaimedRewards>(userData[claimedKey].Value)
?? new FYFortunaPassClaimedRewards { RewardsIDs = new List<string>() };
claimedRewards.RewardsIDs ??= new List<string>();
// Catalogue des récompenses (optionnel) : rewardId -> {itemId, amount}.
Dictionary<string, FortunaPassRewardDef> catalog = null;
try
{
var td = _titleDataService.Find(new List<string> { rewardsCatalogKey });
if (td.TryGetValue(rewardsCatalogKey, out var raw) && !string.IsNullOrWhiteSpace(raw))
catalog = JsonSerializer.Deserialize<Dictionary<string, FortunaPassRewardDef>>(raw);
}
catch { /* pas de catalogue → on enregistre seulement la réclamation */ }
var inventory = JsonSerializer.Deserialize<List<FYCustomItemInfo>>(userData["Inventory"].Value) ?? new List<FYCustomItemInfo>();
var balance = JsonSerializer.Deserialize<PlayerBalance>(userData["Balance"].Value);
var grantedItems = new List<FYCustomItemInfo>();
var changedCurrencies = new List<FYCurrencyItem>();
foreach (var rewardId in request.RewardsIDs)
{
// Ne pas re-créditer un palier déjà réclamé.
if (claimedRewards.RewardsIDs.Contains(rewardId)) continue;
if (catalog == null || !catalog.TryGetValue(rewardId, out var def) || def == null)
{
// Le catalogue des paliers est défini côté client (DataTable). On journalise
// les IDs inconnus pour pouvoir construire un vrai catalogue FortunaPass{2,3}_Rewards
// qui matche. IMPORTANT : ne PAS marquer le palier comme réclamé si on ne peut rien
// accorder — sinon il serait brûlé définitivement et inclaimable une fois le
// catalogue ajouté.
_logger.LogInformation("FortunaPass claim: aucun catalogue pour le reward '{RewardId}' (catalogue {State}) — non accordé", rewardId, catalog == null ? "absent" : "présent");
continue;
} }
var output = JsonSerializer.Serialize(claimedRewards); claimedRewards.RewardsIDs.Add(rewardId);
await _userDataService.UpdateAsync( _logger.LogInformation("FortunaPass claim: octroi reward '{RewardId}' -> {ItemId} x{Amount}", rewardId, def.ItemId, def.Amount);
userId, userId,
new Dictionary<string, string>{ switch (def.ItemId)
["FortunaPass2_ClaimedRewards"] = output, {
["FortunaPass3_ClaimedRewards"] = output, // TODO: Separate logic for S2 and S3 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 changes = new Dictionary<string, string> { [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 return new RequestClaimFortunaPassRewardsResponse
{ {
UserId = userId, UserId = userId,
ErrorType = 3, // EYFortunaPassToastReponseType::Ok ErrorType = 3, // EYFortunaPassToastReponseType::Ok
Error = "", Error = "",
GrantedItems = [], GrantedItems = grantedItems.ToArray(),
ChangedCurrencies = [], ChangedCurrencies = changedCurrencies.ToArray(),
}; };
} }
} }
@@ -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.CloudScript.Models;
using Prospect.Server.Api.Services.UserData;
namespace Prospect.Server.Api.Services.CloudScript.Functions; namespace Prospect.Server.Api.Services.CloudScript.Functions;
@@ -7,42 +9,43 @@ namespace Prospect.Server.Api.Services.CloudScript.Functions;
public class RequestFactionProgression : ICloudScriptFunction<FYQueryFactionProgressionRequest, object?> public class RequestFactionProgression : ICloudScriptFunction<FYQueryFactionProgressionRequest, object?>
{ {
private readonly IHttpContextAccessor _httpContextAccessor; private readonly IHttpContextAccessor _httpContextAccessor;
private readonly UserDataService _userDataService;
public RequestFactionProgression(IHttpContextAccessor httpContextAccessor) public RequestFactionProgression(IHttpContextAccessor httpContextAccessor, UserDataService userDataService)
{ {
_httpContextAccessor = httpContextAccessor; _httpContextAccessor = httpContextAccessor;
_userDataService = userDataService;
} }
public Task<object?> ExecuteAsync(FYQueryFactionProgressionRequest request) public async Task<object?> ExecuteAsync(FYQueryFactionProgressionRequest request)
{ {
var context = _httpContextAccessor.HttpContext; var context = _httpContextAccessor.HttpContext;
if (context == null) if (context == null)
{ {
return Task.FromResult<object?>(null); return null;
} }
var userId = context.User.FindAuthUserId();
return Task.FromResult<object?>(new var userData = await _userDataService.FindAsync(
userId, userId,
new List<string> { "FactionProgressionICA", "FactionProgressionKorolev", "FactionProgressionOsiris" }
);
int Progression(string faction) =>
userData.TryGetValue("FactionProgression" + faction, out var v) && v.Value != null
? JsonSerializer.Deserialize<int>(v.Value)
: 0;
return new
{ {
factions = new [] factions = new[]
{ {
new new { factionId = "ICA", currentProgression = Progression("ICA") },
{ new { factionId = "Korolev", currentProgression = Progression("Korolev") },
factionId = "ICA", new { factionId = "Osiris", currentProgression = Progression("Osiris") },
currentProgression = 0
}, },
new userId,
{
factionId = "Korolev",
currentProgression = 0
},
new
{
factionId = "Osiris",
currentProgression = 0
},
},
userId = context.User.FindAuthUserId(),
error = "" error = ""
}); };
} }
} }
@@ -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;
using Prospect.Server.Api.Services.CloudScript.Models.Data; using Prospect.Server.Api.Services.CloudScript.Models.Data;
using Prospect.Server.Api.Services.UserData;
namespace Prospect.Server.Api.Services.CloudScript.Functions; namespace Prospect.Server.Api.Services.CloudScript.Functions;
[CloudScriptFunction("RequestPlayerContracts")] [CloudScriptFunction("RequestPlayerContracts")]
public class RequestPlayerContracts : ICloudScriptFunction<FYGetPlayerContractsRequest, FYGetPlayerContractsResult> public class RequestPlayerContracts : ICloudScriptFunction<FYGetPlayerContractsRequest, FYGetPlayerContractsResult>
{ {
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; _httpContextAccessor = httpContextAccessor;
_userDataService = userDataService;
_titleDataService = titleDataService;
} }
public Task<FYGetPlayerContractsResult> ExecuteAsync(FYGetPlayerContractsRequest request) public async Task<FYGetPlayerContractsResult> ExecuteAsync(FYGetPlayerContractsRequest request)
{ {
var context = _httpContextAccessor.HttpContext; var context = _httpContextAccessor.HttpContext;
if (context == null) if (context == null)
{ {
throw new CloudScriptException("CloudScript was not called within a http request"); 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<string>
{ {
UserId = context.User.FindAuthUserId(), "ContractsActive", "JobBoardsData",
"FactionProgressionKorolev", "FactionProgressionICA", "FactionProgressionOsiris",
}
);
var contractsActive = JsonSerializer.Deserialize<FYGetActiveContractsResult>(userData["ContractsActive"].Value)!;
var jobBoardsData = JsonSerializer.Deserialize<JobBoardsData>(userData["JobBoardsData"].Value)!;
var titleData = _titleDataService.Find(new List<string> { "Contracts", "Jobs", "LevelData" });
var contracts = JsonSerializer.Deserialize<Dictionary<string, TitleDataContractInfo>>(titleData["Contracts"])!;
var jobs = JsonSerializer.Deserialize<Dictionary<string, Dictionary<string, string[]>>>(titleData["Jobs"])!;
var levelsData = JsonSerializer.Deserialize<Dictionary<string, int[]>>(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))
{
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<int>(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<string, string> { ["JobBoardsData"] = JsonSerializer.Serialize(jobBoardsData) }
);
}
return new FYGetPlayerContractsResult
{
UserId = userId,
Error = null, Error = null,
ActiveContracts = new List<FYActiveContractPlayerData>(), ActiveContracts = contractsActive.Contracts,
FactionsContracts = new FYFactionsContractsData FactionsContracts = new FYFactionsContractsData
{ {
Boards = new List<FYFactionContractsData> Boards = jobBoardsData.Boards,
{ LastBoardRefreshTimeUtc = jobBoardsData.LastBoardRefreshTimeUtc ?? new FYTimestamp { Seconds = now },
new FYFactionContractsData
{
FactionId = "ICA",
Contracts = new List<FYFactionContractData>
{
new FYFactionContractData
{
ContractId = "NEW-Easy-ICA-Gather-1",
}, },
new FYFactionContractData RefreshHours24UtcFromBackend = RefreshHours,
{ };
ContractId = "NEW-Medium-ICA-Uplink-1",
},
new FYFactionContractData
{
ContractId = "NEW-Hard-ICA-Uplink-1",
}
}
},
new FYFactionContractsData
{
FactionId = "Korolev",
Contracts = new List<FYFactionContractData>
{
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<FYFactionContractData>
{
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()
}
},
RefreshHours24UtcFromBackend = 12
});
} }
} }
@@ -35,6 +35,11 @@ public class RequestStoreRotationDataFunction : ICloudScriptFunction<RequestStor
_httpContextAccessor = httpContextAccessor; _httpContextAccessor = httpContextAccessor;
} }
// Nombre de variantes de rotation (les storeId "DailyShop_N" / "WeeklyShop_N"
// permettent au client de distinguer chaque rotation et de rafraîchir son cache).
private const int DailyVariants = 8;
private const int WeeklyVariants = 4;
public async Task<RequestStoreRotationDataResponse> ExecuteAsync(RequestStoreRotationDataRequest request) public async Task<RequestStoreRotationDataResponse> ExecuteAsync(RequestStoreRotationDataRequest request)
{ {
var context = _httpContextAccessor.HttpContext; var context = _httpContextAccessor.HttpContext;
@@ -43,18 +48,33 @@ public class RequestStoreRotationDataFunction : ICloudScriptFunction<RequestStor
throw new CloudScriptException("CloudScript was not called within a http request"); throw new CloudScriptException("CloudScript was not called within a http request");
} }
// TODO: Get and save store rotation data var now = DateTime.UtcNow;
// Rotation quotidienne : expire au prochain minuit UTC ; l'index de rotation
// est le nombre de jours écoulés depuis l'epoch (déterministe, stable).
var todayUtc = now.Date;
var dailyExpiration = todayUtc.AddDays(1);
var dayIndex = (int)(todayUtc - DateTime.UnixEpoch.Date).TotalDays;
var dailyStoreId = $"DailyShop_{dayIndex % DailyVariants}";
// Rotation hebdomadaire : expire au prochain lundi 00:00 UTC.
// DayOfWeek : dimanche=0 ; on ramène lundi=0.
var daysSinceMonday = ((int)todayUtc.DayOfWeek + 6) % 7;
var weekStart = todayUtc.AddDays(-daysSinceMonday);
var weeklyExpiration = weekStart.AddDays(7);
var weekIndex = (int)Math.Floor((weekStart - DateTime.UnixEpoch.Date).TotalDays / 7);
var weeklyStoreId = $"WeeklyShop_{weekIndex % WeeklyVariants}";
return new RequestStoreRotationDataResponse return new RequestStoreRotationDataResponse
{ {
Error = "", Error = "",
DailyStoreData = new StoreRotationData { DailyStoreData = new StoreRotationData {
StoreID = "DailyShop", StoreID = dailyStoreId,
ExpirationData = DateTime.UtcNow.AddDays(1) ExpirationData = dailyExpiration
}, },
WeeklyStoreData = new StoreRotationData { WeeklyStoreData = new StoreRotationData {
StoreID = "WeeklyShop", StoreID = weeklyStoreId,
ExpirationData = DateTime.UtcNow.AddDays(1) ExpirationData = weeklyExpiration
} }
}; };
} }
@@ -54,7 +54,7 @@ public class StartTechTreeNodeUpgradeClient : ICloudScriptFunction<FYStartTechTr
var userData = await _userDataService.FindAsync( var userData = await _userDataService.FindAsync(
userId, userId, userId, userId,
new List<string>{"TechTreeNodeData", "Balance", "Inventory"} new List<string>{"TechTreeNodeData", "Balance", "Inventory", "PlayerQuartersLevel"}
); );
var userTechTree = JsonSerializer.Deserialize<UserTechTreeNodeData>(userData["TechTreeNodeData"].Value); var userTechTree = JsonSerializer.Deserialize<UserTechTreeNodeData>(userData["TechTreeNodeData"].Value);
@@ -79,7 +79,15 @@ public class StartTechTreeNodeUpgradeClient : ICloudScriptFunction<FYStartTechTr
var techTreeNodeData = techTreeNodes[request.NodeID]; var techTreeNodeData = techTreeNodes[request.NodeID];
var nextTreeNodeLevel = techTreeNodeData.PerkLevels[currentLevel]; // Tech tree node levels start from 0 var nextTreeNodeLevel = techTreeNodeData.PerkLevels[currentLevel]; // Tech tree node levels start from 0
// TODO: Dependency check // Dependency check: the player's quarters must be high enough to unlock this node.
var playerQuarters = JsonSerializer.Deserialize<FYPlayerQuarterStatus>(userData["PlayerQuartersLevel"].Value);
if (playerQuarters != null && playerQuarters.Level < techTreeNodeData.PlayerQuarterLevelRequired) {
return new FYStartTechTreeNodeUpgradeClientResult
{
UserID = userId,
Error = $"Requires player quarters level {techTreeNodeData.PlayerQuarterLevelRequired}",
};
}
// Validate price // Validate price
// TODO: Refactor // TODO: Refactor
@@ -1,7 +1,14 @@
namespace Prospect.Server.Api.Services.CloudScript.Models.Data; using System.Text.Json.Serialization;
namespace Prospect.Server.Api.Services.CloudScript.Models.Data;
// Envoyé par le client au déploiement (dans FYEnterMatchAzureFunction) : quels items
// de l'inventaire assurer, avec quelle police (voir TitleData "InventoryInsurance").
public class FYPurchaseInventoryInsuranceRequest public class FYPurchaseInventoryInsuranceRequest
{ {
// m_insuranceId [JsonPropertyName("insuranceId")]
// m_itemIds public string InsuranceId { get; set; }
[JsonPropertyName("itemIds")]
public List<string> ItemIds { get; set; }
} }