7 Commits
Author SHA1 Message Date
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
32 changed files with 566 additions and 341 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"]
+2 -2
View File
@@ -141,7 +141,7 @@ to successfully communicate with the local server. Do the following:
1. Select **Place all certificates in the following store** > **Browse...**. Choose **Trusted Root Certification Authorities** and click **OK**. Click **Next**.
1. Click **Finish**. A **Security Warning** popup may appear, make sure it specifies `127.0.0.1` certification authority and click **Yes**.
1. Click **Finish**. A **Security Warning** popup may appear, make sure it specifies `2EA46.playfabapi.com` certification authority and click **Yes**.
### 4. Extract `LoaderPack` to the game
@@ -174,7 +174,7 @@ If you've installed the certificate for the **Current User**:
1. Expand **Trusted Root Certification Authorities** and select **Certificates**.
1. Find `127.0.0.1`, right-click it > **Delete**.
1. Find `2EA46.playfabapi.com`, right-click it > **Delete**.
If you've installed the certificate for the **Local Machine**, repeat the same steps but instead open `certlm.msc`.
@@ -4,4 +4,6 @@ public class PlayFabSettings
{
public string PublisherId { get; set; }
public string TitleId { get; set; }
public string SignalRURL { get; set; }
public string SignalRAccessToken { get; set; }
}
@@ -1,7 +0,0 @@
namespace Prospect.Server.Api.Config;
public class SignalRSettings
{
public string SignalRURL { get; set; }
public string SignalRAccessToken { get; set; }
}
@@ -1,9 +0,0 @@
namespace Prospect.Server.Api.Config;
public class VivoxConfig
{
public string Key { get; set; }
public string Issuer { get; set; }
public string Domain { get; set; }
public string Server { get; set; }
}
@@ -1,62 +0,0 @@
using System.Net.Mime;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.SignalR;
using Prospect.Server.Api.Hubs;
namespace Prospect.Server.Api.Controllers;
public class NotifyRequest
{
[JsonPropertyName("type")]
public string MessageType { get; set; }
[JsonPropertyName("data")]
public object MessageData { get; set; }
}
public class ServerReadyMessageData
{
[JsonPropertyName("success")]
public bool Success { get; set; }
[JsonPropertyName("sessionId")]
public string SessionID { get; set; }
[JsonPropertyName("squadId")]
public string SquadID { get; set; }
}
[Route("Notifications")]
[ApiController]
public class NotificationsController : Controller
{
private readonly IHubContext<CycleHub> _hubContext;
private readonly ILogger<NotificationsController> _logger;
public NotificationsController(ILogger<NotificationsController> logger, IHubContext<CycleHub> hubContext)
{
_logger = logger;
_hubContext = hubContext;
}
[HttpPost("TestNotify")]
[Produces(MediaTypeNames.Application.Json)]
public async Task<IActionResult> TestNotify(NotifyRequest request)
{
// TODO: To test Notifications_DT
_logger.LogDebug("Received test notification event {MessageType}", request.MessageType);
await _hubContext.Clients.All.SendAsync(request.MessageType, request.MessageData);
return StatusCode(200);
}
[HttpPost("ServerReady")]
[Produces(MediaTypeNames.Application.Json)]
public async Task<IActionResult> ServerReady(ServerReadyMessageData request)
{
_logger.LogDebug("Received test notification event OnSquadMatchmakingSuccess");
await _hubContext.Clients.All.SendAsync("OnSquadMatchmakingSuccess", request);
return StatusCode(200);
}
}
@@ -15,8 +15,8 @@ public class TitleDataContractInfo {
public TitleDataContractInfoUnlockData UnlockData { get; set; }
[JsonPropertyName("IsMainContract")]
public bool IsMainContract { get; set; }
[JsonPropertyName("Difficulty")]
public EYContractDifficulty Difficulty { get; set; }
[JsonPropertyName("ContractDifficulty")]
public EYContractDifficulty ContractDifficulty { get; set; }
}
public class TitleDataContractInfoReward {
@@ -1,22 +1,6 @@
using System.Text.Json.Serialization;
public class TitleDataDailyCrateInfo {
[JsonPropertyName("Name")]
public string Name { get; set; }
[JsonPropertyName("Level")]
public int Level { get; set; }
[JsonPropertyName("RewardGrants")]
public TitleDataDailyCrateInfoRewardGrants[] RewardGrants { get; set; }
}
public class TitleDataDailyCrateInfoRewardGrants {
[JsonPropertyName("Pool")]
public TitleDataDailyCrateInfoPoolItem[] Pool { get; set; }
[JsonPropertyName("Amount")]
public int Amount { get; set; }
}
public class TitleDataDailyCrateInfoPoolItem {
[JsonPropertyName("Name")]
public string Name { get; set; }
[JsonPropertyName("Weight")]
@@ -9,6 +9,8 @@ public class TitleDataMissionInfo {
public UDataTable OnboardingRewards { get; set; }
}
// {\"TalkToBadumReward\":{\"MissionRewardId\":\"TalkToBadumReward\",\"RewardEntries\":[{\"RewardType\":7,\"RewardRowHandle\":{\"DataTable\":\"DataTable\\u0027/Game/DataTables/PRO_Weapons.PRO_Weapons\\u0027\",\"RowName\":\"WP_E_SMG_Bullet_01\"},\"RewardAmount\":2},{\"RewardType\":7,\"RewardRowHandle\":{\"DataTable\":\"DataTable\\u0027/Game/DataTables/PRO_Weapons.PRO_Weapons\\u0027\",\"RowName\":\"WP_E_AR_Energy_01\"},\"RewardAmount\":1},{\"RewardType\":7,\"RewardRowHandle\":{\"DataTable\":\"DataTable\\u0027/Game/DataTables/PRO_Weapons.PRO_Weapons\\u0027\",\"RowName\":\"WP_E_Pistol_Bullet_01\"},\"RewardAmount\":5},{\"RewardType\":7,\"RewardRowHandle\":{\"DataTable\":\"DataTable\\u0027/Game/DataTables/PRO_Weapons.PRO_Weapons\\u0027\",\"RowName\":\"WP_E_SGun_Bullet_01\"},\"RewardAmount\":2},{\"RewardType\":6,\"RewardRowHandle\":{\"DataTable\":\"DataTable\\u0027/Game/DataTables/AmmoTypes_DT.AmmoTypes_DT\\u0027\",\"RowName\":\"Light\"},\"RewardAmount\":6},{\"RewardType\":6,\"RewardRowHandle\":{\"DataTable\":\"DataTable\\u0027/Game/DataTables/AmmoTypes_DT.AmmoTypes_DT\\u0027\",\"RowName\":\"Medium\"},\"RewardAmount\":4},{\"RewardType\":6,\"RewardRowHandle\":{\"DataTable\":\"DataTable\\u0027/Game/DataTables/AmmoTypes_DT.AmmoTypes_DT\\u0027\",\"RowName\":\"Shotgun\"},\"RewardAmount\":2},{\"RewardType\":8,\"RewardRowHandle\":{\"DataTable\":\"DataTable\\u0027/Game/DataTables/PRO_Abilities.PRO_Abilities\\u0027\",\"RowName\":\"ShockGrenade_02\"},\"RewardAmount\":2},{\"RewardType\":8,\"RewardRowHandle\":{\"DataTable\":\"DataTable\\u0027/Game/DataTables/PRO_Abilities.PRO_Abilities\\u0027\",\"RowName\":\"Consumable_Health_01\"},\"RewardAmount\":5},{\"RewardType\":0,\"RewardRowHandle\":{\"DataTable\":\"DataTable\\u0027/Game/DataTables/PRO_PlayerShield.PRO_PlayerShield\\u0027\",\"RowName\":\"Shield_01\"},\"RewardAmount\":5},{\"RewardType\":10,\"RewardRowHandle\":{\"DataTable\":\"DataTable\\u0027/Game/DataTables/PRO_Bags.PRO_Bags\\u0027\",\"RowName\":\"Bag_01\"},\"RewardAmount\":5},{\"RewardType\":7,\"RewardRowHandle\":{\"DataTable\":\"DataTable\\u0027/Game/DataTables/PRO_Weapons.PRO_Weapons\\u0027\",\"RowName\":\"TOOL_MineralScanner_01\"},\"RewardAmount\":2}]},\"FirstMatchReward\":{\"MissionRewardId\":\"FirstMatchReward\",\"RewardEntries\":[{\"RewardType\":2,\"RewardRowHandle\":{\"DataTable\":\"DataTable\\u0027/Game/DataTables/Materials_DT.Materials_DT\\u0027\",\"RowName\":\"Insulation\"},\"RewardAmount\":4},{\"RewardType\":1,\"RewardRowHandle\":{\"DataTable\":\"DataTable\\u0027/Game/DataTables/Currencies_DT.Currencies_DT\\u0027\",\"RowName\":\"SoftCurrency\"},\"RewardAmount\":1500},{\"RewardType\":2,\"RewardRowHandle\":{\"DataTable\":\"DataTable\\u0027/Game/DataTables/Materials_DT.Materials_DT\\u0027\",\"RowName\":\"GenericCreatureDrop\"},\"RewardAmount\":3},{\"RewardType\":2,\"RewardRowHandle\":{\"DataTable\":\"DataTable\\u0027/Game/DataTables/Materials_DT.Materials_DT\\u0027\",\"RowName\":\"ICAScrip\"},\"RewardAmount\":1}]}}",
public class TitleDataMissionRewardInfo {
[JsonPropertyName("MissionRewardId")]
public string MissionRewardId { get; set; }
@@ -50,7 +50,7 @@ public class ClaimActiveContract : ICloudScriptFunction<FYClaimCompletedActiveCo
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly UserDataService _userDataService;
private readonly TitleDataService _titleDataService;
private readonly EYContractDifficulty[] difficulties = [EYContractDifficulty.Easy, EYContractDifficulty.Medium, EYContractDifficulty.Hard];
private readonly string[] difficulties = ["Easy", "Medium", "Hard"];
private readonly Random rnd = new Random();
public ClaimActiveContract(IHttpContextAccessor httpContextAccessor, UserDataService userDataService, TitleDataService titleDataService)
@@ -131,6 +131,12 @@ public class ClaimActiveContract : ICloudScriptFunction<FYClaimCompletedActiveCo
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 {
// NOTE: A game server must write the correct progress
// for other types of objectives.
@@ -219,19 +225,20 @@ public class ClaimActiveContract : ICloudScriptFunction<FYClaimCompletedActiveCo
if (contract.IsMainContract) {
contractsCompleted.ContractsIDs.Add(contract.Name);
} else {
var contractDifficulty = contract.Difficulty;
var parts = contract.Name.Split('-');
var difficulty = parts[1];
var levelsData = JsonSerializer.Deserialize<Dictionary<string, int[]>>(titleData["LevelData"]);
var levels = levelsData[contract.Faction];
var jobs = JsonSerializer.Deserialize<Dictionary<string, Dictionary<EYContractDifficulty, string[]>>>(titleData["Jobs"]);
var jobs = JsonSerializer.Deserialize<Dictionary<string, Dictionary<string, string[]>>>(titleData["Jobs"]);
var level = levels.Length - Array.FindIndex(levels, (int xp) => {
return factionProgression >= xp;
});
var factionBoard = jobBoardsData.Boards.Find(c => c.FactionId == contract.Faction);
var idx = Array.FindIndex(difficulties, d => d == contractDifficulty);
var idx = Array.FindIndex(difficulties, d => d == difficulty);
var factionJobs = jobs[contract.Faction];
var jobsAvailable = Array.FindAll(factionJobs[contractDifficulty], (string jobId) => {
var jobsAvailable = Array.FindAll(factionJobs[difficulty], (string jobId) => {
return level >= contracts[jobId].UnlockData.Level;
});
// If player completes a job, it means he has at least one unlocked anyway.
@@ -43,9 +43,9 @@ public class ClaimGeneratorIncomeClient : ICloudScriptFunction<FYClaimGeneratorI
static readonly Random random = new();
private TitleDataDailyCrateInfoPoolItem[] PickItems(TitleDataDailyCrateInfoPoolItem[] items, int numPicks)
private TitleDataDailyCrateInfo[] PickItems(TitleDataDailyCrateInfo[] items, int numPicks)
{
var itemList = new (TitleDataDailyCrateInfoPoolItem item, float cumulativeWeight)[items.Length];
var itemList = new (TitleDataDailyCrateInfo item, float cumulativeWeight)[items.Length];
float totalWeight = 0;
for (int i = 0; i < items.Length; i++) {
@@ -54,7 +54,7 @@ public class ClaimGeneratorIncomeClient : ICloudScriptFunction<FYClaimGeneratorI
itemList[i] = (item, totalWeight);
}
var selectedItems = new TitleDataDailyCrateInfoPoolItem[numPicks];
var selectedItems = new TitleDataDailyCrateInfo[numPicks];
for (int i = 0; i < numPicks; i++) {
float pick = (float)random.NextDouble() * totalWeight;
@@ -108,7 +108,7 @@ public class ClaimGeneratorIncomeClient : ICloudScriptFunction<FYClaimGeneratorI
var inventory = JsonSerializer.Deserialize<List<FYCustomItemInfo>>(userData["Inventory"].Value);
var techTreeBonuses = JsonSerializer.Deserialize<CharacterTechTreeBonuses>(userData["CharacterTechTreeBonuses"].Value);
var titleData = _titleDataService.Find(new List<string>{"PassiveGenerators", "DailyCrateSetups", "Blueprints"});
var titleData = _titleDataService.Find(new List<string>{"PassiveGenerators", "DailyCrateRewardsPools", "Blueprints"});
var passiveGenerators = JsonSerializer.Deserialize<Dictionary<string, TitleDataPassiveGeneratorsInfo>>(titleData["PassiveGenerators"]);
var now = (int)DateTimeOffset.UtcNow.ToUnixTimeSeconds();
@@ -187,12 +187,12 @@ public class ClaimGeneratorIncomeClient : ICloudScriptFunction<FYClaimGeneratorI
};
}
var dailyCrateSetups = JsonSerializer.Deserialize<TitleDataDailyCrateInfo[]>(titleData["DailyCrateSetups"]);
var dailyCrateRewards = JsonSerializer.Deserialize<TitleDataDailyCrateInfo[][]>(titleData["DailyCrateRewardsPools"]);
var blueprints = JsonSerializer.Deserialize<Dictionary<string, TitleDataBlueprintInfo>>(titleData["Blueprints"]);
var rewardsPool = dailyCrateSetups[crateTier - 1];
var rewardsPool = dailyCrateRewards[crateTier - 1];
foreach (var grant in rewardsPool.RewardGrants) {
var items = PickItems(grant.Pool, grant.Amount);
// TODO: Get rewardGrants from PassiveGenerators_CrateRewards_DT
var items = PickItems(rewardsPool, 5);
// TODO: Stackable? GrantedItems doesn't imply granted OR updated.
foreach (var item in items) {
var itemInfo = blueprints[item.Name];
@@ -213,7 +213,6 @@ public class ClaimGeneratorIncomeClient : ICloudScriptFunction<FYClaimGeneratorI
inventory.Add(grantedItem);
grantedItems.Add(grantedItem);
}
}
break;
}
default: {
@@ -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<CompleteInve
private readonly ILogger<CompleteInventoryUpdateFunction> _logger;
private readonly IHttpContextAccessor _httpContextAccessor;
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;
_httpContextAccessor = httpContextAccessor;
_userDataService = userDataService;
_titleDataService = titleDataService;
}
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 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: 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<CompleteInve
// IMPORTANT: The same request is currently used to report the updated inventory in the end of match.
var newInventory = new List<FYCustomItemInfo>(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);
}
}
@@ -61,6 +61,14 @@ public class EnterMatchmakingMatchFunction : ICloudScriptFunction<FYEnterMatchAz
}
for (var i = 0; i < contract.Objectives.Length; 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) {
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;
// 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<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,
ErrorMessage = "",
SingleplayerStation = true, // NOTE: This will always travel to single player station
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);
}
}
@@ -7,9 +7,9 @@ namespace Prospect.Server.Api.Services.CloudScript.Functions;
[CloudScriptFunction("GetSignalRConnection")]
public class GetSignalRConnection : ICloudScriptFunction<FYGetSignalRConnection, FYGetSignalRConnectionResult>
{
private readonly SignalRSettings _settings;
private readonly PlayFabSettings _settings;
public GetSignalRConnection(IOptions<SignalRSettings> settings)
public GetSignalRConnection(IOptions<PlayFabSettings> settings)
{
_settings = settings.Value;
}
@@ -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<PurchaseWeapo
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 balance = JsonSerializer.Deserialize<PlayerBalance>(userData["Balance"].Value);
var blueprintsData = _titleDataService.Find(new List<string>{"Blueprints"});
@@ -153,7 +154,21 @@ public class PurchaseWeaponShopItemFunction : ICloudScriptFunction<PurchaseWeapo
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
var shopCraftingData = blueprintData.ItemShopsCraftingData[request.ShopItemBelongsTo];
@@ -16,7 +16,7 @@ public class RequestActiveObjectivesAndBoardsData : ICloudScriptFunction<FYQuery
private readonly UserDataService _userDataService;
private readonly TitleDataService _titleDataService;
private readonly string[] factions = ["Korolev", "Osiris", "ICA"]; // TODO: Order is important
private readonly EYContractDifficulty[] difficulties = [EYContractDifficulty.Easy, EYContractDifficulty.Medium, EYContractDifficulty.Hard];
private readonly string[] difficulties = ["Easy", "Medium", "Hard"];
private readonly Random rnd = new Random();
public RequestActiveObjectivesAndBoardsData(IHttpContextAccessor httpContextAccessor, UserDataService userDataService, TitleDataService titleDataService)
@@ -47,20 +47,22 @@ public class RequestActiveObjectivesAndBoardsData : ICloudScriptFunction<FYQuery
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<EYContractDifficulty, string[]>>>(titleData["Jobs"]);
var jobs = JsonSerializer.Deserialize<Dictionary<string, Dictionary<string, string[]>>>(titleData["Jobs"]);
var levelsData = JsonSerializer.Deserialize<Dictionary<string, int[]>>(titleData["LevelData"]);
var currentQuests = new Dictionary<string, Dictionary<EYContractDifficulty, bool>> {
["Korolev"] = new Dictionary<EYContractDifficulty, bool> { [EYContractDifficulty.Easy] = false, [EYContractDifficulty.Medium] = false, [EYContractDifficulty.Hard] = false },
["Osiris"] = new Dictionary<EYContractDifficulty, bool> { [EYContractDifficulty.Easy] = false, [EYContractDifficulty.Medium] = false, [EYContractDifficulty.Hard] = false },
["ICA"] = new Dictionary<EYContractDifficulty, bool> { [EYContractDifficulty.Easy] = false, [EYContractDifficulty.Medium] = false, [EYContractDifficulty.Hard] = false },
var currentQuests = new Dictionary<string, Dictionary<string, bool>> {
["Korolev"] = new Dictionary<string, bool> { ["Easy"] = false, ["Medium"] = false, ["Hard"] = false },
["Osiris"] = new Dictionary<string, bool> { ["Easy"] = false, ["Medium"] = false, ["Hard"] = false },
["ICA"] = new Dictionary<string, bool> { ["Easy"] = false, ["Medium"] = false, ["Hard"] = false },
};
foreach (var contract in contractsActive.Contracts) {
var contractData = contracts[contract.ContractID];
if (contractData.IsMainContract) {
var parts = contract.ContractID.Split('-');
if (parts[0] != "NEW") {
continue;
}
currentQuests[contractData.Faction][contractData.Difficulty] = true;
var difficulty = parts[1];
var faction = parts[2] == "KOR" ? "Korolev" : parts[2];
currentQuests[faction][difficulty] = true;
}
for (var i = 0; i < factions.Length; i++) {
@@ -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<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
{
[JsonPropertyName("rewardsIds")]
@@ -34,11 +44,15 @@ public class RequestClaimFortunaPassRewardsFunction : ICloudScriptFunction<Reque
{
private readonly IHttpContextAccessor _httpContextAccessor;
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;
_userDataService = userDataService;
_titleDataService = titleDataService;
_logger = logger;
}
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");
}
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);
foreach (var rewardId in request.RewardsIDs) {
claimedRewards.RewardsIDs.Add(rewardId);
// 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<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);
await _userDataService.UpdateAsync(
userId, userId,
new Dictionary<string, string>{
["FortunaPass2_ClaimedRewards"] = output,
["FortunaPass3_ClaimedRewards"] = output, // TODO: Separate logic for S2 and S3
claimedRewards.RewardsIDs.Add(rewardId);
_logger.LogInformation("FortunaPass claim: octroi reward '{RewardId}' -> {ItemId} x{Amount}", rewardId, def.ItemId, def.Amount);
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 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
{
UserId = userId,
ErrorType = 3, // EYFortunaPassToastReponseType::Ok
Error = "",
GrantedItems = [],
ChangedCurrencies = [],
GrantedItems = grantedItems.ToArray(),
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.UserData;
namespace Prospect.Server.Api.Services.CloudScript.Functions;
@@ -7,42 +9,43 @@ namespace Prospect.Server.Api.Services.CloudScript.Functions;
public class RequestFactionProgression : ICloudScriptFunction<FYQueryFactionProgressionRequest, object?>
{
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly UserDataService _userDataService;
public RequestFactionProgression(IHttpContextAccessor httpContextAccessor)
public RequestFactionProgression(IHttpContextAccessor httpContextAccessor, UserDataService userDataService)
{
_httpContextAccessor = httpContextAccessor;
_userDataService = userDataService;
}
public Task<object?> ExecuteAsync(FYQueryFactionProgressionRequest request)
public async Task<object?> ExecuteAsync(FYQueryFactionProgressionRequest request)
{
var context = _httpContextAccessor.HttpContext;
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
{
factionId = "ICA",
currentProgression = 0
new { factionId = "ICA", currentProgression = Progression("ICA") },
new { factionId = "Korolev", currentProgression = Progression("Korolev") },
new { factionId = "Osiris", currentProgression = Progression("Osiris") },
},
new
{
factionId = "Korolev",
currentProgression = 0
},
new
{
factionId = "Osiris",
currentProgression = 0
},
},
userId = context.User.FindAuthUserId(),
userId,
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.Data;
using Prospect.Server.Api.Services.UserData;
namespace Prospect.Server.Api.Services.CloudScript.Functions;
[CloudScriptFunction("RequestPlayerContracts")]
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;
_userDataService = userDataService;
_titleDataService = titleDataService;
}
public Task<FYGetPlayerContractsResult> ExecuteAsync(FYGetPlayerContractsRequest request)
public async Task<FYGetPlayerContractsResult> 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<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,
ActiveContracts = new List<FYActiveContractPlayerData>(),
ActiveContracts = contractsActive.Contracts,
FactionsContracts = new FYFactionsContractsData
{
Boards = new List<FYFactionContractsData>
{
new FYFactionContractsData
{
FactionId = "ICA",
Contracts = new List<FYFactionContractData>
{
new FYFactionContractData
{
ContractId = "NEW-Easy-ICA-Gather-1",
Boards = jobBoardsData.Boards,
LastBoardRefreshTimeUtc = jobBoardsData.LastBoardRefreshTimeUtc ?? new FYTimestamp { Seconds = now },
},
new FYFactionContractData
{
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
});
RefreshHours24UtcFromBackend = RefreshHours,
};
}
}
@@ -35,6 +35,11 @@ public class RequestStoreRotationDataFunction : ICloudScriptFunction<RequestStor
_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)
{
var context = _httpContextAccessor.HttpContext;
@@ -43,18 +48,33 @@ public class RequestStoreRotationDataFunction : ICloudScriptFunction<RequestStor
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
{
Error = "",
DailyStoreData = new StoreRotationData {
StoreID = "DailyShop",
ExpirationData = DateTime.UtcNow.AddDays(1)
StoreID = dailyStoreId,
ExpirationData = dailyExpiration
},
WeeklyStoreData = new StoreRotationData {
StoreID = "WeeklyShop",
ExpirationData = DateTime.UtcNow.AddDays(1)
StoreID = weeklyStoreId,
ExpirationData = weeklyExpiration
}
};
}
@@ -54,7 +54,7 @@ public class StartTechTreeNodeUpgradeClient : ICloudScriptFunction<FYStartTechTr
var userData = await _userDataService.FindAsync(
userId, userId,
new List<string>{"TechTreeNodeData", "Balance", "Inventory"}
new List<string>{"TechTreeNodeData", "Balance", "Inventory", "PlayerQuartersLevel"}
);
var userTechTree = JsonSerializer.Deserialize<UserTechTreeNodeData>(userData["TechTreeNodeData"].Value);
@@ -79,7 +79,15 @@ public class StartTechTreeNodeUpgradeClient : ICloudScriptFunction<FYStartTechTr
var techTreeNodeData = techTreeNodes[request.NodeID];
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
// TODO: Refactor
@@ -43,9 +43,9 @@ public class UpdateRetentionBonus : ICloudScriptFunction<FYRetentionBonusRequest
};
}
var currentMidnight = (int)new DateTimeOffset(DateTime.UtcNow.Date, TimeSpan.Zero).ToUnixTimeSeconds();
var nextClaimTime = retentionBonus.LastClaimTime.Seconds + 60 * 60 * 24;
if (currentMidnight < nextClaimTime) {
var now = (int)DateTimeOffset.UtcNow.ToUnixTimeSeconds();
var nextClaimTime = retentionBonus.LastClaimTime.Seconds + 60 * 60 * 24 * 2;
if (now < nextClaimTime) {
return new FYRetentionBonusResult
{
UserId = userId,
@@ -58,7 +58,7 @@ public class UpdateRetentionBonus : ICloudScriptFunction<FYRetentionBonusRequest
var bonusData = JsonSerializer.Deserialize<Dictionary<string, TitleDataRetentionBonusInfo>>(titleData["RetentionBonusData"]);
retentionBonus.DaysClaimed++;
retentionBonus.LastClaimTime.Seconds = currentMidnight;
retentionBonus.LastClaimTime.Seconds = now;
retentionBonus.ClaimedAll = retentionBonus.DaysClaimed >= 14;
// TODO: Currently only CB2 bonuses are considered
@@ -1,6 +1,4 @@
using System.Text.Json.Serialization;
using Microsoft.Extensions.Options;
using Prospect.Server.Api.Config;
using Prospect.Server.Api.Services.CloudScript.Models;
namespace Prospect.Server.Api.Services.CloudScript.Functions;
@@ -29,13 +27,6 @@ public class FYVivoxJoinTokenRequest
[CloudScriptFunction("VivoxJoin")]
public class VivoxJoin : ICloudScriptFunction<FYVivoxJoinTokenRequest, object>
{
private readonly VivoxConfig _settings;
public VivoxJoin(IOptions<VivoxConfig> settings)
{
_settings = settings.Value;
}
private int ReturnChannelType(string type) {
switch (type) {
case "GLOBAL": return 2;
@@ -50,7 +41,8 @@ public class VivoxJoin : ICloudScriptFunction<FYVivoxJoinTokenRequest, object>
public async Task<object> ExecuteAsync(FYVivoxJoinTokenRequest request)
{
var exp = (int)DateTimeOffset.UtcNow.AddDays(1).ToUnixTimeSeconds();
var token = TokenGenerator.vxGenerateToken(_settings.Key, _settings.Issuer, exp, "join", request.Username, request.Channel);
// TODO: Replace with app configuration
var token = TokenGenerator.vxGenerateToken("key", "issuer", exp, "join", request.Username, request.Channel);
return new FYVivoxJoinData
{
@@ -1,6 +1,4 @@
using Microsoft.Extensions.Options;
using Prospect.Server.Api.Config;
using Prospect.Server.Api.Services.CloudScript.Models;
using Prospect.Server.Api.Services.CloudScript.Models;
using System.Security.Cryptography;
using System.Text.Json;
@@ -86,18 +84,12 @@ public class TokenGenerator
[CloudScriptFunction("VivoxLogin")]
public class VivoxLogin : ICloudScriptFunction<FYVivoxLoginTokenRequest, FYVivoxJoinData>
{
private readonly VivoxConfig _settings;
public VivoxLogin(IOptions<VivoxConfig> settings)
{
_settings = settings.Value;
}
public async Task<FYVivoxJoinData> ExecuteAsync(FYVivoxLoginTokenRequest request)
{
var channel = $"sip:confctl-g-{_settings.Issuer}.testchannel@mtu1xp.vivox.com"; // e - echo channel, g - global, p - positional
// TODO: Replace with app configuration
var channel = "sip:confctl-g-<issuer>.testchannel@mtu1xp.vivox.com"; // e - echo channel, g - global, p - positional
var exp = (int)DateTimeOffset.UtcNow.AddSeconds(90).ToUnixTimeSeconds();
var token = TokenGenerator.vxGenerateToken(_settings.Key, _settings.Issuer, exp, "login", request.Username, channel);
var token = TokenGenerator.vxGenerateToken("key", "issuer", exp, "login", request.Username, channel);
return new FYVivoxJoinData
{
@@ -1,26 +1,18 @@
using Microsoft.Extensions.Options;
using Prospect.Server.Api.Config;
using Prospect.Server.Api.Services.CloudScript.Models;
using Prospect.Server.Api.Services.CloudScript.Models;
namespace Prospect.Server.Api.Services.CloudScript.Functions;
[CloudScriptFunction("VivoxSettings")]
public class VivoxSettings : ICloudScriptFunction<FYEmptyRequest, FYVivoxSettingsData>
{
private readonly VivoxConfig _settings;
public VivoxSettings(IOptions<VivoxConfig> settings)
{
_settings = settings.Value;
}
public Task<FYVivoxSettingsData> ExecuteAsync(FYEmptyRequest request)
{
return Task.FromResult(new FYVivoxSettingsData
{
VivoxDomain = _settings.Domain,
VivoxIssuer = _settings.Issuer,
VivoxServer = _settings.Server
// TODO: Replace with app configuration
// VivoxDomain = "",
// VivoxIssuer = "",
// VivoxServer = ""
});
}
}
@@ -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
{
// m_insuranceId
// m_itemIds
[JsonPropertyName("insuranceId")]
public string InsuranceId { get; set; }
[JsonPropertyName("itemIds")]
public List<string> ItemIds { get; set; }
}
File diff suppressed because one or more lines are too long
-2
View File
@@ -25,8 +25,6 @@ public class Startup
services.Configure<AuthTokenSettings>(Configuration.GetSection(nameof(AuthTokenSettings)));
services.Configure<DatabaseSettings>(Configuration.GetSection(nameof(DatabaseSettings)));
services.Configure<PlayFabSettings>(Configuration.GetSection(nameof(PlayFabSettings)));
services.Configure<VivoxConfig>(Configuration.GetSection(nameof(VivoxConfig)));
services.Configure<SignalRSettings>(Configuration.GetSection(nameof(SignalRSettings)));
services.AddSingleton<AuthTokenService>();
services.AddSingleton<UserDataService>();
+1 -17
View File
@@ -8,27 +8,11 @@
},
"PlayFabSettings": {
"PublisherId": "850902E5B40508ED",
"TitleId": "2EA46"
},
"SignalRSettings": {
"TitleId": "2EA46",
"SignalRURL": "https://127.0.0.1:8443/signalr/?hub=pubsub",
"SignalRAccessToken": "TEST"
},
"VivoxConfig": {
"Key": "",
"Issuer": "",
"Domain": "",
"Server": ""
},
"Kestrel": {
"Endpoints": {
"Https": {
"Url": "https://127.0.0.1:8443"
},
"Http": {
"Url": "http://127.0.0.1:8000"
}
},
"Certificates": {
"Default": {
"Path": ".\\certificate.pfx"