25 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
Yury d9bd46e6e0 Update README 2025-03-31 01:40:20 +03:00
Yury 269659b8da Stash changes 2025-03-31 01:39:22 +03:00
Yury 14097808d8 Add CompleteInventoryUpdate CloudScript function from closed beta 2025-03-31 01:38:58 +03:00
Yury 6fe4432c6e Add custom UpdatePlayerActiveContracts CloudScript function for contracts update 2025-03-31 01:38:35 +03:00
Yury 4f11c8e550 Change Prospect.Client.Loader output dir 2025-03-31 01:37:34 +03:00
Yury 1ac2374eff Update README 2025-03-31 01:37:16 +03:00
Yury b5cb885262 Improve loader
* Improve loading speed
* Change DLL loading order
* Add Prospect.Agent.dll
2025-03-31 01:37:00 +03:00
Yury d55ef94fd9 Update launchSettings for HTTP server 2025-03-31 01:35:20 +03:00
Yury 1f4640fccd Fix given amount of quest reward 2025-03-31 01:34:41 +03:00
Yury faf90c1841 Autocomplete only PvP quests now 2025-03-31 01:34:16 +03:00
Yury efbacc57d1 Add Prospect.Agent DLL for utility functions 2025-03-31 01:33:41 +03:00
Yury 458585c37f Add "common-name" command-line parameter to generate_ssl 2025-03-31 01:33:01 +03:00
Yury b3c5fa3e2b Disallow accepting already completed contracts 2025-03-31 01:32:15 +03:00
Yury 2fb730cb8c Temporary disable HTTPS redirection 2025-03-31 01:31:46 +03:00
Yury 87e1708d6b Fix inventory update on quarter level upgrade 2025-03-31 01:31:35 +03:00
Yury 2695f25242 Update contract info structure and title data
* Include information about kill conditions
* Use default client settings for MatchmakingSetup
2025-03-31 01:31:18 +03:00
Yury 81dea35778 Allow for configurable SignalR connection settings 2025-03-31 01:30:14 +03:00
Yury 704abac924 Add client version check 2025-03-31 01:29:00 +03:00
41 changed files with 1479 additions and 221 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"]
+14 -30
View File
@@ -9,11 +9,10 @@ Also known as "The Cycle: Frontier".
- [1. Prerequisites](#1-prerequisites)
- [1.1 How to download Season 2 client from SteamDB using Steam console](#11-how-to-download-season-2-client-from-steamdb-using-steam-console)
- [2. Unpack `Prospect.Server.Api`](#2-unpack-prospectserverapi)
- [3. Patch the `hosts` file](#3-patch-the-hosts-file)
- [4. Generate and import SSL certificate](#4-generate-and-import-ssl-certificate)
- [5. Extract `LoaderPack` to the game](#5-extract-loaderpack-to-the-game)
- [6. Run the server](#6-run-the-server)
- [7. Run the game](#7-run-the-game)
- [3. Generate and import SSL certificate](#3-generate-and-import-ssl-certificate)
- [4. Extract `LoaderPack` to the game](#4-extract-loaderpack-to-the-game)
- [5. Run the server](#5-run-the-server)
- [6. Run the game](#6-run-the-game)
- [Troubleshooting and FAQ](#troubleshooting-and-faq)
- [How to remove the certificate?](#how-to-remove-the-certificate)
- [`generate_ssl.exe` is flagged as a virus](#generate_sslexe-is-flagged-as-a-virus)
@@ -55,15 +54,13 @@ Also known as "The Cycle: Frontier".
* [x] Vivox login
* [x] Vivox create and join channel
* [ ] Proximity voice works
* [ ] Game mechanics
* [x] Game mechanics
* [x] Can deploy through terminal
* [x] Can deploy with loadout
* [x] Can evac
* [ ] Can do quests
* [x] Delivery quests
* [ ] Kill quests
* [x] Can do quests (except PvP)
* [x] Can gain/lose loot
* [x] Can use Alien Forge.
* [x] Can use Alien Forge
* [x] Map content
* [x] Bright Sands
* [x] Crescent Falls
@@ -122,17 +119,7 @@ Before you start, you'll need the following software downloaded and installed:
Use your favorite ZIP archiver and unzip the `Prospect.Server.Api.zip` downloaded from this repository.
### 3. Patch the `hosts` file
To be able to connect to the locally running server, you must replace the IP address of `2EA46.playfabapi.com` that the game uses. Do the following:
1. Open Notepad as Administrator.
1. Click **File** > **Open...** and select `C:\Windows\System32\drivers\etc\hosts`.
1. At the end of the file, add `127.0.0.1 2EA46.playfabapi.com` on a new line. Do not add the `#` character!
### 4. Generate and import SSL certificate
### 3. Generate and import SSL certificate
> [!IMPORTANT]
> Do not share the generated certificate! Generated certificate includes a private key that may be used to generate other certificates and compromise your security.
@@ -156,7 +143,7 @@ to successfully communicate with the local server. Do the following:
1. Click **Finish**. A **Security Warning** popup may appear, make sure it specifies `2EA46.playfabapi.com` certification authority and click **Yes**.
### 5. Extract `LoaderPack` to the game
### 4. Extract `LoaderPack` to the game
1. Open the folder with The Cycle: Frontier and navigate to **Prospect** > **Binaries** > **Win64**.
@@ -166,14 +153,14 @@ to successfully communicate with the local server. Do the following:
1. Create a shortcut for the `Prospect.Client.Loader` that you will use later to launch the game.
### 6. Run the server
### 5. Run the server
Now you are all set! Open the folder with `Prospect.Server.Api` and run `Prospect.Server.Api.exe`. It will open a console if it runs successfully.
> [!IMPORTANT]
> Do not close the console when you run the game.
### 7. Run the game
### 6. Run the game
Once the server is running, make sure that Steam is running and open The Cycle: Frontier using the shortcut you've created before.
@@ -214,17 +201,14 @@ Make sure that:
### Login Failed. Error code: 5
Make sure that:
Make sure that `Prospect.Server.Api` server is running.
* `Prospect.Server.Api` server is running.
* The `C:\Windows\System32\drivers\etc\hosts` file contains `127.0.0.1 2EA46.playfabapi.com` and that the file is **saved**.
If the server works and the `hosts` file is saved, press `Alt+Tab` to a game console that opens when you start the game and check for the following:
If the server is running, press `Alt+Tab` to a game console that opens when you start the game and check for the following:
* `libcurl error 7 (Couldn't connect to server)` - indicates that the `Prospect.Server.Api` is not running.
![](./_assets/connection_refused_error.png)
* `InvalidAPIEndpoint` - indicates that the `hosts` file was not updated properly.
* `InvalidAPIEndpoint` - indicates that you are running the game using the original shortcut and not using `Prospect.Client.Loader`.
![](./_assets/invalid_api_endpoint.PNG)
* `libcurl error 60 (Peer certificate cannot be authenticated with given CA certificates)` - indicates that the certificate was not installed correctly. Make sure that the certificate is present in `certmgr.msc` and there is only one certificate. Try removing the certificate and importing it again by following step 4.
+119
View File
@@ -0,0 +1,119 @@
<?xml version="1.0" encoding="utf-8"?>
<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<ItemGroup Label="ProjectConfigurations">
<ProjectConfiguration Include="Debug|x64">
<Configuration>Debug</Configuration>
<Platform>x64</Platform>
</ProjectConfiguration>
<ProjectConfiguration Include="Release|x64">
<Configuration>Release</Configuration>
<Platform>x64</Platform>
</ProjectConfiguration>
</ItemGroup>
<PropertyGroup Label="Globals">
<VCProjectVersion>16.0</VCProjectVersion>
<Keyword>Win32Proj</Keyword>
<ProjectGuid>{a9ba7d25-f239-4320-a0dc-85e8001fd669}</ProjectGuid>
<RootNamespace>ProspectAgent</RootNamespace>
<WindowsTargetPlatformVersion>10.0</WindowsTargetPlatformVersion>
</PropertyGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" />
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|x64'" Label="Configuration">
<ConfigurationType>DynamicLibrary</ConfigurationType>
<UseDebugLibraries>true</UseDebugLibraries>
<PlatformToolset>v143</PlatformToolset>
<CharacterSet>Unicode</CharacterSet>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|x64'" Label="Configuration">
<ConfigurationType>DynamicLibrary</ConfigurationType>
<UseDebugLibraries>false</UseDebugLibraries>
<PlatformToolset>v143</PlatformToolset>
<WholeProgramOptimization>true</WholeProgramOptimization>
<CharacterSet>Unicode</CharacterSet>
</PropertyGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" />
<ImportGroup Label="ExtensionSettings">
</ImportGroup>
<ImportGroup Label="Shared">
</ImportGroup>
<ImportGroup Label="PropertySheets" Condition="'$(Configuration)|$(Platform)'=='Debug|x64'">
<Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" />
</ImportGroup>
<ImportGroup Label="PropertySheets" Condition="'$(Configuration)|$(Platform)'=='Release|x64'">
<Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" />
</ImportGroup>
<PropertyGroup Label="UserMacros" />
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|x64'">
<LinkIncremental>true</LinkIncremental>
<OutDir>$(ProjectDir)bin\$(Platform)\$(Configuration)\</OutDir>
<IntDir>$(ProjectDir)bin\intermediate\$(Platform)\$(Configuration)\</IntDir>
<LibraryPath>$(LibraryPath)</LibraryPath>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|x64'">
<LinkIncremental>false</LinkIncremental>
<OutDir>$(ProjectDir)bin\$(Platform)\$(Configuration)\</OutDir>
<IntDir>$(ProjectDir)bin\intermediate\$(Platform)\$(Configuration)\</IntDir>
<LibraryPath>$(LibraryPath)</LibraryPath>
</PropertyGroup>
<PropertyGroup Label="Vcpkg" Condition="'$(Configuration)|$(Platform)'=='Debug|x64'">
<VcpkgUseStatic>true</VcpkgUseStatic>
</PropertyGroup>
<PropertyGroup Label="Vcpkg" Condition="'$(Configuration)|$(Platform)'=='Release|x64'">
<VcpkgUseStatic>true</VcpkgUseStatic>
</PropertyGroup>
<PropertyGroup Label="Vcpkg">
<VcpkgEnableManifest>true</VcpkgEnableManifest>
</PropertyGroup>
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Debug|x64'">
<ClCompile>
<WarningLevel>Level3</WarningLevel>
<SDLCheck>true</SDLCheck>
<PreprocessorDefinitions>_DEBUG;PROSPECTAGENT_EXPORTS;_WINDOWS;_USRDLL;%(PreprocessorDefinitions)</PreprocessorDefinitions>
<ConformanceMode>true</ConformanceMode>
<PrecompiledHeader>NotUsing</PrecompiledHeader>
<PrecompiledHeaderFile>pch.h</PrecompiledHeaderFile>
<AdditionalIncludeDirectories>%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories>
</ClCompile>
<Link>
<SubSystem>Windows</SubSystem>
<GenerateDebugInformation>true</GenerateDebugInformation>
<EnableUAC>false</EnableUAC>
<AdditionalDependencies>%(AdditionalDependencies)</AdditionalDependencies>
</Link>
</ItemDefinitionGroup>
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Release|x64'">
<ClCompile>
<WarningLevel>Level3</WarningLevel>
<FunctionLevelLinking>true</FunctionLevelLinking>
<IntrinsicFunctions>true</IntrinsicFunctions>
<SDLCheck>true</SDLCheck>
<PreprocessorDefinitions>NDEBUG;PROSPECTAGENT_EXPORTS;_WINDOWS;_USRDLL;%(PreprocessorDefinitions)</PreprocessorDefinitions>
<ConformanceMode>true</ConformanceMode>
<PrecompiledHeader>NotUsing</PrecompiledHeader>
<PrecompiledHeaderFile>pch.h</PrecompiledHeaderFile>
<AdditionalIncludeDirectories>%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories>
<Optimization>MinSpace</Optimization>
</ClCompile>
<Link>
<SubSystem>Windows</SubSystem>
<EnableCOMDATFolding>true</EnableCOMDATFolding>
<OptimizeReferences>true</OptimizeReferences>
<GenerateDebugInformation>true</GenerateDebugInformation>
<EnableUAC>false</EnableUAC>
<AdditionalDependencies>%(AdditionalDependencies)</AdditionalDependencies>
</Link>
</ItemDefinitionGroup>
<ItemGroup>
<ClInclude Include="hooks.h" />
<ClInclude Include="logger.h" />
<ClInclude Include="SDK.h" />
<ClInclude Include="SDK_Memory.h" />
</ItemGroup>
<ItemGroup>
<ClCompile Include="logger.cpp" />
<ClCompile Include="main.cpp" />
</ItemGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />
<ImportGroup Label="ExtensionTargets">
</ImportGroup>
</Project>
@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<ItemGroup>
<Filter Include="Source Files">
<UniqueIdentifier>{4FC737F1-C7A5-4376-A066-2A32D752A2FF}</UniqueIdentifier>
<Extensions>cpp;c;cc;cxx;c++;cppm;ixx;def;odl;idl;hpj;bat;asm;asmx</Extensions>
</Filter>
<Filter Include="Header Files">
<UniqueIdentifier>{93995380-89BD-4b04-88EB-625FBE52EBFB}</UniqueIdentifier>
<Extensions>h;hh;hpp;hxx;h++;hm;inl;inc;ipp;xsd</Extensions>
</Filter>
<Filter Include="Resource Files">
<UniqueIdentifier>{67DA6AB6-F800-4c08-8B7A-83BB121AAD01}</UniqueIdentifier>
<Extensions>rc;ico;cur;bmp;dlg;rc2;rct;bin;rgs;gif;jpg;jpeg;jpe;resx;tiff;tif;png;wav;mfcribbon-ms</Extensions>
</Filter>
</ItemGroup>
<ItemGroup>
<ClInclude Include="logger.h">
<Filter>Header Files</Filter>
</ClInclude>
<ClInclude Include="SDK.h">
<Filter>Header Files</Filter>
</ClInclude>
<ClInclude Include="hooks.h">
<Filter>Header Files</Filter>
</ClInclude>
<ClInclude Include="SDK_Memory.h">
<Filter>Header Files</Filter>
</ClInclude>
</ItemGroup>
<ItemGroup>
<ClCompile Include="main.cpp">
<Filter>Source Files</Filter>
</ClCompile>
<ClCompile Include="logger.cpp">
<Filter>Source Files</Filter>
</ClCompile>
</ItemGroup>
</Project>
+105
View File
@@ -0,0 +1,105 @@
#pragma once
#include <cstdint>
#include <locale>
#include <string>
#include "SDK_Memory.h"
namespace SDK
{
template<class T>
struct TArray
{
friend struct FString;
TArray()
{
Data = nullptr;
Count = Max = 0;
}
int Num() const
{
return Count;
}
T& operator[](int i)
{
return Data[i];
}
const T& operator[](int i) const
{
return Data[i];
}
bool IsValidIndex(int i) const
{
return i < Num();
}
private:
T* Data;
int32_t Count;
int32_t Max;
};
struct FString : private TArray<wchar_t>
{
FString(const wchar_t* other)
{
const size_t charLen = std::wcslen(other);
if (charLen == 0)
{
Count = 0;
Max = 0;
Data = nullptr;
}
else
{
Count = charLen + 1;
Max = Count;
Data = static_cast<wchar_t*>((*GMalloc)->Malloc(Count * 2));
memset(Data, 0, Count * 2);
memcpy(Data, other, charLen * 2);
}
}
FString(const std::wstring& other) : FString(other.c_str())
{
}
bool IsValid() const
{
return Data != nullptr;
}
const wchar_t* c_str() const
{
return Data;
}
std::string ToString() const
{
const auto length = std::wcslen(Data);
std::string str(length, '\0');
std::use_facet<std::ctype<wchar_t>>(std::locale()).narrow(Data, Data + length, '?', &str[0]);
return str;
}
};
struct UPlayFabAPISettings
{
FString VerticalName;
FString BaseServiceHost;
FString TitleId;
FString AdvertisingIdType;
FString AdvertisingIdValue;
bool DisableAdvertising = false;
};
}
+79
View File
@@ -0,0 +1,79 @@
#pragma once
#include <cstdint>
#define DEFAULT_ALIGNMENT 0
namespace SDK
{
class FExec
{
public:
virtual ~FExec() = default;
private:
virtual bool Exec(void* world, void* cmd, void* ar) = 0;
};
class FMalloc : FExec
{
public:
virtual void* Malloc(SIZE_T Count, uint32_t Alignment = DEFAULT_ALIGNMENT) = 0;
virtual void* TryMalloc(SIZE_T Count, uint32_t Alignment = DEFAULT_ALIGNMENT) = 0;
virtual void* Realloc(void* Original, SIZE_T Count, uint32_t Alignment = DEFAULT_ALIGNMENT) = 0;
virtual void* TryRealloc(void* Original, SIZE_T Count, uint32_t Alignment = DEFAULT_ALIGNMENT) = 0;
virtual void Free(void* Original) = 0;
virtual SIZE_T QuantizeSize(SIZE_T Count, uint32_t Alignment)
{
return Count;
}
virtual bool GetAllocationSize(void* Original, SIZE_T& SizeOut)
{
return false;
}
virtual void Trim(bool bTrimThreadCaches)
{
}
virtual void SetupTLSCachesOnCurrentThread()
{
}
virtual void ClearAndDisableTLSCachesOnCurrentThread()
{
}
virtual void InitializeStatsMetadata();
virtual bool Exec(void* InWorld, void* Cmd, void* Ar) override
{
return false;
}
virtual void UpdateStats() = 0;
virtual void GetAllocatorStats(void* out_Stats) = 0;
virtual void DumpAllocatorStats(class FOutputDevice& Ar) = 0;
virtual bool IsInternallyThreadSafe() const
{
return false;
}
virtual bool ValidateHeap()
{
return(true);
}
virtual const TCHAR* GetDescriptiveName() = 0;
};
FMalloc** GMalloc;
}
+3
View File
@@ -0,0 +1,3 @@
#pragma once
#include "SDK.h"
+85
View File
@@ -0,0 +1,85 @@
#include "logger.h"
#include <cstdio>
#include <Windows.h>
HANDLE h_out = nullptr, h_old_out = nullptr;
HANDLE h_err = nullptr, h_old_err = nullptr;
HANDLE h_in = nullptr, h_old_in = nullptr;
namespace logger
{
void Attach()
{
h_old_out = GetStdHandle(STD_OUTPUT_HANDLE);
h_old_err = GetStdHandle(STD_ERROR_HANDLE);
h_old_in = GetStdHandle(STD_INPUT_HANDLE);
AllocConsole() && AttachConsole(GetCurrentProcessId());
h_out = GetStdHandle(STD_OUTPUT_HANDLE);
h_err = GetStdHandle(STD_ERROR_HANDLE);
h_in = GetStdHandle(STD_INPUT_HANDLE);
SetConsoleMode(h_out,
ENABLE_PROCESSED_OUTPUT | ENABLE_WRAP_AT_EOL_OUTPUT);
SetConsoleMode(h_in,
ENABLE_INSERT_MODE | ENABLE_EXTENDED_FLAGS |
ENABLE_PROCESSED_INPUT | ENABLE_QUICK_EDIT_MODE);
}
void Detach()
{
if (h_out && h_err && h_in) {
FreeConsole();
if (h_old_out)
{
SetStdHandle(STD_OUTPUT_HANDLE, h_old_out);
}
if (h_old_err)
{
SetStdHandle(STD_ERROR_HANDLE, h_old_err);
}
if (h_old_in)
{
SetStdHandle(STD_INPUT_HANDLE, h_old_in);
}
}
}
bool Print(const char* fmt, ...)
{
if (!h_out)
{
return false;
}
char buf[1024];
va_list va;
va_start(va, fmt);
_vsnprintf_s(buf, 1024, fmt, va);
va_end(va);
return !!WriteConsoleA(h_out, buf, static_cast<DWORD>(strlen(buf)), nullptr, nullptr);
}
char ReadKey()
{
if (!h_in)
{
return -1;
}
auto key = char{ 0 };
auto keysread = DWORD{ 0 };
ReadConsoleA(h_in, &key, 1, &keysread, nullptr);
return key;
}
};
+12
View File
@@ -0,0 +1,12 @@
#pragma once
namespace logger
{
void Attach();
void Detach();
bool Print(const char* fmt, ...);
char ReadKey();
}
+135
View File
@@ -0,0 +1,135 @@
#define WIN32_LEAN_AND_MEAN
#include <Windows.h>
#include <MinHook.h>
#include <cstdio>
#include "logger.h"
#include "SDK.h"
#include "SDK_Memory.h"
#include <string>
#include <fstream>
#include <sstream>
#include <algorithm>
#include <cctype>
#include <locale>
std::wstring g_backendUrl;
// trim from start (in place)
inline void ltrim(std::wstring &s) {
s.erase(s.begin(), std::find_if(s.begin(), s.end(), [](unsigned char ch) {
return !std::isspace(ch);
}));
}
// trim from end (in place)
inline void rtrim(std::wstring &s) {
s.erase(std::find_if(s.rbegin(), s.rend(), [](unsigned char ch) {
return !std::isspace(ch);
}).base(), s.end());
}
inline void trim(std::wstring &s) {
rtrim(s);
ltrim(s);
}
constexpr uintptr_t OffsetGMalloc = 0x63EC4A0;
/**
* Function offset, find with "?sdk=".
*/
constexpr uintptr_t OffsetPlayFabApiGetUrl = 0xCEEF00;
/**
* Function hooks.
*/
typedef SDK::FString(__fastcall* tPlayFabApiGetUrl)(SDK::UPlayFabAPISettings*, const SDK::FString&);
tPlayFabApiGetUrl OldPlayFabApiGetUrl;
SDK::FString __fastcall PlayFabApiGetUrlProxy(SDK::UPlayFabAPISettings* thiz, const SDK::FString& callPath) {
logger::Print("[Agent] GetURL called with callPath %s\n", callPath.ToString().c_str());
if (!g_backendUrl.empty())
{
return g_backendUrl + std::wstring(callPath.c_str());
}
return std::wstring(L"https://127.0.0.1:8443") + std::wstring(callPath.c_str());
}
DWORD WINAPI OnDllAttach(LPVOID base) {
logger::Print("[Agent] DLL Attached\n");
const auto hModule = GetModuleHandleW(nullptr);
const auto hModulePtr = reinterpret_cast<uintptr_t>(hModule);
// Initialize GMalloc.
SDK::GMalloc = reinterpret_cast<SDK::FMalloc**>(hModulePtr + OffsetGMalloc);
// Initialize MinHook.
if (MH_Initialize() != MH_OK) {
logger::Print("[Agent] Failed to initialize MinHook\n");
FreeLibraryAndExitThread(hModule, 1);
}
// Hook UPlayFabAPISettings::GetUrl.
const auto pTarget = reinterpret_cast<LPVOID>(hModulePtr + OffsetPlayFabApiGetUrl);
const auto ppOriginal = reinterpret_cast<LPVOID*>(&OldPlayFabApiGetUrl);
const auto mhStatus = MH_CreateHook(pTarget, &PlayFabApiGetUrlProxy, ppOriginal);
if (mhStatus != MH_OK) {
logger::Print("[Agent] Failed to create PlayFabAPIGetUrl hook (%d)\n", mhStatus);
FreeLibraryAndExitThread(hModule, 1);
}
if (MH_EnableHook(pTarget) != MH_OK) {
logger::Print("[Agent] Failed to enable PlayFabAPIGetUrl hook\n");
FreeLibraryAndExitThread(hModule, 1);
}
const wchar_t* filePath = L"backend.txt";
std::wifstream f(filePath);
if (f) {
std::wstringstream buffer;
buffer << f.rdbuf();
g_backendUrl = buffer.str();
trim(g_backendUrl);
if (g_backendUrl.rfind(L"https://", 0) != 0) {
logger::Print("[Agent] URL must start with https://\n");
g_backendUrl.clear();
} else {
logger::Print("[Agent] Loaded custom URL: %S\n", g_backendUrl.c_str());
}
} else {
logger::Print("[Agent] Failed to open file: %S\n", filePath);
}
logger::Print("[Agent] DLL Initialized\n");
ExitThread(1);
}
BOOL WINAPI DllMain(
const _In_ HINSTANCE hinstDll,
const _In_ DWORD fdwReason,
const _In_opt_ LPVOID lpvReserved
) {
if (fdwReason == DLL_PROCESS_ATTACH) {
DisableThreadLibraryCalls(hinstDll);
// Allocate a console.
AllocConsole();
freopen_s(reinterpret_cast<FILE**>(stdout), "CONOUT$", "w", stdout);
// Attach logger.
logger::Attach();
// Spawn thread.
CreateThread(nullptr, 0, OnDllAttach, hinstDll, 0, nullptr);
} else if (fdwReason == DLL_PROCESS_DETACH) {
// Detach logger.
logger::Detach();
}
return TRUE;
}
@@ -0,0 +1,14 @@
{
"default-registry": {
"kind": "git",
"baseline": "2960d7d80e8d09c84ae8abf15c12196c2ca7d39a",
"repository": "https://github.com/microsoft/vcpkg"
},
"registries": [
{
"kind": "artifact",
"location": "https://github.com/microsoft/vcpkg-ce-catalog/archive/refs/heads/main.zip",
"name": "microsoft"
}
]
}
+5
View File
@@ -0,0 +1,5 @@
{
"dependencies": [
"minhook"
]
}
@@ -70,6 +70,9 @@
<Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" />
</ImportGroup>
<PropertyGroup Label="UserMacros" />
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|x64'">
<OutDir>$(SolutionDir)\$(ProjectName)\$(Platform)\$(Configuration)\</OutDir>
</PropertyGroup>
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Debug|Win32'">
<ClCompile>
<WarningLevel>Level3</WarningLevel>
+41 -20
View File
@@ -2,7 +2,14 @@
#include <tlhelp32.h>
#include <stdio.h>
#include <stdint.h>
#include <string.h>
#include <string>
std::wstring ExePath() {
TCHAR buffer[MAX_PATH] = { 0 };
GetModuleFileName( NULL, buffer, MAX_PATH );
std::wstring::size_type pos = std::wstring(buffer).find_last_of(L"\\/");
return std::wstring(buffer).substr(0, pos);
}
// Function to create and launch a process with parameters
HANDLE StartProcess(const wchar_t* exePath, DWORD* processID) {
@@ -22,7 +29,20 @@ HANDLE StartProcess(const wchar_t* exePath, DWORD* processID) {
return pi.hProcess;
}
else {
printf("Failed to start process. Error: %lu\n", GetLastError());
wchar_t szErrorMessage[512];
DWORD dwErrorCode = GetLastError();
FormatMessage(
FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS,
NULL,
dwErrorCode,
0,
szErrorMessage,
sizeof(szErrorMessage),
NULL
);
printf("Failed to start process. Error: %lu: %ws\n", dwErrorCode, szErrorMessage);
return NULL;
}
}
@@ -158,7 +178,7 @@ bool PatchForceLevelStreaming(HANDLE hProcess, uintptr_t imageBase) {
}
if (!found) {
printf("Failed to patch force level streaming!");
printf("Failed to patch force level streaming!\n");
return false;
}
@@ -270,7 +290,7 @@ bool PatchUpdateInventory(HANDLE hProcess, uintptr_t imageBase) {
}
if (!found) {
printf("Failed to patch inventory update function!");
printf("Failed to patch inventory update function!\n");
return false;
}
@@ -322,7 +342,7 @@ bool PatchLoadPlayerContracts(HANDLE hProcess, uintptr_t imageBase) {
uintptr_t address = imageBase + 0x171E9EC;
bool found = CheckUpdatePlayerContractsOffset(hProcess, address);
if (!found) {
printf("Failed to update player contracts function!");
printf("Failed to update player contracts function!\n");
return false;
}
@@ -337,7 +357,7 @@ bool PatchLoadPlayerContracts(HANDLE hProcess, uintptr_t imageBase) {
address = imageBase + 0x1706127;
found = CheckSetupPlayerContractsOffset(hProcess, address);
if (!found) {
printf("Failed to find setup player contracts function!");
printf("Failed to find setup player contracts function!\n");
return false;
}
@@ -356,8 +376,7 @@ bool PatchLoadPlayerContracts(HANDLE hProcess, uintptr_t imageBase) {
return true;
}
bool Inject(HANDLE hProcess) {
const wchar_t* dllPath = L"UE4SS.dll";
bool Inject(HANDLE hProcess, const wchar_t* dllPath) {
// Allocate space in the target process for the DLL path
size_t pathSize = (wcslen(dllPath) + 1) * sizeof(wchar_t);
LPVOID remotePath = VirtualAllocEx(hProcess, NULL, pathSize, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
@@ -401,21 +420,25 @@ bool Inject(HANDLE hProcess) {
bool Patch(const wchar_t* processName) {
DWORD processID;
uintptr_t imageBase;
HANDLE hProcess = StartProcess(processName, &processID);
if (hProcess == NULL) {
return false;
}
// Wait a bit for PE image to load
Sleep(5000);
// Find the PE Image Base Address
imageBase = GetModuleBaseAddress(processID);
if (imageBase == 0) {
printf("Failed to find PE Image Base Address.\n");
if (!Inject(hProcess, L"Prospect.Agent.dll")) {
return false;
}
if (!Inject(hProcess, L"UE4SS.dll")) {
return false;
}
uintptr_t imageBase;
// Wait a bit for PE image to load
Sleep(250);
while ((imageBase = GetModuleBaseAddress(processID)) == 0) {
printf("Failed to find PE Image Base Address. Retrying...\n");
Sleep(250);
}
printf("PE Image Base Address: 0x%p\n", (void*)imageBase);
bool success = !PatchForceLevelStreaming(hProcess, imageBase) || !PatchUpdateInventory(hProcess, imageBase) || !PatchLoadPlayerContracts(hProcess, imageBase);
@@ -423,10 +446,6 @@ bool Patch(const wchar_t* processName) {
return false;
}
if (!Inject(hProcess)) {
return false;
}
// Clean up
CloseHandle(hProcess);
return true;
@@ -459,6 +478,8 @@ bool WriteSteamAppIDFile() {
}
int main() {
printf("Current working directory: %ws\n", ExePath().c_str());
bool success = WriteSteamAppIDFile();
if (!success) {
// Wait for user input to exit
@@ -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; }
}
@@ -33,6 +33,24 @@ public class TitleDataContractInfoObjective {
public int MaxProgress { get; set; }
[JsonPropertyName("ItemToOwn")]
public string ItemToOwn { get; set; }
[JsonPropertyName("KillConditions")]
public TitleDataContractInfoObjectiveKillConditions? KillConditions { get; set; }
}
public class TitleDataContractInfoObjectiveKillConditions
{
[JsonPropertyName("KillTarget")]
public EYKillTypeAction KillTarget { get; set; }
[JsonPropertyName("AllowedWeaponCategories")]
public EYDeviceCategory[] AllowedWeaponCategories { get; set; }
[JsonPropertyName("AllowedSpecificWeapons")]
public string[] AllowedSpecificWeapons { get; set; }
[JsonPropertyName("SpecificAIEnemyTypeToKill")]
public EYEnemyType SpecificAIEnemyTypeToKill { get; set; }
[JsonPropertyName("MapName")]
public string MapName { get; set; }
[JsonPropertyName("OnlyDuringStorm")]
public bool OnlyDuringStorm { get; set; }
}
public class TitleDataContractInfoUnlockData {
@@ -62,3 +80,60 @@ public enum EYContractDifficulty
Hard = 3,
MAX = 4
};
public enum EYDeviceCategory
{
AssaultRifle = 0,
Pistol = 1,
SMG = 2,
SniperRifle = 3,
HeavyWeapon = 4,
Shotgun = 5,
BurstRifle = 6,
Exotic = 7,
MissileLauncher = 8,
Scanner = 9,
All = 10,
INVALID = 11,
MAX = 12
};
public enum EYKillTypeAction
{
Invalid = 0,
Players = 1,
Creatures = 2,
All = 3,
MAX = 4
};
public enum EYEnemyType
{
None = 0,
DebugAutomationTest = 1,
DirtBeast_Melee = 2,
DirtBeast_RangedShort = 3,
DirtBeast_RangedMedium = 4,
DirtBeast_RangedLong = 5,
DirtBeast_MeleeHeavy = 6,
DirtBeast_RangedHeavy = 7,
DirtBeast_FlyingHeavy = 8,
DirtBeast_Boss = 9,
Orobot_Melee = 10,
Orobot_RangedShort = 11,
Orobot_RangedMedium = 12,
Orobot_Walker = 13,
Orobot_Platform = 14,
Plunderbot_RangedShort = 15,
Plunderbot_RangedMedium = 16,
Plunderbot_RangedLong = 17,
GlowBeetle_Blast = 18,
GlowBeetle_Acid = 19,
GlowBeetle_Summon = 20,
Strider = 21,
Rattler = 22,
Weremole = 23,
Crusher = 24,
Howler = 25,
MAX = 26
};
@@ -7,7 +7,7 @@
"ASPNETCORE_Kestrel__Certificates__Default__Path": ".\\certificate.pfx"
},
"dotnetRunMessages": true,
"applicationUrl": "https://2EA46.playfabapi.com:443",
"applicationUrl": "https://127.0.0.1:8443;http://127.0.0.1:8000",
"useSSL": true
},
"IIS Express": {
@@ -7,7 +7,8 @@
"ASPNETCORE_Kestrel__Certificates__Default__Path": ".\\certificate.pfx"
},
"dotnetRunMessages": true,
"applicationUrl": "https://2EA46.playfabapi.com"
"applicationUrl": "https://127.0.0.1:8443;http://127.0.0.1:8000",
"useSSL": true
},
"IIS Express": {
"commandName": "IISExpress",
@@ -1,4 +1,4 @@
using System.Text.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
using Prospect.Server.Api.Services.Auth.Extensions;
using Prospect.Server.Api.Services.CloudScript.Models.Data;
@@ -55,7 +55,7 @@ public class ActivateContract : ICloudScriptFunction<FYActivateContractRequest,
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>{"ContractsActive"});
var userData = await _userDataService.FindAsync(userId, userId, new List<string>{"ContractsActive", "ContractsOneTimeCompleted"});
var titleData = _titleDataService.Find(new List<string>{"Contracts"});
var contracts = JsonSerializer.Deserialize<Dictionary<string, TitleDataContractInfo>>(titleData["Contracts"]);
@@ -67,13 +67,22 @@ public class ActivateContract : ICloudScriptFunction<FYActivateContractRequest,
Status = 3 // EYActivateContractRequestStatus::FAILED_GETTING_STATIC_DATA
};
}
var contractsCompleted = JsonSerializer.Deserialize<FYGetCompletedContractsResult>(userData["ContractsOneTimeCompleted"].Value);
if (contractsCompleted.ContractsIDs.Contains(request.ContractID)) {
return new FYActivateContractResult
{
UserID = userId,
Error = "Contract is already completed",
Status = 1 // EYActivateContractRequestStatus::CONTRACT_OF_THIS_FACTION_IS_ALREADY_ACTIVE
};
}
// TODO: Contract unlock criteria
var progress = new int[contract.Objectives.Length];
// TODO: Automatically set max progress of kill and visit objectives. To remove once kill and visit objectives are reported by the client.
// NOTE: Automatically set max progress for player kill objectives.
for (var i = 0; i < contract.Objectives.Length; i++) {
var objective = contract.Objectives[i];
if (objective.Type != EYContractObjectiveType.OwnNumOfItem) {
if (objective.Type == EYContractObjectiveType.Kills && objective.KillConditions.KillTarget == EYKillTypeAction.Players) {
progress[i] = objective.MaxProgress;
}
}
@@ -83,6 +92,7 @@ public class ActivateContract : ICloudScriptFunction<FYActivateContractRequest,
ContractID = request.ContractID,
Progress = progress,
};
// TODO: Max number of active jobs
contractsActive.Contracts.Add(contractData);
await _userDataService.UpdateAsync(
userId, userId,
@@ -32,18 +32,13 @@ public class CheckClientVersionUpToDateFunction : ICloudScriptFunction<CheckClie
_logger.LogInformation("Processing client version check request for client {Version}",
request.Version);
// TODO: Validate login
// if (string.IsNullOrEmpty(request.LoginNonce))
// {
// return new CheckClientVersionUpToDateResponse
// {
// IsClientUpToDate = false,
// };
// }
return new CheckClientVersionUpToDateResponse
{
#if SEASON_2_RELEASE || SEASON_2_DEBUG
IsClientUpToDate = request.Version == "4.27.2-1413204+PROSPECT-Releases-R2.7.0",
#else
IsClientUpToDate = true,
#endif
};
}
}
@@ -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.
@@ -171,7 +177,7 @@ public class ClaimActiveContract : ICloudScriptFunction<FYClaimCompletedActiveCo
// TODO: Inventory limit check
var blueprintData = blueprints[reward.ItemID];
var remainingAmount = reward.Amount * blueprintData.AmountPerPurchase;
var remainingAmount = reward.Amount; // Quest rewards are given individually
// Populate incomplete stacks first
foreach (var item in newInventory) {
@@ -243,12 +249,15 @@ public class ClaimActiveContract : ICloudScriptFunction<FYClaimCompletedActiveCo
// TODO: Check for next unlock in contracts instead of relying on client
var newContracts = new List<FYActiveContractPlayerData>();
foreach (var contractIdToUnlock in request.ContractsToUnlock) {
if (contractsCompleted.ContractsIDs.Contains(contractIdToUnlock)) {
continue;
}
var contractToUnlock = contracts[contractIdToUnlock];
var progress = new int[contractToUnlock.Objectives.Length];
// TODO: Automatically set max progress of kill and visit objectives. To remove once kill and visit objectives are reported by the client.
// NOTE: Automatically set max progress for player kill objectives.
for (var i = 0; i < contractToUnlock.Objectives.Length; i++) {
var objective = contractToUnlock.Objectives[i];
if (objective.Type != EYContractObjectiveType.OwnNumOfItem) {
if (objective.Type == EYContractObjectiveType.Kills && objective.KillConditions.KillTarget == EYKillTypeAction.Players) {
progress[i] = objective.MaxProgress;
}
}
@@ -0,0 +1,154 @@
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;
// Closed Beta Function
public class CompleteInventoryUpdateRequest
{
[JsonPropertyName("userId")]
public string UserID { get; set; }
[JsonPropertyName("reason")]
public string Reason { get; set; }
[JsonPropertyName("newSet")]
public LoadoutData NewSet { get; set; }
[JsonPropertyName("itemsToAdd")]
public FYCustomItemInfo[] ItemsToAdd { get; set; }
[JsonPropertyName("itemsToUpdateAmount")]
public FYCustomItemInfo[] ItemsToUpdateAmount { get; set; }
[JsonPropertyName("itemsToRemove")]
public HashSet<string> ItemsToRemove { get; set; }
}
public class CompleteInventoryUpdateResponse
{
[JsonPropertyName("userId")]
public string UserId { get; set; }
[JsonPropertyName("error")]
public string Error { get; set; }
[JsonPropertyName("newSet")]
public LoadoutData NewSet { get; set; }
[JsonPropertyName("itemsToAdd")]
public FYCustomItemInfo[] ItemsToAdd { get; set; }
[JsonPropertyName("itemsToUpdateAmount")]
public FYCustomItemInfo[] ItemsToUpdateAmount { get; set; }
[JsonPropertyName("itemsToRemove")]
public HashSet<string> ItemsToRemove { get; set; }
}
[CloudScriptFunction("CompleteInventoryUpdate")]
public class CompleteInventoryUpdateFunction : ICloudScriptFunction<CompleteInventoryUpdateRequest, CompleteInventoryUpdateResponse>
{
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, TitleDataService titleDataService)
{
_logger = logger;
_httpContextAccessor = httpContextAccessor;
_userDataService = userDataService;
_titleDataService = titleDataService;
}
public async Task<CompleteInventoryUpdateResponse> ExecuteAsync(CompleteInventoryUpdateRequest request)
{
var context = _httpContextAccessor.HttpContext;
if (context == null)
{
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>{"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:
// 1. Split item stack - creates a new item and updates amount of existing item.
// 2. Stack existing items - deletes existing items and updates amount of existing item.
// 3. Change vanity data - updates an existing item.
// 4. Change weapon mods - updates an existing item.
// 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) {
// Keep items that weren't removed, plus insured items recovered above.
if (!request.ItemsToRemove.Contains(item.ItemId) || recoveredInsured.Contains(item.ItemId)) {
newInventory.Add(item);
}
}
foreach (var item in request.ItemsToUpdateAmount) {
var inventoryItem = newInventory.Find(i => i.ItemId == item.ItemId);
if (inventoryItem == null) {
continue;
}
inventoryItem.Amount = item.Amount;
inventoryItem.ModData = item.ModData;
inventoryItem.PrimaryVanityId = item.PrimaryVanityId;
inventoryItem.SecondaryVanityId = item.SecondaryVanityId;
inventoryItem.Durability = item.Durability;
}
// TODO: Check updated items and validate new items
foreach (var item in request.ItemsToAdd) {
newInventory.Add(item);
}
await _userDataService.UpdateAsync(userId, userId, new Dictionary<string, string>{
["LOADOUT"] = JsonSerializer.Serialize(request.NewSet),
["Inventory"] = JsonSerializer.Serialize(newInventory),
});
return new CompleteInventoryUpdateResponse
{
UserId = userId,
//Error = "",
//NewSet = request.NewSet,
//ItemsToAdd = request.ItemsToAdd,
//ItemsToRemove = request.ItemsToRemove,
//ItemsToUpdateAmount = request.ItemsToUpdateAmount,
};
}
}
@@ -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);
}
}
@@ -1,16 +1,26 @@
using Prospect.Server.Api.Services.CloudScript.Models;
using Microsoft.Extensions.Options;
using Prospect.Server.Api.Config;
using Prospect.Server.Api.Services.CloudScript.Models;
namespace Prospect.Server.Api.Services.CloudScript.Functions;
[CloudScriptFunction("GetSignalRConnection")]
public class GetSignalRConnection : ICloudScriptFunction<FYGetSignalRConnection, FYGetSignalRConnectionResult>
{
private readonly PlayFabSettings _settings;
public GetSignalRConnection(IOptions<PlayFabSettings> settings)
{
_settings = settings.Value;
}
public Task<FYGetSignalRConnectionResult> ExecuteAsync(FYGetSignalRConnection request)
{
// The game client connects to SignalR only over HTTPS
return Task.FromResult(new FYGetSignalRConnectionResult
{
Url = "https://2ea46.playfabapi.com/signalr/?hub=pubsub",
AccessToken = "TEST"
Url = _settings.SignalRURL,
AccessToken = _settings.SignalRAccessToken
});
}
}
@@ -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];
@@ -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) {
// 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;
}
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 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
}
);
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 = "Korolev",
currentProgression = 0
},
new
{
factionId = "Osiris",
currentProgression = 0
},
new { factionId = "ICA", currentProgression = Progression("ICA") },
new { factionId = "Korolev", currentProgression = Progression("Korolev") },
new { factionId = "Osiris", currentProgression = Progression("Osiris") },
},
userId = context.User.FindAuthUserId(),
userId,
error = ""
});
};
}
}
@@ -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>
{
"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))
{
UserId = context.User.FindAuthUserId(),
foreach (var board in jobBoardsData.Boards)
{
if (board.Contracts == null || !jobs.TryGetValue(board.FactionId, out var factionJobs))
{
continue;
}
var factionKey = "FactionProgression" + board.FactionId;
var factionProgression = userData.TryGetValue(factionKey, out var fp)
? JsonSerializer.Deserialize<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",
},
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()
}
Boards = jobBoardsData.Boards,
LastBoardRefreshTimeUtc = jobBoardsData.LastBoardRefreshTimeUtc ?? new FYTimestamp { Seconds = now },
},
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
}
};
}
@@ -135,7 +135,7 @@ public class StartPlayerQuarterUpgradeClient : ICloudScriptFunction<FYStartPlaye
userId, userId,
new Dictionary<string, string>{
["Balance"] = JsonSerializer.Serialize(balance),
["Inventory"] = JsonSerializer.Serialize(inventory),
["Inventory"] = JsonSerializer.Serialize(newInventory),
["PlayerQuartersLevel"] = JsonSerializer.Serialize(userPlayerQuarters),
}
);
@@ -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
@@ -0,0 +1,62 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Prospect.Server.Api.Services.Auth.Extensions;
using Prospect.Server.Api.Services.CloudScript.Models.Data;
using Prospect.Server.Api.Services.UserData;
namespace Prospect.Server.Api.Services.CloudScript.Functions;
// Custom request for dedicated server implementation
public class FYUpdatePlayerActiveContractsRequest {
[JsonPropertyName("userId")]
public string UserID { get; set; }
[JsonPropertyName("contracts")]
public List<FYActiveContractPlayerData> Contracts { get; set; }
}
public class FYUpdatePlayerActiveContractsResult {
}
[CloudScriptFunction("UpdatePlayerActiveContracts")]
public class UpdatePlayerActiveContracts : ICloudScriptFunction<FYUpdatePlayerActiveContractsRequest, FYUpdatePlayerActiveContractsResult>
{
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly UserDataService _userDataService;
private readonly TitleDataService _titleDataService;
public UpdatePlayerActiveContracts(IHttpContextAccessor httpContextAccessor, UserDataService userDataService, TitleDataService titleDataService)
{
_httpContextAccessor = httpContextAccessor;
_userDataService = userDataService;
_titleDataService = titleDataService;
}
public async Task<FYUpdatePlayerActiveContractsResult> ExecuteAsync(FYUpdatePlayerActiveContractsRequest request)
{
var context = _httpContextAccessor.HttpContext;
if (context == null)
{
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>{"ContractsActive"});
var contractsActive = JsonSerializer.Deserialize<FYGetActiveContractsResult>(userData["ContractsActive"].Value);
foreach (var contract in request.Contracts) {
var foundContract = contractsActive.Contracts.Find(item => item.ContractID == contract.ContractID);
if (foundContract == null) {
continue;
}
foundContract.Progress = contract.Progress;
}
await _userDataService.UpdateAsync(
userId, userId,
new Dictionary<string, string>{ ["ContractsActive"] = JsonSerializer.Serialize(contractsActive) }
);
return new FYUpdatePlayerActiveContractsResult
{};
}
}
@@ -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
+1 -1
View File
@@ -65,7 +65,7 @@ public class Startup
app.UseDeveloperExceptionPage();
}
app.UseHttpsRedirection();
// app.UseHttpsRedirection();
app.UseSerilogRequestLogging();
app.UseMiddleware<RequestLoggerMiddleware>();
app.UseRouting();
+3 -6
View File
@@ -8,14 +8,11 @@
},
"PlayFabSettings": {
"PublisherId": "850902E5B40508ED",
"TitleId": "2EA46"
"TitleId": "2EA46",
"SignalRURL": "https://127.0.0.1:8443/signalr/?hub=pubsub",
"SignalRAccessToken": "TEST"
},
"Kestrel": {
"Endpoints": {
"Https": {
"Url": "https://2EA46.playfabapi.com"
}
},
"Certificates": {
"Default": {
"Path": ".\\certificate.pfx"
+27
View File
@@ -23,6 +23,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Prospect.Unreal.Generator",
EndProject
Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "Prospect.Client.Loader", "Prospect.Client.Loader\Prospect.Client.Loader.vcxproj", "{04B1D963-2BCB-4887-9D6D-04CB3366A348}"
EndProject
Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "Prospect.Agent", "Prospect.Agent\Prospect.Agent.vcxproj", "{A9BA7D25-F239-4320-A0DC-85E8001FD669}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|x64 = Debug|x64
@@ -207,6 +209,30 @@ Global
{04B1D963-2BCB-4887-9D6D-04CB3366A348}.Season 3 Release|x64.Build.0 = Release|x64
{04B1D963-2BCB-4887-9D6D-04CB3366A348}.Season 3 Release|x86.ActiveCfg = Release|Win32
{04B1D963-2BCB-4887-9D6D-04CB3366A348}.Season 3 Release|x86.Build.0 = Release|Win32
{A9BA7D25-F239-4320-A0DC-85E8001FD669}.Debug|x64.ActiveCfg = Debug|x64
{A9BA7D25-F239-4320-A0DC-85E8001FD669}.Debug|x64.Build.0 = Debug|x64
{A9BA7D25-F239-4320-A0DC-85E8001FD669}.Debug|x86.ActiveCfg = Debug|x64
{A9BA7D25-F239-4320-A0DC-85E8001FD669}.Debug|x86.Build.0 = Debug|x64
{A9BA7D25-F239-4320-A0DC-85E8001FD669}.Release|x64.ActiveCfg = Release|x64
{A9BA7D25-F239-4320-A0DC-85E8001FD669}.Release|x64.Build.0 = Release|x64
{A9BA7D25-F239-4320-A0DC-85E8001FD669}.Release|x86.ActiveCfg = Release|x64
{A9BA7D25-F239-4320-A0DC-85E8001FD669}.Release|x86.Build.0 = Release|x64
{A9BA7D25-F239-4320-A0DC-85E8001FD669}.Season 2 Debug|x64.ActiveCfg = Debug|x64
{A9BA7D25-F239-4320-A0DC-85E8001FD669}.Season 2 Debug|x64.Build.0 = Debug|x64
{A9BA7D25-F239-4320-A0DC-85E8001FD669}.Season 2 Debug|x86.ActiveCfg = Debug|x64
{A9BA7D25-F239-4320-A0DC-85E8001FD669}.Season 2 Debug|x86.Build.0 = Debug|x64
{A9BA7D25-F239-4320-A0DC-85E8001FD669}.Season 2 Release|x64.ActiveCfg = Release|x64
{A9BA7D25-F239-4320-A0DC-85E8001FD669}.Season 2 Release|x64.Build.0 = Release|x64
{A9BA7D25-F239-4320-A0DC-85E8001FD669}.Season 2 Release|x86.ActiveCfg = Release|x64
{A9BA7D25-F239-4320-A0DC-85E8001FD669}.Season 2 Release|x86.Build.0 = Release|x64
{A9BA7D25-F239-4320-A0DC-85E8001FD669}.Season 3 Debug|x64.ActiveCfg = Debug|x64
{A9BA7D25-F239-4320-A0DC-85E8001FD669}.Season 3 Debug|x64.Build.0 = Debug|x64
{A9BA7D25-F239-4320-A0DC-85E8001FD669}.Season 3 Debug|x86.ActiveCfg = Debug|x64
{A9BA7D25-F239-4320-A0DC-85E8001FD669}.Season 3 Debug|x86.Build.0 = Debug|x64
{A9BA7D25-F239-4320-A0DC-85E8001FD669}.Season 3 Release|x64.ActiveCfg = Release|x64
{A9BA7D25-F239-4320-A0DC-85E8001FD669}.Season 3 Release|x64.Build.0 = Release|x64
{A9BA7D25-F239-4320-A0DC-85E8001FD669}.Season 3 Release|x86.ActiveCfg = Release|x64
{A9BA7D25-F239-4320-A0DC-85E8001FD669}.Season 3 Release|x86.Build.0 = Release|x64
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -219,6 +245,7 @@ Global
{14DAE1A4-6F3F-43C0-AC17-9DCDE420AF34} = {168B6247-2D33-4942-AB3F-C5041B3BDFEA}
{71E3E262-49C5-4B6C-9670-D0741CEDB63B} = {76364BAA-BC9D-4A92-AA2A-8914FAE22834}
{04B1D963-2BCB-4887-9D6D-04CB3366A348} = {31D3D5D2-B011-4F9B-B6CC-D0A35675D797}
{A9BA7D25-F239-4320-A0DC-85E8001FD669} = {31D3D5D2-B011-4F9B-B6CC-D0A35675D797}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {12888C62-D3A2-4DD7-A9A5-1481D95865D0}
+6 -1
View File
@@ -4,8 +4,13 @@ from cryptography.x509 import NameOID, CertificateBuilder
from cryptography import x509
import datetime
import os
import argparse
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--common-name", type=str, default='127.0.0.1')
args = parser.parse_args()
if os.path.exists("certificate.pfx"):
q = input("certificate.pfx already exists. Are you sure you want to generate a new certificate? (y/n): ")
if q != "y":
@@ -20,7 +25,7 @@ def main():
# Generate self-signed certificate
subject = issuer = x509.Name([
x509.NameAttribute(NameOID.COMMON_NAME, '2EA46.playfabapi.com'),
x509.NameAttribute(NameOID.COMMON_NAME, args.common_name),
])
cert = CertificateBuilder().subject_name(