1 Commits
Author SHA1 Message Date
Yury 44a7554616 Push changes 2025-02-24 01:01:48 +02:00
101 changed files with 721 additions and 5222 deletions
-33
View File
@@ -1,33 +0,0 @@
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
@@ -1 +0,0 @@
_out/
-15
View File
@@ -1,15 +0,0 @@
# 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"]
-80
View File
@@ -1,80 +0,0 @@
# 🎮 The Cycle: Frontier — Rejoindre le serveur privé (installation client)
Guide pour **installer le client et jouer sur le serveur privé**. Tu n'installes **rien côté serveur** — juste le jeu + un loader + un certificat.
> ⚠️ **À savoir avant de commencer**
> - Le jeu a été retiré de Steam en septembre 2022 : il faut **l'avoir déjà eu dans ta bibliothèque Steam** pour le retélécharger.
> - **Ce n'est pas du co-op en raid.** Chacun joue sa propre instance (stations solo) ; l'émulateur ne supporte pas plusieurs joueurs dans le même raid. Vous partagez le même serveur (progression, boutiques, comptes), pas la même partie.
> - Le serveur doit être **allumé** au moment où tu joues (demande à l'hébergeur).
---
## Fichiers à télécharger (dans la release)
Les 2 fichiers sont dispo dans la **release Gitea** :
👉 **https://git.nfteam.ovh/neckfire/the-cycle/releases/tag/friends-client**
1. **`LoaderPack.zip`** — le loader qui redirige le jeu vers le serveur privé (build b8 / Saison 2).
2. **`certificate.crt`** — le certificat **public** du serveur (sans clé privée, safe).
Télécharge les deux depuis la page de release avant de commencer.
---
## Étapes (Windows)
### 1. Installer le client Saison 2 (via la console Steam)
1. Steam ouvert, `Win+R` → tape `steam://nav/console` → Entrée (la **console Steam** s'ouvre).
2. Colle **exactement** cette commande → Entrée :
```
download_depot 868270 868271 4623363103423775682
```
(`868270` = le jeu, `868271` = le dépôt client, `4623363103423775682` = manifest **Saison 2 v2.7.2**.)
3. Le téléchargement démarre (⚠️ **pas de barre de progression**, sois patient — plusieurs Go).
4. À la fin, Steam affiche **« Depot download complete »** + le **chemin du dossier**
(ex. `...\Steam\steamapps\content\app_868270\depot_868271`). **Ce dossier = ton install du jeu.**
> Il faut **avoir eu The Cycle: Frontier dans ta bibliothèque Steam** avant, sinon → erreur `missing license`.
> **Pas assez d'espace sur le disque principal ?** `download_depot` écrit toujours dans `...\Steam\steamapps\content`. Pour le mettre sur un autre disque : ferme Steam, supprime `...\Steam\steamapps\content`, puis en **cmd admin** :
> ```
> mkdir "D:\SteamContent"
> mklink /J "C:\Program Files (x86)\Steam\steamapps\content" "D:\SteamContent"
> ```
> Rouvre Steam et relance la commande → le jeu ira sur `D:\`.
### 2. Installer le LoaderPack
1. Va dans le dossier du jeu → **`Prospect\Binaries\Win64`**.
2. Ouvre `LoaderPack.zip` et **glisse tout son contenu** dans ce dossier `Win64`.
3. Crée un **raccourci** vers `Prospect.Client.Loader.exe` (c'est avec ça que tu lanceras le jeu).
### 3. Faire confiance au certificat du serveur
1. Double-clique **`certificate.crt`** → **Installer le certificat**.
2. **Emplacement : Utilisateur actuel** → Suivant.
3. **Placer tous les certificats dans le magasin suivant****Parcourir****Autorités de certification racines de confiance** → OK → Suivant → Terminer.
4. Accepte l'avertissement de sécurité (il mentionne `2EA46.playfabapi.com`).
### 4. Pointer le client vers le serveur
Dans **`Prospect\Binaries\Win64`**, crée (ou édite) un fichier **`backend.txt`** contenant **exactement** cette ligne :
```
https://tc.nfteam.ovh:8443
```
### 5. Lancer le jeu
1. **Steam doit être ouvert** (connecté à ton compte).
2. Lance le jeu via le **raccourci `Prospect.Client.Loader`** (⚠️ pas via Steam directement).
3. Connexion automatique avec ton compte Steam. Bienvenue sur Fortuna III ! 🚀
---
## Dépannage
| Symptôme | Cause / solution |
|---|---|
| `libcurl error 7 (Couldn't connect to server)` | Le serveur est **éteint** → demande à l'hébergeur de l'allumer. |
| Erreur de certificat / `hostname mismatch` | `certificate.crt` **pas importé** (ou pas dans « Autorités racines de confiance »). Refais l'étape 3. |
| `missing license` au téléchargement | Tu n'as jamais eu le jeu sur ce compte Steam → impossible de le retélécharger. |
| Parties du corps manquantes (client S3) | Va en station → change ton apparence une fois (corrige les IDs). |
---
*Serveur privé The Cycle: Frontier — émulateur Prospect. Usage privé entre amis.*
+194 -188
View File
@@ -1,249 +1,255 @@
# The Cycle: Frontier — serveur privé (émulateur Prospect)
# Prospect <!-- omit in toc -->
Émulateur des services en ligne de **The Cycle: Frontier** (jeu retiré de Steam en
septembre 2022), permettant de rejouer en solo sur un serveur auto-hébergé.
Fork de [`deiteris/Prospect`](https://github.com/deiteris/Prospect), figé sur le
**Build 8 / client Saison 2**, buildé depuis les sources et déployé en conteneur via
une CI Gitea.
Also known as "The Cycle: Frontier".
> **Ce n'est pas du multijoueur.** Le raid tourne **côté client** (station solo) : chacun
> joue sa propre instance. Le serveur partage la progression, les comptes et les boutiques,
> pas la partie. Le vrai multi (squad en raid, voix de proximité) suppose un serveur de jeu
> Unreal dédié — voir [Hors-périmètre & R&D](#hors-périmètre--rd).
## Table of Contents <!-- omit in toc -->
---
- [Features](#features)
- [Running locally](#running-locally)
- [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. Add `steam_appid` to the game](#5-add-steam_appid-to-the-game)
- [6. Create a game shortcut with specified arguments](#6-create-a-game-shortcut-with-specified-arguments)
- [7. Run the server](#7-run-the-server)
- [8. Run the game](#8-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)
- [Body parts are missing with Season 3 client](#body-parts-are-missing-with-season-3-client)
- [Prospect.Server.Api does not start](#prospectserverapi-does-not-start)
- [Login Failed. Error code: 3](#login-failed-error-code-3)
- [Login Failed. Error code: 5](#login-failed-error-code-5)
- [Development](#development)
## Sommaire
## Features
- [Comment ça marche](#comment-ça-marche)
- [Structure du dépôt](#structure-du-dépôt)
- [Build & lancement du serveur](#build--lancement-du-serveur)
- [Certificat TLS](#certificat-tls)
- [CI/CD](#cicd)
- [Config du client (switch de serveur + certificat)](#config-du-client-switch-de-serveur--certificat)
- [État des fonctionnalités](#état-des-fonctionnalités)
- [Hors-périmètre & R&D](#hors-périmètre--rd)
- [Crédits & licence](#crédits--licence)
* [x] Basic login with Steam
* [x] EULA acceptance
* [x] Tutorial
* [x] Single-player station (Season 2 and Season 3):
* [ ] Onboarding
* [x] Talk to Badum
* [x] Equip items
* [ ] Deploy to Fortuna
* [ ] Matchmaking and deployment
* [x] Solo
* [ ] Squad
* [ ] Items insurance
* [ ] Free loadouts (Season 3)
* [x] Inventory and loadout
* [ ] Loadout presets (Season 3)
* [x] Quests
* [ ] Job boards
* [x] Faction progression
* [ ] Season pass
* [ ] Shops
* [ ] Daily shop
* [ ] Weekly shop
* [ ] Shop rotation
* [x] Daily login
* [x] Character appearance and emotes
* [x] Shops
* [x] Crafting station
* [x] Quarters
* [x] Player balance
* [ ] Social features
* [ ] Proximity voice
* [x] Vivox login
* [x] Vivox create and join channel
* [ ] Proximity voice works
* [ ] Game mechanics
* [x] Can deploy through terminal
* [x] Can deploy with loadout
* [ ] Can evac
* [ ] Can do quests
* [ ] Can gain/lose loot
* [ ] Can use Alien Forge.
* [ ] Map content
* [x] Bright Sands
* [ ] Crescent Falls
* [ ] Tharis Island
---
## Running locally
## Comment ça marche
> [!NOTE]
> If you've already done all steps previously, you can skip to Step 7.
Le client officiel parle à PlayFab. On l'intercepte côté client et on le redirige vers
notre serveur, qui réimplémente juste ce qu'il faut de PlayFab (auth Steam, CloudScript,
UserData/TitleData, matchmaking solo).
### 1. Prerequisites
```mermaid
flowchart LR
subgraph client [Poste de jeu]
L[Prospect.Client.Loader<br/>injecte l'agent] --> A[Prospect.Agent<br/>hooke l'URL PlayFab]
A -- lit --> B[backend.txt]
A --> G[Jeu &#40;TCF&#41;]
end
G -- HTTPS / SignalR --> API[Prospect.Server.Api<br/>émulateur PlayFab]
API --> M[(MongoDB)]
```
> [!WARNING]
> The latest Steam version of The Cycle: Frontier currently does not work with Windows 11 24H2!
- **`Prospect.Client.Loader`** lance le jeu en injectant l'agent.
- **`Prospect.Agent`** hooke l'URL de l'API PlayFab et la remplace par le contenu de
**`backend.txt`** (placé dans `Prospect/Binaries/Win64`). Absent → fallback
`https://127.0.0.1:8443`.
- **`Prospect.Server.Api`** émule PlayFab (auth Steam → JWT, CloudScript, données joueur)
et pousse le temps-réel via **SignalR**. Les données vivent dans **MongoDB**.
> [!IMPORTANT]
> You must have The Cycle: Frontier from Steam in your Steam library to be able to download it.
> Otherwise, the download will fail with an error message about missing license.
Toute la redirection du client tient donc dans **une seule valeur** (`backend.txt`) — gérée
par l'outil [`Prospect.Client.Config`](#config-du-client-switch-de-serveur--certificat).
Before you start, you'll need the following software downloaded and installed:
---
1. [MongoDB Community Edition](https://fastdl.mongodb.org/windows/mongodb-windows-x86_64-8.0.4-signed.msi).
## Structure du dépôt
1. [Latest `Prospect.Server.Api`](https://github.com/deiteris/Prospect/releases) from the Releases section
| Projet | Rôle |
|---|---|
| **`Prospect.Server.Api`** | Cœur : émulateur PlayFab (ASP.NET 8). Controllers Client/CloudScript/Multiplayer, services Auth/UserData/TitleData/Database(Mongo)/Qos, hub SignalR. |
| **`Prospect.Steam`** | Validation du ticket Steam (auth). |
| **`Prospect.Client.Loader`** | Loader C++ : lance le jeu et injecte l'agent. |
| **`Prospect.Agent`** | Agent C++ injecté : hooke l'URL PlayFab → `backend.txt`. |
| **`Prospect.Client.Config`** | Utilitaire multi-OS : écrit `backend.txt`, importe le certificat, lance le jeu. Voir son [README](src/Prospect.Client.Config/README.md). |
| **`Prospect.Server.Game`** | Serveur de jeu dédié (squelette, **R&D**). |
| **`Prospect.Unreal[.Generator/.Tests]`** | Réimplémentation C# du netcode Unreal (**R&D** serveur dédié). |
| `utils/` | `generate_ssl.py` — génération du certificat auto-signé. |
1. [.NET Runtime 8.0](https://aka.ms/dotnet-core-applaunch?missing_runtime=true&arch=x64&rid=win-x64&os=win10&apphost_version=8.0.11) installed.
Build config **`Season 2 Release`** obligatoire pour l'API (le code sélectionne la saison
via `#if SEASON_2_RELEASE` / `SEASON_3_RELEASE` → sinon `#error Unsupported build type`).
1. [ASP.NET Core 8.0](https://aka.ms/dotnet-core-applaunch?framework=Microsoft.AspNetCore.App&framework_version=8.0.0&arch=x64&rid=win-x64&os=win10) installed.
---
1. The Cycle: Frontier game client:
## Build & lancement du serveur
- The latest version from [Steam](https://steamcommunity.com/app/868270).
Le serveur tourne en conteneur. L'image est buildée depuis les sources par le
[`Dockerfile`](Dockerfile) (multi-stage SDK .NET 8 → runtime aspnet 8, publish en
`Season 2 Release`).
- Season 2 client. See [download instructions below](#11-how-to-download-season-2-client-from-steamdb-using-steam-console).
### Build de l'image
- Any other version, even Season 1 works.
```bash
docker build -t the-cycle .
```
### 1.1 How to download Season 2 client from SteamDB using Steam console
### Lancement
> [!WARNING]
> This will overwrite the existing client if you try to download a different manifest!
Il faut une **instance MongoDB** joignable et un **certificat TLS** monté (voir section
suivante). Variables d'environnement :
1. With Steam running, click <steam://nav/console> to open Steam console. Alternatively, you can press `Win+R` and enter `steam://nav/console`.
| Variable | Rôle |
|---|---|
| `DatabaseSettings__ConnectionString` | URI de connexion MongoDB. |
| `DatabaseSettings__DatabaseName` | Base à utiliser (ex. `ProspectDb`). |
| `AuthTokenSettings__Secret` | Secret de signature des JWT émis par le serveur. |
| `PlayFabSettings__SignalRURL` | URL SignalR **telle que le client doit l'atteindre** (voir gotcha ci-dessous). |
| `Kestrel__Certificates__Default__Path` | Chemin du `.pfx` dans le conteneur. |
| `Kestrel__Endpoints__Https__Url` | ex. `https://0.0.0.0:8443`. |
| `SteamWebApiKey` | *(optionnel)* clé Steam Web API pour récupérer les pseudos ; inerte si absente. |
1. Open [The Cycle: Frontier SteamDB manifests](https://steamdb.info/depot/868271/manifests/).
```bash
docker run -d --name the-cycle-api \
-e DatabaseSettings__ConnectionString="mongodb://user:pass@HOST:27017/?authSource=ProspectDb" \
-e DatabaseSettings__DatabaseName="ProspectDb" \
-e AuthTokenSettings__Secret="<secret>" \
-e PlayFabSettings__SignalRURL="https://<host-public>:8443/signalr/?hub=pubsub" \
-e Kestrel__Endpoints__Https__Url="https://0.0.0.0:8443" \
-e Kestrel__Certificates__Default__Path="/certs/certificate.pfx" \
-v "$PWD/certs:/certs:ro" \
-p 8443:8443 \
the-cycle
```
1. Press `CTRL+F` and enter `4623363103423775682` to find the manifest for Season 2 version 2.7.2 client.
> ⚠️ **`PlayFabSettings__SignalRURL` = l'URL que le CLIENT doit joindre**, pas `127.0.0.1`.
> Le serveur y renvoie le client pour l'event de matchmaking ; s'il pointe sur `127.0.0.1`,
> le déploiement en raid **timeout**. Mets le hostname/IP public du serveur.
1. Click the ![Copy](./_assets/steamdb_copy.PNG) icon to copy the download command.
### Dev local (.NET)
1. Paste the command in the Steam console and press `Enter`.
```bash
dotnet build src/Prospect.Server.Api/Prospect.Server.Api.csproj -c "Season 2 Release"
dotnet run --project src/Prospect.Server.Api -c "Season 2 Release"
```
1. The depot will begin downloading. You should receive a notification and the destination folder when the download is complete.
---
### 2. Unpack `Prospect.Server.Api`
## Certificat TLS
Use your favorite ZIP archiver and unzip the `Prospect.Server.Api.zip` downloaded from this repository.
La connexion est en HTTPS et le client valide le certificat → il doit être **auto-signé et
fait confiance** côté client. Générer le `.pfx` avec `utils/generate_ssl.py`.
### 3. Patch the `hosts` file
> ⚠️ **Le SAN doit contenir `DNS:<ip>` ET `IP:<ip>`**, en plus des hostnames.
> Le HTTP du jeu (libcurl) accepte l'IP en SAN IP, mais le WebSocket (libwebsockets) valide
> l'IP contre les SAN **DNS** → sans `DNS:<ip>`, la connexion SignalR échoue
> (`Hostname mismatch err=62`). Inclure aussi `2EA46.playfabapi.com`, `localhost`,
> `127.0.0.1` et tous les hostnames publics utilisés dans `backend.txt`.
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:
Côté client, l'import du certificat est automatisé par
[`Prospect.Client.Config`](#config-du-client-switch-de-serveur--certificat).
1. Open Notepad as Administrator.
---
1. Click **File** > **Open...** and select `C:\Windows\System32\drivers\etc\hosts`.
## CI/CD
1. At the end of the file, add `127.0.0.1 2EA46.playfabapi.com` on a new line. Do not add the `#` character!
[`.gitea/workflows/build.yml`](.gitea/workflows/build.yml) — sur push `main` / `preprod`
(ou `workflow_dispatch`) :
### 4. Generate and import SSL certificate
1. build de l'image depuis le `Dockerfile` ;
2. push sur le registry `git.nfteam.ovh/neckfire/the-cycle` ;
3. notification du résultat (ntfy).
> [!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.
**Modèle de branches :**
A connection to the server is served over a secured connection. The server uses self-signed certificate that must be added to trusted authorities in order for the game
to successfully communicate with the local server. Do the following:
| Branche | Tag image | Usage |
|---|---|---|
| `preprod` | `:preprod` (+ `:preprod-<sha>`) | banc de test — valider un build avant de merger |
| `main` | `:latest` (+ `:<sha>`) | production |
1. Open the folder with `Prospect.Server.Api`.
Workflow type : coder → push `preprod` → tester sur le serveur preprod → **PR `preprod → main`**
→ la CI republie `:latest`. Le déploiement applique la nouvelle image
(`docker compose pull && docker compose up -d`).
1. Double-click `generate_ssl.exe`. `certificate.pfx` will appear in the same folder.
> `Prospect.Client.Config` (outil client, cross-OS) n'est **pas** buildé par cette CI —
> voir sa section publication.
1. Double-click `certificate.pfx`. The Certificate Import Wizard will open:
---
1. Select **Current User** under Store Location and click **Next**.
## Config du client (switch de serveur + certificat)
1. Leave **File to Import** unchanged and click **Next**.
L'outil **`Prospect.Client.Config`** (binaire `ProspectServerSwitcher`, multi-OS
Linux/Proton + Windows) fait tout le boulot côté client :
1. Leave **Password** empty and click **Next**.
- écrit `backend.txt` (presets **prod** / **preprod** ou URL libre) ;
- récupère le certificat **en direct depuis le serveur ciblé** (TLS) et le rend fiable :
- **Windows** : import dans *Autorités de certification racines de confiance* (utilisateur) ;
- **Linux/Proton** : import direct dans le préfixe Wine du jeu via `wine reg import`
(car `wine certutil` est cassé sous Proton) ;
- lance le jeu.
1. Select **Place all certificates in the following store** > **Browse...**. Choose **Trusted Root Certification Authorities** and click **OK**. Click **Next**.
```bash
# menu interactif
ProspectServerSwitcher
1. Click **Finish**. A **Security Warning** popup may appear, make sure it specifies `2EA46.playfabapi.com` certification authority and click **Yes**.
# scriptable
ProspectServerSwitcher --folder "<...>/Prospect/Binaries/Win64" --set preprod
ProspectServerSwitcher --set https://mon-serveur:8443
```
### 5. Add `steam_appid` to the game
Détails complets, gotchas Proton (préfixe non-Steam) et commandes de publication des
binaires autonomes : **[src/Prospect.Client.Config/README.md](src/Prospect.Client.Config/README.md)**.
1. Open the folder with The Cycle: Frontier and navigate to **Prospect** > **Binaries** > **Win64**.
> Installation complète pas-à-pas pour un nouveau joueur (télécharger le client S2, le
> LoaderPack, importer le certificat) : **[FRIENDS-INSTALL.md](FRIENDS-INSTALL.md)**.
1. Right-click in the folder > **New** > **Text Document**, and name it `steam_appid`.
---
1. Open `steam_appid` and enter `480`.
## État des fonctionnalités
1. Save and close the file.
**Fonctionne :**
### 6. Create a game shortcut with specified arguments
- [x] Login Steam, EULA, tutoriel
- [x] Station solo (S2/S3) : onboarding, matchmaking & déploiement **solo**
- [x] Inventaire & loadout, stash, vente, réparation
- [x] Contrats / quêtes — y compris les objectifs **kills** et **de zone** (auto-crédités :
pas de serveur dédié pour remonter les events runtime du raid client-hosted)
- [x] Progression des factions
- [x] Season pass : claim + gain d'XP de saison (niveau Fortuna)
- [x] Boutiques d'items (Korolev / ICA / Osiris / QuickShop / CraftingStation)
- [x] Aurum Shop (cosmétiques) + rotation daily/weekly
- [x] Craft, Quarters, solde joueur, connexion quotidienne
- [x] Apparence & emotes
- [x] Assurance : débit de la prime au déploiement + payout à la mort
- [x] Stats de carrière (valeurs à 0 — non traçables sans serveur de jeu)
- [x] Présence des amis (en ligne / en raid)
- [x] Pseudos réels via Steam Web API (le client n'envoie que le SteamID)
- [x] Cartes : Bright Sands, Crescent Falls, Tharis Island
1. Open the folder with The Cycle: Frontier and navigate to **Prospect** > **Binaries** > **Win64**.
**Non implémenté / hors-périmètre :**
1. Right-click `Prospect-Win64-Shipping.exe` > **Create Shortcut**.
- [ ] Squad / multi dans le **même** raid — nécessite un serveur de jeu dédié
- [ ] Voix de proximité (login/join Vivox = placeholders)
- [ ] Free loadouts & presets (Saison 3 uniquement)
- [ ] Achat de cosmétiques (endpoint d'achat vanity distinct, non câblé)
- [ ] Catalogue des récompenses Fortuna (DataTable côté client, dans des paks chiffrés)
- [ ] Défis quotidiens Fortuna
1. Right-click the created shortcut > **Properties**.
---
1. In the **Target** field, add the following parameters after the quote ` -log -steam_auth PF_TITLEID=2EA46`. Note the space after the quote.
## Hors-périmètre & R&D
1. Move the shortcut to desktop.
Un **serveur de jeu Unreal dédié** (`Prospect.Server.Game` + `Prospect.Unreal`, branche
`game-server`) est en cours de reverse-engineering pour, à terme, permettre le vrai multi.
État : handshake stateless UE, séquençage et décodage des bunches **franchis**, canal de
contrôle ouvert, `NMT_Hello` parsé. **Bloqué** sur le chiffrement **DTLS-PSK** du client
(clé dérivée du `user_id`), derrière un exe packé (BattlEye) → la dérivation n'est pas
extractible statiquement. Détails dans `NETCODE-RND.md` (branche `game-server`).
### 7. Run the server
Le « lobby squad » via l'API seule n'est **pas faisable** : l'invitation d'amis est gérée
100 % côté client Steam.
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.
>
> If the console does not open, make sure you have [.NET Runtime 8.0](https://aka.ms/dotnet-core-applaunch?missing_runtime=true&arch=x64&rid=win-x64&os=win10&apphost_version=8.0.11) and [ASP.NET Core 8.0](https://aka.ms/dotnet-core-applaunch?framework=Microsoft.AspNetCore.App&framework_version=8.0.0&arch=x64&rid=win-x64&os=win10) installed.
## Crédits & licence
### 8. Run the game
Fork de [`deiteris/Prospect`](https://github.com/deiteris/Prospect) (lui-même issu du
projet Prospect original). Voir [`LICENSE`](LICENSE). Usage privé.
Once the server is running, make sure that Steam is running and open The Cycle: Frontier using the shortcut you've created before.
## Troubleshooting and FAQ
### How to remove the certificate?
If you've installed the certificate for the **Current User**:
1. Open **Start** and enter `certmgr.msc`.
1. Expand **Trusted Root Certification Authorities** and select **Certificates**.
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`.
### `generate_ssl.exe` is flagged as a virus
`generate_ssl.exe` is a Python application packed with PyInstaller and some anti-viruses may flag it as a virus.
This application is a simple certificate generator and you can find its source code in `utils/generate_ssl.py`.
### Body parts are missing with Season 3 client
Currently, the server loads body part IDs for Season 2 by default, so this is expected. You can fix this by going to station and changing your character appearance. This will store the updated body part IDs for your character.
### Prospect.Server.Api does not start
Make sure you have [.NET Runtime 8.0](https://aka.ms/dotnet-core-applaunch?missing_runtime=true&arch=x64&rid=win-x64&os=win10&apphost_version=8.0.11) and [ASP.NET Core 8.0](https://aka.ms/dotnet-core-applaunch?framework=Microsoft.AspNetCore.App&framework_version=8.0.0&arch=x64&rid=win-x64&os=win10) installed.
### Login Failed. Error code: 3
Make sure that:
* You have Steam running.
* You have created and **saved** the `steam_appid` file as described in step 6.
* The `steam_appid` file type is "TXT File".
### Login Failed. Error code: 5
Make sure that:
* `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:
* `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.
![](./_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.
![](./_assets/certificate_error.PNG)
* `HTTP code: 500` - usually indicates that MongoDB is not running. Make sure that MongoDB is installed and and that `MongoDB Server` is running in `services.msc`.
![](./_assets/mongodb_error.PNG)
## Development
TBD
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
-119
View File
@@ -1,119 +0,0 @@
<?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>
@@ -1,39 +0,0 @@
<?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
@@ -1,105 +0,0 @@
#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
@@ -1,79 +0,0 @@
#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
@@ -1,3 +0,0 @@
#pragma once
#include "SDK.h"
-85
View File
@@ -1,85 +0,0 @@
#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
@@ -1,12 +0,0 @@
#pragma once
namespace logger
{
void Attach();
void Detach();
bool Print(const char* fmt, ...);
char ReadKey();
}
-135
View File
@@ -1,135 +0,0 @@
#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;
}
@@ -1,14 +0,0 @@
{
"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
@@ -1,5 +0,0 @@
{
"dependencies": [
"minhook"
]
}
-404
View File
@@ -1,404 +0,0 @@
using System.Diagnostics;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Text.Json;
namespace Prospect.Client.Config;
// Cross-platform (Linux/Proton + Windows) helper to point a The Cycle: Frontier client at a
// server. The Prospect agent reads the target PlayFab URL from `backend.txt` next to the game
// executable, so switching servers = writing that one file. This tool also imports the server
// certificate and launches the game (natively on Windows, guided on Linux/Proton).
internal static class Program
{
private const string AppId = "868270";
// Presets use the public domains so the same target works for everyone (LAN or internet):
// DNS resolves to the public IP for friends, and the unified TLS cert covers both hostnames.
// On the same LAN, add a hosts entry (domain -> 192.168.1.136) to avoid hairpin NAT.
private static readonly (string Key, string Label, string Url)[] Presets =
{
("prod", "PROD", "https://tc.nfteam.ovh:8443"),
("preprod", "PREPROD", "https://rd-tc.nfteam.ovh:8444"),
};
private static string SettingsPath => Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"ProspectSwitcher", "settings.json");
private static Settings _settings = new();
private static int Main(string[] args)
{
try { Console.OutputEncoding = Encoding.UTF8; } catch { /* some terminals reject it */ }
LoadSettings();
return args.Length > 0 ? RunCli(args) : MenuLoop();
}
// ---- Scriptable CLI ----------------------------------------------------
private static int RunCli(string[] args)
{
string? folder = null, target = null;
for (var i = 0; i < args.Length; i++)
{
switch (args[i])
{
case "--folder" when i + 1 < args.Length: folder = args[++i]; break;
case "--set" when i + 1 < args.Length: target = args[++i]; break;
case "-h" or "--help": PrintCliHelp(); return 0;
}
}
if (folder != null) { _settings.GameFolder = folder; SaveSettings(); }
if (target == null) { PrintCliHelp(); return folder != null ? 0 : 1; }
var url = ResolveTarget(target);
if (url == null) { Console.Error.WriteLine("Cible inconnue : " + target); return 1; }
return WriteBackend(url) ? 0 : 1;
}
private static void PrintCliHelp()
{
Console.WriteLine("ProspectServerSwitcher — sélecteur de serveur The Cycle (multi-OS)");
Console.WriteLine();
Console.WriteLine(" (sans argument) menu interactif");
Console.WriteLine(" --folder <chemin Win64> définit le dossier du jeu (mémorisé)");
Console.WriteLine(" --set <cible> écrit backend.txt");
Console.WriteLine();
Console.WriteLine("Cibles : " + string.Join(", ", Presets.Select(p => p.Key)) + ", ou une URL https://…");
Console.WriteLine("Ex : ProspectServerSwitcher --folder \"…/Prospect/Binaries/Win64\" --set preprod-lan");
}
private static string? ResolveTarget(string t)
{
foreach (var p in Presets)
if (string.Equals(p.Key, t, StringComparison.OrdinalIgnoreCase)) return p.Url;
if (t.StartsWith("http", StringComparison.OrdinalIgnoreCase)) return t;
return null;
}
// ---- Interactive menu --------------------------------------------------
private static int MenuLoop()
{
while (true)
{
Console.WriteLine();
Console.WriteLine("=== The Cycle — Sélecteur de serveur ===");
Console.WriteLine("Dossier du jeu : " + (string.IsNullOrEmpty(_settings.GameFolder) ? "(non défini)" : _settings.GameFolder));
Console.WriteLine("backend.txt : " + ReadCurrentBackend());
Console.WriteLine();
Console.WriteLine(" 1) Définir le dossier du jeu (Win64)");
for (var i = 0; i < Presets.Length; i++)
Console.WriteLine($" {i + 2}) {Presets[i].Label,-24} {Presets[i].Url}");
var n = Presets.Length + 2;
Console.WriteLine($" {n}) Serveur personnalisé…");
Console.WriteLine($" {n + 1}) Importer un certificat…");
Console.WriteLine($" {n + 2}) Lancer le jeu");
Console.WriteLine(" 0) Quitter");
Console.Write("> ");
var raw = Console.ReadLine()?.Trim();
if (raw is null or "0") return 0;
if (!int.TryParse(raw, out var choice)) { Console.WriteLine("Choix invalide."); continue; }
if (choice == 1) SetFolder();
else if (choice >= 2 && choice < n) WriteBackend(Presets[choice - 2].Url);
else if (choice == n) SetCustom();
else if (choice == n + 1) ImportCert();
else if (choice == n + 2) LaunchGame();
else Console.WriteLine("Choix invalide.");
}
}
private static void SetFolder()
{
Console.Write("Chemin du dossier Win64 : ");
var p = Console.ReadLine()?.Trim().Trim('"');
if (string.IsNullOrEmpty(p)) return;
if (!Directory.Exists(p)) { Console.WriteLine("⚠ Dossier introuvable."); return; }
_settings.GameFolder = p;
SaveSettings();
Console.WriteLine("✔ Dossier mémorisé.");
}
private static void SetCustom()
{
Console.Write("URL du serveur (ex : https://mon-serveur:8443) : ");
var u = Console.ReadLine()?.Trim();
if (!string.IsNullOrWhiteSpace(u)) WriteBackend(u);
}
// ---- backend.txt -------------------------------------------------------
private static bool WriteBackend(string url)
{
if (!EnsureFolder(out var dir)) return false;
try
{
// No trailing newline: the agent reads the raw file and requires it to start with https://.
File.WriteAllText(Path.Combine(dir, "backend.txt"), url, new UTF8Encoding(false));
Console.WriteLine($"✔ backend.txt → {url}");
Console.WriteLine(" (relance le jeu : backend.txt n'est lu qu'au démarrage du loader.)");
return true;
}
catch (Exception ex) { Console.WriteLine("✖ " + ex.Message); return false; }
}
private static string ReadCurrentBackend()
{
if (string.IsNullOrEmpty(_settings.GameFolder)) return "(dossier non défini)";
var f = Path.Combine(_settings.GameFolder, "backend.txt");
try
{
if (!File.Exists(f)) return "(absent)";
var v = File.ReadAllText(f).Trim();
var match = Presets.FirstOrDefault(p => p.Url == v);
return match.Key != null ? $"{v} [{match.Label.Trim()}]" : v;
}
catch { return "(illisible)"; }
}
// ---- Certificate -------------------------------------------------------
private static void ImportCert()
{
// Prefer fetching the cert live from the server the client points at (backend.txt), so
// it's always the current one; fall back to a .crt file.
X509Certificate2? cert = null;
var target = ReadBackendUrl();
if (target != null && TryFetchServerCert(target, out cert))
Console.WriteLine($"Certificat récupéré depuis {target}");
else
{
Console.Write("Chemin d'un certificat (.crt) : ");
var p = Console.ReadLine()?.Trim().Trim('"');
if (string.IsNullOrEmpty(p) || !File.Exists(p)) { Console.WriteLine("⚠ Annulé."); return; }
try { cert = new X509Certificate2(p); } catch (Exception ex) { Console.WriteLine("✖ " + ex.Message); return; }
}
if (cert == null) { Console.WriteLine("⚠ Aucun certificat."); return; }
Console.WriteLine($" empreinte : {cert.Thumbprint}");
if (OperatingSystem.IsWindows()) ImportCertWindows(cert);
else ImportCertProton(cert);
}
private static string? ReadBackendUrl()
{
if (string.IsNullOrEmpty(_settings.GameFolder)) return null;
var f = Path.Combine(_settings.GameFolder, "backend.txt");
try { return File.Exists(f) ? File.ReadAllText(f).Trim() : null; } catch { return null; }
}
private static bool TryFetchServerCert(string url, out X509Certificate2? cert)
{
cert = null;
try
{
var uri = new Uri(url);
using var tcp = new System.Net.Sockets.TcpClient();
tcp.Connect(uri.Host, uri.Port <= 0 ? 443 : uri.Port);
X509Certificate2? captured = null;
using var ssl = new System.Net.Security.SslStream(tcp.GetStream(), false,
(_, c, _, _) => { if (c != null) captured = new X509Certificate2(c.Export(X509ContentType.Cert)); return true; });
ssl.AuthenticateAsClient(uri.Host);
cert = captured;
return cert != null;
}
catch { return false; }
}
[System.Runtime.Versioning.SupportedOSPlatform("windows")]
private static void ImportCertWindows(X509Certificate2 cert)
{
try
{
using var store = new X509Store(StoreName.Root, StoreLocation.CurrentUser);
store.Open(OpenFlags.ReadWrite);
store.Add(cert);
Console.WriteLine("✔ Certificat importé (Autorités racines de confiance, utilisateur).");
}
catch (Exception ex) { Console.WriteLine("✖ Import : " + ex.Message); }
}
// Trust the cert inside the game's Proton/Wine prefix by writing the serialized cert blob
// into the prefix Root store via `wine reg import` (the wine `certutil` tool is unreliable
// under Proton). The prefix is chosen explicitly, so it works even though the client runs
// as a non-Steam shortcut (whose appid isn't the game's 868270).
private static void ImportCertProton(X509Certificate2 cert)
{
var wine = FindProtonWine();
if (wine == null) { Console.WriteLine("⚠ Aucun wine Proton trouvé (Steam/Proton installé ?)."); return; }
var prefix = SelectPrefix();
if (prefix == null) return;
// Wine serialized cert blob: SHA1-hash property (id 3) + encoded cert property (id 0x20).
var der = cert.RawData;
var sha1 = cert.GetCertHash();
byte[] blob;
using (var ms = new MemoryStream())
{
void Prop(uint id, byte[] data)
{
ms.Write(BitConverter.GetBytes(id));
ms.Write(BitConverter.GetBytes(1u));
ms.Write(BitConverter.GetBytes((uint)data.Length));
ms.Write(data);
}
Prop(3, sha1);
Prop(0x20, der);
blob = ms.ToArray();
}
var thumb = Convert.ToHexString(sha1);
var hex = string.Join(",", blob.Select(b => b.ToString("x2")));
var regFile = Path.Combine(Path.GetTempPath(), "prospect-cert.reg");
File.WriteAllText(regFile,
"REGEDIT4\r\n\r\n" +
$"[HKEY_LOCAL_MACHINE\\Software\\Microsoft\\SystemCertificates\\Root\\Certificates\\{thumb}]\r\n" +
$"\"Blob\"=hex:{hex}\r\n");
Console.WriteLine("⚠ Ferme complètement le jeu avant de continuer (sinon le registre ne sera pas pris en compte).");
Console.Write("Appuie sur Entrée pour importer… ");
Console.ReadLine();
var wineserver = Path.Combine(Path.GetDirectoryName(wine)!, "wineserver");
var regWinePath = "Z:" + regFile.Replace('/', '\\'); // Z: maps to / inside the prefix
RunWine(wineserver, "-k", prefix); // stop any running wineserver (flush)
RunWine(wine, $"reg import \"{regWinePath}\"", prefix);
RunWine(wineserver, "-k", prefix); // flush the change to disk
var sysreg = Path.Combine(prefix, "system.reg");
var ok = File.Exists(sysreg) && File.ReadAllText(sysreg).Contains(thumb, StringComparison.OrdinalIgnoreCase);
Console.WriteLine(ok
? $"✔ Certificat importé dans le préfixe : {prefix}\n Relance le jeu."
: "✖ Import non confirmé — assure-toi que le jeu est fermé et réessaie.");
}
private static void RunWine(string exe, string args, string prefix)
{
try
{
var psi = new ProcessStartInfo(exe, args)
{
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
};
psi.Environment["WINEPREFIX"] = prefix;
psi.Environment["WINEDEBUG"] = "-all";
using var p = Process.Start(psi);
p?.WaitForExit(20000);
}
catch (Exception ex) { Console.WriteLine(" (wine) " + ex.Message); }
}
private static string? FindProtonWine()
{
foreach (var root in SteamRoots())
{
var common = Path.Combine(root, "steamapps", "common");
if (!Directory.Exists(common)) continue;
var candidates = Directory.GetDirectories(common, "Proton*")
.Select(d => Path.Combine(d, "files", "bin", "wine"))
.Where(File.Exists)
.OrderByDescending(w => w.Contains("Experimental"))
.ThenByDescending(File.GetLastWriteTimeUtc)
.ToList();
if (candidates.Count > 0) return candidates[0];
}
return null;
}
private static string? SelectPrefix()
{
if (!string.IsNullOrEmpty(_settings.WinePrefix) && Directory.Exists(_settings.WinePrefix))
return _settings.WinePrefix;
var prefixes = new List<string>();
foreach (var root in SteamRoots())
{
var cd = Path.Combine(root, "steamapps", "compatdata");
if (!Directory.Exists(cd)) continue;
foreach (var d in Directory.GetDirectories(cd))
{
var pfx = Path.Combine(d, "pfx");
if (File.Exists(Path.Combine(pfx, "system.reg"))) prefixes.Add(Path.GetFullPath(pfx));
}
}
prefixes = prefixes.Distinct()
.OrderByDescending(p => File.GetLastWriteTimeUtc(Path.Combine(p, "system.reg")))
.ToList();
if (prefixes.Count == 0) { Console.WriteLine("⚠ Aucun préfixe Proton trouvé."); return null; }
Console.WriteLine("Préfixes Proton détectés (le plus récemment utilisé en premier = sûrement ton jeu) :");
for (var i = 0; i < prefixes.Count; i++)
Console.WriteLine($" {i + 1}) {prefixes[i]}");
Console.Write("Numéro du préfixe (Entrée = 1) : ");
var raw = Console.ReadLine()?.Trim();
var idx = string.IsNullOrEmpty(raw) ? 0 : (int.TryParse(raw, out var n) ? n - 1 : -1);
if (idx < 0 || idx >= prefixes.Count) { Console.WriteLine("⚠ Choix invalide."); return null; }
_settings.WinePrefix = prefixes[idx];
SaveSettings();
return _settings.WinePrefix;
}
private static IEnumerable<string> SteamRoots()
{
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
yield return Path.Combine(home, ".steam", "steam");
yield return Path.Combine(home, ".local", "share", "Steam");
}
// ---- Launch ------------------------------------------------------------
private static void LaunchGame()
{
if (OperatingSystem.IsWindows())
{
if (!EnsureFolder(out var dir)) return;
var loader = Path.Combine(dir, "Prospect.Client.Loader.exe");
if (!File.Exists(loader)) { Console.WriteLine("⚠ Prospect.Client.Loader.exe introuvable dans le dossier."); return; }
try
{
Process.Start(new ProcessStartInfo(loader) { WorkingDirectory = dir, UseShellExecute = true });
Console.WriteLine("▶ Jeu lancé.");
}
catch (Exception ex) { Console.WriteLine("✖ " + ex.Message); }
return;
}
Console.WriteLine("Sous Linux/Proton, lance via Steam (le loader doit tourner dans le préfixe Proton) :");
Console.WriteLine(" steam steam://rungameid/" + AppId);
}
// ---- Helpers -----------------------------------------------------------
private static bool EnsureFolder(out string dir)
{
dir = _settings.GameFolder;
if (string.IsNullOrEmpty(dir) || !Directory.Exists(dir))
{
Console.WriteLine("⚠ Définis d'abord le dossier du jeu (option 1).");
return false;
}
return true;
}
private static void LoadSettings()
{
try
{
if (File.Exists(SettingsPath))
_settings = JsonSerializer.Deserialize<Settings>(File.ReadAllText(SettingsPath)) ?? new Settings();
}
catch { _settings = new Settings(); }
}
private static void SaveSettings()
{
try
{
Directory.CreateDirectory(Path.GetDirectoryName(SettingsPath)!);
File.WriteAllText(SettingsPath, JsonSerializer.Serialize(_settings));
}
catch { /* best-effort persistence */ }
}
private sealed class Settings
{
public string GameFolder { get; set; } = "";
public string WinePrefix { get; set; } = "";
}
}
@@ -1,15 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>ProspectServerSwitcher</AssemblyName>
<RootNamespace>Prospect.Client.Config</RootNamespace>
<InvariantGlobalization>true</InvariantGlobalization>
<!-- Cross-platform client tool (Linux/Proton + Windows). Never built by the Linux server CI. -->
<Configurations>Debug;Release;Season 3 Release;Season 2 Release;Season 2 Debug;Season 3 Debug</Configurations>
</PropertyGroup>
</Project>
-102
View File
@@ -1,102 +0,0 @@
# ProspectServerSwitcher
Petit utilitaire **multi-OS** (Linux/Proton + Windows) pour pointer un client
*The Cycle: Frontier* vers un serveur, importer le certificat, et lancer le jeu.
## Pourquoi
L'agent Prospect (`Prospect.Agent.dll`, injecté par `Prospect.Client.Loader.exe`)
hooke l'URL de l'API PlayFab et la remplace par le contenu de **`backend.txt`**,
placé à côté de l'exécutable du jeu (`Prospect/Binaries/Win64`). S'il est absent,
l'agent retombe sur `https://127.0.0.1:8443`.
**Tout l'aiguillage du client tient donc dans cette seule valeur.** Cet outil ne fait
essentiellement qu'écrire le bon `backend.txt` — plus quelques commodités (cert, launch).
## Serveurs (presets)
Les presets utilisent les **domaines publics** pour que la même cible marche pour
tout le monde (le DNS résout vers l'IP publique pour les amis, et le certificat TLS
unifié couvre les deux hostnames) :
| Preset | URL |
|-----------|----------------------------------|
| `prod` | `https://tc.nfteam.ovh:8443` |
| `preprod` | `https://rd-tc.nfteam.ovh:8444` |
> **Sur le même LAN que le serveur**, ajoute une entrée hosts pour éviter le hairpin NAT :
> ```
> 192.168.1.136 tc.nfteam.ovh
> 192.168.1.136 rd-tc.nfteam.ovh
> ```
## Utilisation
### Menu interactif
Lance le binaire sans argument :
- **1** : définir le dossier du jeu (`…/Prospect/Binaries/Win64`) — mémorisé
- **2/3** : basculer sur PROD / PREPROD
- **4** : URL personnalisée
- **5** : importer un certificat (`.crt`)
- **6** : lancer le jeu
- **0** : quitter
### En ligne de commande (scriptable)
```
ProspectServerSwitcher --folder "<...>/Prospect/Binaries/Win64" --set preprod
ProspectServerSwitcher --set prod
ProspectServerSwitcher --set https://mon-serveur:8443 # URL libre
```
> ⚠️ `backend.txt` n'est lu qu'**au démarrage** du loader → relance le jeu après un switch.
> Le serveur ciblé doit **tourner** (conteneurs on-demand `the-cycle-api` / `the-cycle-api-rd`).
## Certificat
L'outil récupère le certificat **en direct depuis le serveur** ciblé (il lit `backend.txt`
et se connecte en TLS), puis le rend fiable :
- **Windows** : import automatique dans *Autorités de certification racines de confiance*
(utilisateur courant), via le magasin `X509Store`.
- **Linux/Proton** : import **automatique dans le préfixe Wine** du jeu. Comme `wine certutil`
est cassé sous Proton, l'outil écrit directement le *blob* sérialisé du certificat
(propriété SHA1 `id=3` + certificat encodé `id=0x20`) dans le magasin `Root` du préfixe via
`wine reg import`. Il :
1. détecte le binaire `wine` de Proton (`…/common/Proton*/files/bin/wine`),
2. liste les préfixes `compatdata/*/pfx` (le plus récemment utilisé en premier),
3. importe dans le préfixe choisi (⚠️ **le jeu doit être fermé**), mémorisé ensuite.
> ⚠️ Le client tourne souvent comme **raccourci non-Steam** → son préfixe n'est **pas**
> `compatdata/868270` mais un appid généré (ex. `3883998305`). D'où la sélection explicite
> du préfixe plutôt qu'une déduction sur l'appid du jeu.
## Lancement
- **Windows** : démarre `Prospect.Client.Loader.exe` depuis le dossier du jeu.
- **Linux/Proton** : passe par Steam (`steam steam://rungameid/868270`) — le loader doit
tourner dans le préfixe Proton.
## Réglages
Le dossier du jeu est mémorisé dans
`…/ApplicationData/ProspectSwitcher/settings.json`
(`%AppData%` sous Windows, `~/.config` sous Linux).
## Build / publication
Projet console **net8.0** (cross-platform, `InvariantGlobalization`), jamais buildé par
la CI serveur (Linux).
```bash
# build simple
dotnet build src/Prospect.Client.Config -c Release
# binaires autonomes single-file (aucun .NET requis chez l'utilisateur)
dotnet publish src/Prospect.Client.Config -c Release -r win-x64 --self-contained \
-p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -p:EnableCompressionInSingleFile=true
dotnet publish src/Prospect.Client.Config -c Release -r linux-x64 --self-contained \
-p:PublishSingleFile=true -p:EnableCompressionInSingleFile=true
```
Les binaires publiés sont distribués via la release Gitea **`friends-client`**
(`ProspectServerSwitcher.exe` pour Windows, `ProspectServerSwitcher-linux-x64` pour Linux).
@@ -1,138 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<ItemGroup Label="ProjectConfigurations">
<ProjectConfiguration Include="Debug|Win32">
<Configuration>Debug</Configuration>
<Platform>Win32</Platform>
</ProjectConfiguration>
<ProjectConfiguration Include="Release|Win32">
<Configuration>Release</Configuration>
<Platform>Win32</Platform>
</ProjectConfiguration>
<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>17.0</VCProjectVersion>
<Keyword>Win32Proj</Keyword>
<ProjectGuid>{04b1d963-2bcb-4887-9d6d-04cb3366a348}</ProjectGuid>
<RootNamespace>ProspectClientLoader</RootNamespace>
<WindowsTargetPlatformVersion>10.0</WindowsTargetPlatformVersion>
</PropertyGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" />
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|Win32'" Label="Configuration">
<ConfigurationType>Application</ConfigurationType>
<UseDebugLibraries>true</UseDebugLibraries>
<PlatformToolset>v143</PlatformToolset>
<CharacterSet>Unicode</CharacterSet>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|Win32'" Label="Configuration">
<ConfigurationType>Application</ConfigurationType>
<UseDebugLibraries>false</UseDebugLibraries>
<PlatformToolset>v143</PlatformToolset>
<WholeProgramOptimization>true</WholeProgramOptimization>
<CharacterSet>Unicode</CharacterSet>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|x64'" Label="Configuration">
<ConfigurationType>Application</ConfigurationType>
<UseDebugLibraries>true</UseDebugLibraries>
<PlatformToolset>v143</PlatformToolset>
<CharacterSet>Unicode</CharacterSet>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|x64'" Label="Configuration">
<ConfigurationType>Application</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|Win32'">
<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|Win32'">
<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)'=='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)'=='Release|x64'">
<OutDir>$(SolutionDir)\$(ProjectName)\$(Platform)\$(Configuration)\</OutDir>
</PropertyGroup>
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Debug|Win32'">
<ClCompile>
<WarningLevel>Level3</WarningLevel>
<SDLCheck>true</SDLCheck>
<PreprocessorDefinitions>WIN32;_DEBUG;_CONSOLE;%(PreprocessorDefinitions)</PreprocessorDefinitions>
<ConformanceMode>true</ConformanceMode>
</ClCompile>
<Link>
<SubSystem>Console</SubSystem>
<GenerateDebugInformation>true</GenerateDebugInformation>
</Link>
</ItemDefinitionGroup>
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Release|Win32'">
<ClCompile>
<WarningLevel>Level3</WarningLevel>
<FunctionLevelLinking>true</FunctionLevelLinking>
<IntrinsicFunctions>true</IntrinsicFunctions>
<SDLCheck>true</SDLCheck>
<PreprocessorDefinitions>WIN32;NDEBUG;_CONSOLE;%(PreprocessorDefinitions)</PreprocessorDefinitions>
<ConformanceMode>true</ConformanceMode>
</ClCompile>
<Link>
<SubSystem>Console</SubSystem>
<EnableCOMDATFolding>true</EnableCOMDATFolding>
<OptimizeReferences>true</OptimizeReferences>
<GenerateDebugInformation>true</GenerateDebugInformation>
</Link>
</ItemDefinitionGroup>
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Debug|x64'">
<ClCompile>
<WarningLevel>Level3</WarningLevel>
<SDLCheck>true</SDLCheck>
<PreprocessorDefinitions>_DEBUG;_CONSOLE;%(PreprocessorDefinitions)</PreprocessorDefinitions>
<ConformanceMode>true</ConformanceMode>
</ClCompile>
<Link>
<SubSystem>Console</SubSystem>
<GenerateDebugInformation>true</GenerateDebugInformation>
</Link>
</ItemDefinitionGroup>
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Release|x64'">
<ClCompile>
<WarningLevel>Level3</WarningLevel>
<FunctionLevelLinking>true</FunctionLevelLinking>
<IntrinsicFunctions>true</IntrinsicFunctions>
<SDLCheck>true</SDLCheck>
<PreprocessorDefinitions>NDEBUG;_CONSOLE;%(PreprocessorDefinitions)</PreprocessorDefinitions>
<ConformanceMode>true</ConformanceMode>
</ClCompile>
<Link>
<SubSystem>Console</SubSystem>
<EnableCOMDATFolding>true</EnableCOMDATFolding>
<OptimizeReferences>true</OptimizeReferences>
<GenerateDebugInformation>true</GenerateDebugInformation>
</Link>
</ItemDefinitionGroup>
<ItemGroup>
<ClCompile Include="main.cpp" />
</ItemGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />
<ImportGroup Label="ExtensionTargets">
</ImportGroup>
</Project>
@@ -1,22 +0,0 @@
<?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>
<ClCompile Include="main.cpp">
<Filter>Source Files</Filter>
</ClCompile>
</ItemGroup>
</Project>
-500
View File
@@ -1,500 +0,0 @@
#include <windows.h>
#include <tlhelp32.h>
#include <stdio.h>
#include <stdint.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) {
STARTUPINFO si = { 0 };
PROCESS_INFORMATION pi = { 0 };
si.cb = sizeof(si);
// Command-line arguments to pass to the process
wchar_t commandLine[MAX_PATH] = L""; // Buffer for full command
_snwprintf_s(commandLine, sizeof(commandLine) / sizeof(wchar_t), L"\"%s\" -log -steam_auth PF_TITLEID=2EA46", exePath);
if (CreateProcess(NULL, commandLine, NULL, NULL, FALSE, CREATE_SUSPENDED, NULL, NULL, &si, &pi)) {
*processID = pi.dwProcessId;
printf("Successfully started process: %ls (PID: %lu)\n", exePath, *processID);
ResumeThread(pi.hThread);
CloseHandle(pi.hThread);
return pi.hProcess;
}
else {
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;
}
}
// Function to get the base address of the main module (PE Image)
uintptr_t GetModuleBaseAddress(DWORD processID) {
uintptr_t baseAddress = 0;
HANDLE hSnapshot = CreateToolhelp32Snapshot(TH32CS_SNAPMODULE | TH32CS_SNAPMODULE32, processID);
if (hSnapshot != INVALID_HANDLE_VALUE) {
MODULEENTRY32 moduleEntry;
moduleEntry.dwSize = sizeof(MODULEENTRY32);
if (Module32First(hSnapshot, &moduleEntry)) {
baseAddress = (uintptr_t)moduleEntry.modBaseAddr;
}
CloseHandle(hSnapshot);
}
else {
printf("Failed to take process snapshot. Error: %lu\n", GetLastError());
}
return baseAddress;
}
// Function to get a handle to the target process
HANDLE OpenTargetProcess(DWORD processID) {
HANDLE hProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, processID);
if (hProcess == NULL) {
printf("Failed to open process. Error: %lu\n", GetLastError());
}
return hProcess;
}
bool WriteBytes(HANDLE hProcess, uintptr_t address, BYTE* newValues, size_t size) {
SIZE_T bytesWritten;
DWORD oldProtect;
if (VirtualProtectEx(hProcess, (LPVOID)address, size, PAGE_EXECUTE_READWRITE, &oldProtect)) {
if (WriteProcessMemory(hProcess, (LPVOID)address, newValues, size, &bytesWritten)) {
printf("Memory Write Success at address 0x%p\n", (void*)address);
}
else {
printf("Failed to write memory. Error: %lu\n", GetLastError());
return false;
}
// Restore original protection
VirtualProtectEx(hProcess, (LPVOID)address, size, oldProtect, &oldProtect);
}
else {
printf("Failed to change memory protection. Error: %lu\n", GetLastError());
return false;
}
return true;
}
bool CheckForceLevelStreamingOffset(HANDLE hProcess, uintptr_t address) {
BYTE buffer[2];
SIZE_T bytesRead;
if (ReadProcessMemory(hProcess, (LPCVOID)address, buffer, sizeof(buffer), &bytesRead)) {
return buffer[0] == 0x32 && buffer[1] == 0xDB;
}
else {
DWORD error = GetLastError();
if (error == 299) {
printf("Incomplete memory read. Retrying...\n");
Sleep(1000);
return CheckForceLevelStreamingOffset(hProcess, address);
}
printf("Failed to read memory. Error: %lu\n", error);
return false;
}
}
bool CheckGPStringOffset(HANDLE hProcess, uintptr_t address) {
BYTE buffer[2];
SIZE_T bytesRead;
if (ReadProcessMemory(hProcess, (LPCVOID)address, buffer, sizeof(buffer), &bytesRead)) {
return buffer[0] == 0x5F && buffer[1] == 0x00;
}
else {
DWORD error = GetLastError();
if (error == 299) {
printf("Incomplete memory read. Retrying...\n");
Sleep(1000);
return CheckForceLevelStreamingOffset(hProcess, address);
}
printf("Failed to read memory. Error: %lu\n", error);
return false;
}
}
bool CheckWeakPointerCheckJumpOffset(HANDLE hProcess, uintptr_t address) {
BYTE buffer[2];
SIZE_T bytesRead;
if (ReadProcessMemory(hProcess, (LPCVOID)address, buffer, sizeof(buffer), &bytesRead)) {
return buffer[0] == 0xF8 && buffer[1] == 0xFE;
}
else {
DWORD error = GetLastError();
if (error == 299) {
printf("Incomplete memory read. Retrying...\n");
Sleep(1000);
return CheckWeakPointerCheckJumpOffset(hProcess, address);
}
printf("Failed to read memory. Error: %lu\n", error);
return false;
}
}
bool CheckWeakPointerCheckOffset(HANDLE hProcess, uintptr_t address) {
BYTE buffer[3];
SIZE_T bytesRead;
if (ReadProcessMemory(hProcess, (LPCVOID)address, buffer, sizeof(buffer), &bytesRead)) {
return buffer[0] == 0x0F && buffer[1] == 0x1F && buffer[2] == 0x40;
}
else {
DWORD error = GetLastError();
if (error == 299) {
printf("Incomplete memory read. Retrying...\n");
Sleep(1000);
return CheckWeakPointerCheckOffset(hProcess, address);
}
printf("Failed to read memory. Error: %lu\n", error);
return false;
}
}
bool PatchForceLevelStreaming(HANDLE hProcess, uintptr_t imageBase) {
// Season 3 Patch force disable level streaming: sub_3DA7520 -> loc_3DA75B6
uintptr_t address = imageBase + 0x3DA75B6;
bool found = CheckForceLevelStreamingOffset(hProcess, address);
if (!found) {
// Season 2 Patch force disable level streaming: sub_3D19900 -> loc_3D19996
address = imageBase + 0x3D19996;
found = CheckForceLevelStreamingOffset(hProcess, address);
}
if (!found) {
printf("Failed to patch force level streaming!\n");
return false;
}
{
BYTE newValues[2] = { 0xB3, 0x01 };
bool result = WriteBytes(hProcess, address, newValues, sizeof(newValues));
if (!result) {
return false;
}
}
address = imageBase + 0x5541C08;
found = CheckGPStringOffset(hProcess, address);
if (!found) {
address = imageBase + 0x54E0558;
found = CheckGPStringOffset(hProcess, address);
}
if (!found) {
printf("Failed to find _GP string!\n");
return false;
}
{
BYTE newValues[6] = { 0x47, 0x00, 0x00, 0x00, 0x00, 0x00 };
bool result = WriteBytes(hProcess, address, newValues, sizeof(newValues));
if (!result) {
return false;
}
}
address = imageBase + 0x16D7C24;
found = CheckWeakPointerCheckOffset(hProcess, address);
// TODO
//if (!found) {
// address = imageBase + 0x54E0558;
// found = CheckWeakPointerCheckOffset(hProcess, address);
//}
if (!found) {
printf("Failed to find Weak Pointer Check!\n");
return false;
}
{
BYTE newValues[28] = {
0x48, 0x8B, 0x06,
0x48, 0x8D, 0x4D, 0x7F,
0x48, 0x89, 0x45, 0x7F,
0xE8, 0xDC, 0xC7, 0xA7, 0x00,
0x48, 0x85, 0xC0,
0x0F, 0x84, 0xEE, 0x00, 0x00, 0x00,
0x90, 0x90, 0x90
};
bool result = WriteBytes(hProcess, address, newValues, sizeof(newValues));
if (!result) {
return false;
}
}
address = imageBase + 0x16D7D34;
found = CheckWeakPointerCheckJumpOffset(hProcess, address);
// TODO
//if (!found) {
// address = imageBase + 0x54E0558;
// found = CheckWeakPointerCheckJumpOffset(hProcess, address);
//}
if (!found) {
printf("Failed to find Weak Pointer Check Jump!\n");
return false;
}
{
BYTE newValues[1] = { 0xEC };
bool result = WriteBytes(hProcess, address, newValues, sizeof(newValues));
if (!result) {
return false;
}
}
return true;
}
bool CheckUpdateInventoryFunctionOffset(HANDLE hProcess, uintptr_t address) {
BYTE buffer[2];
SIZE_T bytesRead;
if (ReadProcessMemory(hProcess, (LPCVOID)address, buffer, sizeof(buffer), &bytesRead)) {
return buffer[0] == 0x84 && buffer[1] == 0xC0;
}
else {
DWORD error = GetLastError();
if (error == 299) {
printf("Incomplete memory read. Retrying...\n");
Sleep(1000);
return CheckUpdateInventoryFunctionOffset(hProcess, address);
}
printf("Failed to read memory. Error: %lu\n", error);
return false;
}
}
bool PatchUpdateInventory(HANDLE hProcess, uintptr_t imageBase) {
uintptr_t address = imageBase + 0x18D5A11;
bool found = CheckUpdateInventoryFunctionOffset(hProcess, address);
if (!found) {
address = imageBase + 0x3D19B19;
found = CheckUpdateInventoryFunctionOffset(hProcess, address);
}
if (!found) {
printf("Failed to patch inventory update function!\n");
return false;
}
BYTE newValues[8] = { 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90 };
bool result = WriteBytes(hProcess, address, newValues, sizeof(newValues));
if (!result) {
return false;
}
return true;
}
bool CheckSetupPlayerContractsOffset(HANDLE hProcess, uintptr_t address) {
BYTE buffer[2];
SIZE_T bytesRead;
if (ReadProcessMemory(hProcess, (LPCVOID)address, buffer, sizeof(buffer), &bytesRead)) {
return buffer[0] == 0x48 && buffer[1] == 0x8D;
}
else {
DWORD error = GetLastError();
if (error == 299) {
printf("Incomplete memory read. Retrying...\n");
Sleep(1000);
return CheckSetupPlayerContractsOffset(hProcess, address);
}
printf("Failed to read memory. Error: %lu\n", error);
return false;
}
}
bool CheckUpdatePlayerContractsOffset(HANDLE hProcess, uintptr_t address) {
BYTE buffer[2];
SIZE_T bytesRead;
if (ReadProcessMemory(hProcess, (LPCVOID)address, buffer, sizeof(buffer), &bytesRead)) {
return buffer[0] == 0x3B && buffer[1] == 0xC1;
}
else {
DWORD error = GetLastError();
if (error == 299) {
printf("Incomplete memory read. Retrying...\n");
Sleep(1000);
return CheckUpdatePlayerContractsOffset(hProcess, address);
}
printf("Failed to read memory. Error: %lu\n", error);
return false;
}
}
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!\n");
return false;
}
{
BYTE newValues[4] = { 0x90, 0x90, 0xEB, 0x21 };
bool result = WriteBytes(hProcess, address, newValues, sizeof(newValues));
if (!result) {
return false;
}
}
address = imageBase + 0x1706127;
found = CheckSetupPlayerContractsOffset(hProcess, address);
if (!found) {
printf("Failed to find setup player contracts function!\n");
return false;
}
{
BYTE newValues[39] = {
0x45, 0x33, 0xC0, 0x49, 0x8B, 0xD5, 0x48, 0x8B, 0x89, 0xD8, 0x00, 0x00, 0x00, 0xE8, 0x47,
0xE2, 0x10, 0x00, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90,
0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0xEB
};
bool result = WriteBytes(hProcess, address, newValues, sizeof(newValues));
if (!result) {
return false;
}
}
return true;
}
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);
if (!remotePath) {
wprintf(L"Failed to allocate memory in remote process. Error: %lu\n", GetLastError());
CloseHandle(hProcess);
return false;
}
// Write the DLL path into the target process
if (!WriteProcessMemory(hProcess, remotePath, dllPath, pathSize, NULL)) {
wprintf(L"Failed to write memory. Error: %lu\n", GetLastError());
VirtualFreeEx(hProcess, remotePath, 0, MEM_RELEASE);
CloseHandle(hProcess);
return false;
}
// Get the address of LoadLibraryW in kernel32.dll
LPVOID loadLibraryAddr = (LPVOID)GetProcAddress(GetModuleHandleW(L"kernel32.dll"), "LoadLibraryW");
if (!loadLibraryAddr) {
wprintf(L"Failed to get LoadLibraryW address. Error: %lu\n", GetLastError());
VirtualFreeEx(hProcess, remotePath, 0, MEM_RELEASE);
CloseHandle(hProcess);
return false;
}
// Create a remote thread to call LoadLibraryW with our DLL path
HANDLE hThread = CreateRemoteThread(
hProcess, NULL, 0,
(LPTHREAD_START_ROUTINE)loadLibraryAddr,
remotePath, 0, NULL);
if (!hThread) {
wprintf(L"Failed to create remote thread. Error: %lu\n", GetLastError());
VirtualFreeEx(hProcess, remotePath, 0, MEM_RELEASE);
CloseHandle(hProcess);
return false;
}
return true;
}
bool Patch(const wchar_t* processName) {
DWORD processID;
HANDLE hProcess = StartProcess(processName, &processID);
if (hProcess == NULL) {
return false;
}
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);
if (success) {
return false;
}
// Clean up
CloseHandle(hProcess);
return true;
}
bool WriteSteamAppIDFile() {
const char* filename = "steam_appid.txt";
FILE* fp;
if (fopen_s(&fp, filename, "r") == 0) {
// File exists, close the file
fclose(fp);
printf("File '%s' already exists.\n", filename);
return true;
}
else {
// File does not exist, create and write to it
if (fopen_s(&fp, filename, "w") == 0) {
const char* content = "480";
size_t written = fwrite(content, sizeof(char), strlen(content), fp);
fclose(fp);
printf("File '%s' was created and written successfully.\n", filename);
}
else {
printf("Error creating the file '%s'.\n", filename);
return false;
}
}
return true;
}
int main() {
printf("Current working directory: %ws\n", ExePath().c_str());
bool success = WriteSteamAppIDFile();
if (!success) {
// Wait for user input to exit
printf("An error occurred when writing steam_appid.txt! Press any key to exit...\n");
getchar();
return 1;
}
success = Patch(L"Prospect-Win64-Shipping.exe");
if (!success) {
// Wait for user input to exit
printf("An error occurred when patching the executable! Press any key to exit...\n");
getchar();
return 1;
}
return 0;
}
@@ -4,6 +4,4 @@ public class PlayFabSettings
{
public string PublisherId { get; set; }
public string TitleId { get; set; }
public string SignalRURL { get; set; }
public string SignalRAccessToken { get; set; }
}
@@ -1,431 +0,0 @@
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Mvc;
using Prospect.Server.Api.Services.Database;
using Prospect.Server.Api.Services.Database.Models;
using Prospect.Server.Api.Services.GameRef;
using Prospect.Server.Api.Services.Steam;
using Prospect.Server.Api.Services.UserData;
namespace Prospect.Server.Api.Controllers;
// Read-only back-office to visualise everything relevant in the database.
// Served at /admin, restricted to LAN / loopback callers (an admin panel must not be
// reachable from the public internet even though :8443 is port-forwarded).
// List pages (players, catalog) are driven by a small self-contained client-side table
// engine (sort / filter / search / paging) fed by the /admin/api/* JSON endpoints.
[Route("admin")]
public class AdminController : ControllerBase
{
private readonly DbUserService _userService;
private readonly DbEntityService _entityService;
private readonly DbUserDataService _userDataService;
private readonly TitleDataService _titleDataService;
private readonly SteamWebApiService _steamWebApi;
private readonly GameRefService _gameRef;
public AdminController(DbUserService userService, DbEntityService entityService,
DbUserDataService userDataService, TitleDataService titleDataService, SteamWebApiService steamWebApi,
GameRefService gameRef)
{
_userService = userService;
_entityService = entityService;
_userDataService = userDataService;
_titleDataService = titleDataService;
_steamWebApi = steamWebApi;
_gameRef = gameRef;
}
// ---------- pages ----------
[HttpGet("")]
public async Task<IActionResult> Index()
{
if (!IsLan()) return Denied();
var users = await _userService.GetAllAsync();
var entities = await _entityService.GetAllAsync();
var dataCount = await _userDataService.CountAsync();
var titleKeys = _titleDataService.Find(new List<string>());
var sb = new StringBuilder();
sb.Append("<div class='cards'>");
sb.Append(Card("Joueurs", users.Count.ToString()));
sb.Append(Card("Entités", entities.Count.ToString()));
sb.Append(Card("Docs UserData", dataCount.ToString()));
sb.Append(Card("Clés TitleData", titleKeys.Count.ToString()));
sb.Append(Card("Catalogue", _gameRef.All.Count.ToString()));
sb.Append("</div>");
sb.Append("<p><a class='btn' href='/admin/players'>Joueurs →</a> &nbsp; <a class='btn' href='/admin/catalog'>Catalogue →</a> &nbsp; <a class='btn' href='/admin/titledata'>TitleData →</a></p>");
return Html(Layout("Dashboard", sb.ToString()));
}
[HttpGet("players")]
public IActionResult Players()
{
if (!IsLan()) return Denied();
var body =
"<h2>Joueurs</h2>" +
"<p><a class='btn' href='/admin/backfill-names' title='Récupère les pseudos Steam des comptes sans nom'>⟳ Backfill pseudos Steam</a></p>" +
"<div id='tbl'></div>" +
"<script>DT.init({id:'tbl',url:'/admin/api/players',size:25,filters:['auth'],columns:[" +
"{key:'name',label:'Nom',kind:'link',base:'/admin/players/',lkey:'id'}," +
"{key:'id',label:'PlayFab Id',kind:'mono'}," +
"{key:'entity',label:'Entity Id',kind:'mono'}," +
"{key:'auth',label:'Auth',kind:'mono'}]});</script>";
return Html(Layout("Joueurs", body));
}
[HttpGet("catalog")]
public IActionResult Catalog()
{
if (!IsLan()) return Denied();
var body =
"<h2>Catalogue</h2>" +
"<p class='muted'>Référence dataminée (TCF-Wiki) — " + _gameRef.All.Count + " entrées.</p>" +
"<div id='tbl'></div>" +
"<script>DT.init({id:'tbl',url:'/admin/api/catalog',size:50,filters:['category','rarity'],columns:[" +
"{key:'name',label:'Nom'}," +
"{key:'rarity',label:'Rareté',kind:'rarity'}," +
"{key:'category',label:'Catégorie'}," +
"{key:'id',label:'id',kind:'mono'}]});</script>";
return Html(Layout("Catalogue", body));
}
[HttpGet("players/{id}")]
public async Task<IActionResult> Player(string id)
{
if (!IsLan()) return Denied();
var user = await _userService.FindByIdAsync(id);
if (user == null) return Html(Layout("Joueur introuvable", "<p>Aucun joueur avec cet id.</p>"));
var entity = await _entityService.FindAsync(id);
var data = await _userDataService.GetAllForUserAsync(id);
var map = data.ToDictionary(d => d.Key, d => d);
var sb = new StringBuilder();
var auth = user.Auth != null ? string.Join(", ", user.Auth.Select(a => $"{a.Type}:{a.Key}")) : "";
sb.Append($"<p><a class='lnk' href='/admin/players'>← Joueurs</a></p>");
sb.Append($"<h2>{Esc(user.DisplayName)}</h2>");
sb.Append($"<p class='muted mono'>PlayFabId {Esc(user.Id)} · Entity {Esc(entity?.Id ?? "-")} · {Esc(auth)}</p>");
sb.Append("<div class='cards'>");
if (map.TryGetValue("Balance", out var bal))
{
sb.Append(Card("K-Marks", GetNum(bal.Value, "SoftCurrency").ToString()));
sb.Append(Card("Aurum", GetNum(bal.Value, "HardCurrency").ToString()));
sb.Append(Card("Insurance", GetNum(bal.Value, "InsuranceCurrency").ToString()));
}
foreach (var f in new[] { "Korolev", "Osiris", "ICA" })
if (map.TryGetValue("FactionProgression" + f, out var fp))
sb.Append(Card("Rép. " + f, (fp.Value ?? "0").Trim()));
foreach (var s in new[] { "2", "3" })
if (map.TryGetValue($"FortunaPass{s}_SeasonXp", out var xp) && (xp.Value ?? "0").Trim() != "0")
sb.Append(Card($"Fortuna S{s} XP", (xp.Value ?? "0").Trim()));
sb.Append("</div>");
if (map.TryGetValue("Inventory", out var inv))
{
sb.Append("<h3>Inventaire</h3>");
sb.Append(InventoryTable(inv.Value));
}
sb.Append("<h3>Toutes les données</h3>");
foreach (var d in data)
{
sb.Append("<details><summary>" + Esc(d.Key) +
$" <span class='muted small'>· {d.Value?.Length ?? 0} car · maj {d.LastUpdated:yyyy-MM-dd HH:mm}{(d.Public ? " · public" : "")}</span></summary>");
sb.Append($"<pre>{Esc(Pretty(d.Value))}</pre></details>");
}
return Html(Layout("Joueur · " + user.DisplayName, sb.ToString()));
}
[HttpGet("titledata")]
public IActionResult TitleData()
{
if (!IsLan()) return Denied();
var all = _titleDataService.Find(new List<string>());
var sb = new StringBuilder();
sb.Append("<h2>TitleData</h2>");
sb.Append("<table><thead><tr><th>Clé</th><th>Taille</th><th></th></tr></thead><tbody>");
foreach (var kv in all.OrderBy(k => k.Key))
sb.Append($"<tr><td class='mono'>{Esc(kv.Key)}</td><td>{kv.Value?.Length ?? 0} car</td>" +
$"<td><a class='btn' href='/admin/titledata/{Esc(kv.Key)}'>Voir</a></td></tr>");
sb.Append("</tbody></table>");
return Html(Layout("TitleData", sb.ToString()));
}
[HttpGet("titledata/{key}")]
public IActionResult TitleDataKey(string key)
{
if (!IsLan()) return Denied();
var all = _titleDataService.Find(new List<string> { key });
if (!all.TryGetValue(key, out var value))
return Html(Layout("Clé introuvable", "<p>Aucune clé TitleData avec ce nom.</p>"));
var body = value != null && value.Length > 300_000
? $"<p class='muted'>Valeur volumineuse ({value.Length} car) — affichage brut.</p><pre>{Esc(value)}</pre>"
: $"<pre>{Esc(Pretty(value))}</pre>";
return Html(Layout("TitleData · " + key, $"<p><a class='lnk' href='/admin/titledata'>← TitleData</a></p><h2 class='mono'>{Esc(key)}</h2>" + body));
}
[HttpGet("backfill-names")]
public async Task<IActionResult> BackfillNames()
{
if (!IsLan()) return Denied();
if (!_steamWebApi.Enabled)
return Html(Layout("Backfill", "<p class='muted'>Steam Web API non configurée (clé absente).</p><p><a class='btn' href='/admin/players'>← Joueurs</a></p>"));
var users = await _userService.GetAllAsync();
int updated = 0, skipped = 0, failed = 0;
var rows = new StringBuilder();
foreach (var u in users)
{
if (!string.IsNullOrEmpty(u.DisplayName) && u.DisplayName != "Unknown") { skipped++; continue; }
var steam = u.Auth?.FirstOrDefault(a => a.Type == PlayFabUserAuthType.Steam)?.Key;
if (string.IsNullOrEmpty(steam)) { skipped++; continue; }
var persona = await _steamWebApi.GetPersonaNameAsync(steam);
if (!string.IsNullOrWhiteSpace(persona))
{
await _userService.UpdateDisplayNameAsync(u.Id, persona);
updated++;
rows.Append($"<tr><td class='mono small'>{Esc(u.Id)}</td><td>{Esc(persona)}</td></tr>");
}
else { failed++; }
}
var body = $"<h2>Backfill des pseudos Steam</h2><p class='muted'>{updated} mis à jour · {skipped} ignorés · {failed} échec(s)</p>" +
(updated > 0 ? $"<table><thead><tr><th>PlayFabId</th><th>Nouveau nom</th></tr></thead><tbody>{rows}</tbody></table>" : "") +
"<p><a class='btn' href='/admin/players'>← Joueurs</a></p>";
return Html(Layout("Backfill", body));
}
// ---------- JSON API (consumed by the client-side table engine) ----------
[HttpGet("api/players")]
public async Task<IActionResult> ApiPlayers()
{
if (!IsLan()) return Denied();
var users = await _userService.GetAllAsync();
var entities = (await _entityService.GetAllAsync())
.GroupBy(e => e.UserId).ToDictionary(g => g.Key, g => g.First().Id);
var list = users.Select(u => new
{
name = string.IsNullOrEmpty(u.DisplayName) ? u.Id : u.DisplayName,
id = u.Id,
entity = entities.TryGetValue(u.Id, out var e) ? e : "",
auth = u.Auth != null ? string.Join(", ", u.Auth.Select(a => $"{a.Type}:{a.Key}")) : ""
});
return Json(list);
}
[HttpGet("api/catalog")]
public IActionResult ApiCatalog()
{
if (!IsLan()) return Denied();
var list = _gameRef.All.Select(kv => new
{
id = kv.Key,
name = kv.Value.Name,
rarity = kv.Value.Rarity,
category = kv.Value.Category
});
return Json(list);
}
// ---------- helpers ----------
private bool IsLan()
{
var ip = HttpContext.Connection.RemoteIpAddress;
if (ip == null) return false;
if (IPAddress.IsLoopback(ip)) return true;
if (ip.IsIPv4MappedToIPv6) ip = ip.MapToIPv4();
if (ip.AddressFamily == AddressFamily.InterNetwork)
{
var b = ip.GetAddressBytes();
if (b[0] == 10) return true;
if (b[0] == 192 && b[1] == 168) return true;
if (b[0] == 172 && b[1] >= 16 && b[1] <= 31) return true;
if (b[0] == 127) return true;
return false;
}
var s = ip.GetAddressBytes();
if ((s[0] & 0xFE) == 0xFC) return true;
if (s[0] == 0xFE && (s[1] & 0xC0) == 0x80) return true;
return false;
}
private IActionResult Denied() =>
StatusCode(403, "Admin back-office is LAN-only. Access it from the local network (e.g. https://192.168.1.136:8443/admin).");
private IActionResult Html(string html) => Content(html, "text/html; charset=utf-8");
private IActionResult Json(object data) =>
Content(JsonSerializer.Serialize(data), "application/json; charset=utf-8");
private static string Esc(string? s) => WebUtility.HtmlEncode(s ?? "");
private static string Pretty(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return json ?? "";
try
{
using var doc = JsonDocument.Parse(json);
return JsonSerializer.Serialize(doc.RootElement, new JsonSerializerOptions { WriteIndented = true });
}
catch { return json; }
}
private static long GetNum(string? json, string prop)
{
if (string.IsNullOrWhiteSpace(json)) return 0;
try
{
using var doc = JsonDocument.Parse(json);
foreach (var p in doc.RootElement.EnumerateObject())
if (string.Equals(p.Name, prop, StringComparison.OrdinalIgnoreCase) && p.Value.ValueKind == JsonValueKind.Number)
return p.Value.GetInt64();
}
catch { }
return 0;
}
private string InventoryTable(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return "<p class='muted'>vide</p>";
try
{
using var doc = JsonDocument.Parse(json);
if (doc.RootElement.ValueKind != JsonValueKind.Array) return $"<pre>{Esc(Pretty(json))}</pre>";
var sb = new StringBuilder();
var count = doc.RootElement.GetArrayLength();
sb.Append($"<p class='muted'>{count} item(s)</p>");
sb.Append("<table><thead><tr><th>Nom</th><th>Rareté</th><th>baseItemId</th><th>qté</th><th>durab.</th><th>assurance</th></tr></thead><tbody>");
foreach (var it in doc.RootElement.EnumerateArray())
{
string S(string k) => it.TryGetProperty(k, out var v) ? (v.ValueKind == JsonValueKind.Number ? v.GetRawText() : v.ToString()) : "";
var baseId = S("baseItemId");
var entry = _gameRef.Get(baseId);
var name = entry?.Name ?? baseId;
sb.Append($"<tr><td>{Esc(name)}</td><td>{Rarity(entry?.Rarity)}</td><td class='mono small'>{Esc(baseId)}</td>" +
$"<td>{Esc(S("amount"))}</td><td>{Esc(S("durability"))}</td><td>{Esc(S("insurance"))}</td></tr>");
}
sb.Append("</tbody></table>");
return sb.ToString();
}
catch { return $"<pre>{Esc(Pretty(json))}</pre>"; }
}
private static string Rarity(string? r)
{
if (string.IsNullOrEmpty(r)) return "";
var color = r.ToLowerInvariant() switch
{
"common" => "#9aa4b2",
"uncommon" => "#4caf50",
"rare" => "#2f81f7",
"epic" => "#a371f7",
"legendary" or "exotic" => "#e8a838",
_ => "#9aa4b2",
};
return $"<span style='color:{color};font-weight:600'>{Esc(r)}</span>";
}
private static string Card(string label, string value) =>
$"<div class='card'><div class='v'>{Esc(value)}</div><div class='l'>{Esc(label)}</div></div>";
private static string Layout(string title, string body) =>
Head.Replace("__TITLE__", Esc(title)) + DtScript + body + Foot;
// Static shell (no C# interpolation → JS braces/quotes stay literal). Single quotes only.
private const string Head = @"<!doctype html><html lang='fr'><head>
<meta charset='utf-8'><meta name='viewport' content='width=device-width,initial-scale=1'>
<title>__TITLE__ · The Cycle Admin</title>
<style>
:root{--bg:#0f1115;--panel:#171a21;--line:#262b36;--txt:#e6e8ec;--muted:#8b93a1;--acc:#e8a838;}
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--txt);font:14px/1.5 system-ui,Segoe UI,Roboto,sans-serif}
header{background:var(--panel);border-bottom:1px solid var(--line);padding:12px 20px;display:flex;gap:18px;align-items:center;position:sticky;top:0;z-index:10}
header b{color:var(--acc)}header a{color:var(--muted);text-decoration:none}header a:hover{color:var(--txt)}
main{padding:20px;max-width:1200px;margin:0 auto}
h2{margin:18px 0 10px}h3{margin:22px 0 8px;border-bottom:1px solid var(--line);padding-bottom:4px}
.cards{display:flex;flex-wrap:wrap;gap:12px;margin:12px 0}
.card{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:14px 18px;min-width:130px}
.card .v{font-size:22px;font-weight:700;color:var(--acc)}.card .l{color:var(--muted);font-size:12px}
table{width:100%;border-collapse:collapse;background:var(--panel);border:1px solid var(--line);border-radius:10px;overflow:hidden}
th,td{text-align:left;padding:8px 12px;border-bottom:1px solid var(--line);vertical-align:top}
th{background:#1c2029;color:var(--muted);font-weight:600}tr:last-child td{border-bottom:none}
tbody tr:hover{background:#1b2028}
.mono{font-family:ui-monospace,Consolas,monospace}.small{font-size:12px}.muted{color:var(--muted)}
.btn{display:inline-block;background:#222834;color:var(--txt);border:1px solid var(--line);border-radius:8px;padding:5px 12px;text-decoration:none;font-size:13px;cursor:pointer}
.btn:hover{border-color:var(--acc);color:var(--acc)}
a.lnk{color:var(--acc);text-decoration:none}a.lnk:hover{text-decoration:underline}
details{background:var(--panel);border:1px solid var(--line);border-radius:8px;margin:6px 0}summary{cursor:pointer;padding:8px 12px}
pre{margin:0;padding:12px;background:#0b0d11;border-top:1px solid var(--line);overflow:auto;max-height:480px;white-space:pre-wrap;word-break:break-word}
.dtbar{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin:12px 0}
.dtbar input,.dtbar select{background:var(--panel);border:1px solid var(--line);border-radius:8px;color:var(--txt);padding:7px 10px}
.dtsearch{flex:1;min-width:220px}
.dtcount{margin-left:auto}
th.dth{cursor:pointer;user-select:none;white-space:nowrap}th.dth:hover{color:var(--txt)}
.dtfoot{display:flex;gap:12px;align-items:center;margin:10px 0}
</style></head><body>
<header><b>The Cycle · Admin</b>
<a href='/admin'>Dashboard</a><a href='/admin/players'>Joueurs</a><a href='/admin/catalog'>Catalogue</a><a href='/admin/titledata'>TitleData</a>
<span class='muted' style='margin-left:auto'>read-only · LAN</span></header>
<main>";
private const string Foot = @"</main></body></html>";
// Client-side table engine, injected right after <main> so DT is defined before any
// page-level DT.init() runs. Single quotes only (verbatim string → JS braces stay literal).
private const string DtScript = @"<script>
const DT={
init(cfg){const el=document.getElementById(cfg.id);el.innerHTML='<p class=muted>Chargement…</p>';
fetch(cfg.url).then(r=>r.json()).then(rows=>DT.build(el,cfg,rows)).catch(e=>{el.innerHTML='<p class=muted>Erreur: '+e+'</p>';});},
build(el,cfg,rows){el.innerHTML='';el._rows=rows;el._cfg=cfg;el._sort={k:null,d:1};el._page=1;el._size=cfg.size||25;el._filters={};el._q='';
const bar=document.createElement('div');bar.className='dtbar';
const s=document.createElement('input');s.className='dtsearch';s.placeholder='Rechercher…';s.oninput=()=>{el._q=s.value.toLowerCase();el._page=1;DT.draw(el);};bar.appendChild(s);
(cfg.filters||[]).forEach(fk=>{const vals=[...new Set(rows.map(r=>r[fk]).filter(v=>v!=null&&v!==''))].sort();
const sel=document.createElement('select');const o0=document.createElement('option');o0.value='';o0.textContent=fk;sel.appendChild(o0);
vals.forEach(v=>{const o=document.createElement('option');o.value=v;o.textContent=v;sel.appendChild(o);});
sel.onchange=()=>{el._filters[fk]=sel.value;el._page=1;DT.draw(el);};bar.appendChild(sel);});
const sz=document.createElement('select');[25,50,100,500].forEach(n=>{const o=document.createElement('option');o.value=n;o.textContent=n+'/page';sz.appendChild(o);});
const oa=document.createElement('option');oa.value='0';oa.textContent='Tout';sz.appendChild(oa);sz.value=el._size;
sz.onchange=()=>{el._size=+sz.value;el._page=1;DT.draw(el);};bar.appendChild(sz);
const cnt=document.createElement('span');cnt.className='dtcount muted';bar.appendChild(cnt);el._cnt=cnt;el.appendChild(bar);
const tbl=document.createElement('table');const th=document.createElement('thead');const tr=document.createElement('tr');
cfg.columns.forEach(c=>{const h=document.createElement('th');h.className='dth';h.dataset.k=c.key;h.textContent=c.label;h.onclick=()=>DT.sort(el,c.key);tr.appendChild(h);});
th.appendChild(tr);tbl.appendChild(th);el._head=tr;const tb=document.createElement('tbody');tbl.appendChild(tb);el._tb=tb;el.appendChild(tbl);
const ft=document.createElement('div');ft.className='dtfoot';
const pv=document.createElement('button');pv.className='btn';pv.textContent=' Préc.';pv.onclick=()=>{if(el._page>1){el._page--;DT.draw(el);}};
const nf=document.createElement('span');nf.className='muted';el._nfo=nf;
const nx=document.createElement('button');nx.className='btn';nx.textContent='Suiv. ';nx.onclick=()=>{el._page++;DT.draw(el);};
ft.appendChild(pv);ft.appendChild(nf);ft.appendChild(nx);el.appendChild(ft);DT.draw(el);},
rows(el){const cfg=el._cfg;let rows=el._rows;
Object.keys(el._filters).forEach(k=>{const fv=el._filters[k];if(fv)rows=rows.filter(r=>(''+(r[k]??''))===fv);});
if(el._q)rows=rows.filter(r=>cfg.columns.some(c=>(''+(r[c.key]??'')).toLowerCase().includes(el._q)));
if(el._sort.k){const k=el._sort.k,d=el._sort.d;rows=[...rows].sort((a,b)=>{let x=a[k],y=b[k];const nx=parseFloat(x),ny=parseFloat(y);
if(!isNaN(nx)&&!isNaN(ny)&&(''+x).trim()!==''&&(''+y).trim()!==''){x=nx;y=ny;}else{x=(''+(x??'')).toLowerCase();y=(''+(y??'')).toLowerCase();}return x<y?-d:x>y?d:0;});}
return rows;},
sort(el,k){if(el._sort.k===k)el._sort.d*=-1;else{el._sort.k=k;el._sort.d=1;}el._page=1;DT.draw(el);},
draw(el){const cfg=el._cfg,rows=DT.rows(el),total=rows.length,size=el._size||total||1,pages=Math.max(1,Math.ceil(total/size));
if(el._page>pages)el._page=pages;const start=el._size?(el._page-1)*size:0;const slice=el._size?rows.slice(start,start+size):rows;
el._tb.innerHTML=slice.map(r=>'<tr>'+cfg.columns.map(c=>DT.cell(c,r)).join('')+'</tr>').join('')||'<tr><td colspan='+cfg.columns.length+' class=muted>Aucun résultat</td></tr>';
el._cnt.textContent=total+' résultat(s)';el._nfo.textContent='page '+el._page+'/'+pages;
[...el._head.children].forEach(h=>{const c=cfg.columns.find(x=>x.key===h.dataset.k);h.textContent=c.label+(el._sort.k===h.dataset.k?(el._sort.d>0?' ▲':' ▼'):'');});},
cell(c,r){let v=r[c.key];v=v==null?'':(''+v);
if(c.kind==='rarity')return '<td>'+DT.rarity(v)+'</td>';
if(c.kind==='link')return '<td><a class=lnk href='+c.base+encodeURIComponent(r[c.lkey||'id'])+'>'+DT.esc(v)+'</a></td>';
const cls=c.kind==='mono'?'mono small':'';return '<td class='+cls+'>'+DT.esc(v)+'</td>';},
rarity(r){if(!r)return '';const c={common:'#9aa4b2',uncommon:'#4caf50',rare:'#2f81f7',epic:'#a371f7',legendary:'#e8a838',exotic:'#e8a838'}[r.toLowerCase()]||'#9aa4b2';
return '<span style=color:'+c+';font-weight:600>'+DT.esc(r)+'</span>';},
esc(s){const d=document.createElement('div');d.textContent=(s==null?'':''+s);return d.innerHTML;}
};
</script>";
}
@@ -6,7 +6,6 @@ using Microsoft.Extensions.Options;
using Prospect.Server.Api.Config;
using Prospect.Server.Api.Models.Client;
using Prospect.Server.Api.Models.Client.Data;
using Prospect.Server.Api.Models.Data;
using Prospect.Server.Api.Services.Auth;
using Prospect.Server.Api.Services.Auth.Extensions;
using Prospect.Server.Api.Services.Auth.User;
@@ -32,7 +31,6 @@ public class ClientController : Controller
private readonly DbEntityService _entityService;
private readonly UserDataService _userDataService;
private readonly TitleDataService _titleDataService;
private readonly Prospect.Server.Api.Services.Steam.SteamWebApiService _steamWebApi;
public ClientController(ILogger<ClientController> logger,
IOptions<PlayFabSettings> settings,
@@ -40,8 +38,7 @@ public class ClientController : Controller
DbUserService userService,
DbEntityService entityService,
UserDataService userDataService,
TitleDataService titleDataService,
Prospect.Server.Api.Services.Steam.SteamWebApiService steamWebApi)
TitleDataService titleDataService)
{
_settings = settings.Value;
_logger = logger;
@@ -50,7 +47,6 @@ public class ClientController : Controller
_entityService = entityService;
_userDataService = userDataService;
_titleDataService = titleDataService;
_steamWebApi = steamWebApi;
}
[AllowAnonymous]
@@ -66,19 +62,6 @@ public class ClientController : Controller
var userSteamId = ticket.SteamId.ToString();
var user = await _userService.FindOrCreateAsync(PlayFabUserAuthType.Steam, userSteamId);
// Backfill the display name from the Steam persona when we don't have a real
// one yet (accounts are created as "Unknown"; the client never sends a name).
if (string.IsNullOrEmpty(user.DisplayName) || user.DisplayName == "Unknown")
{
var persona = await _steamWebApi.GetPersonaNameAsync(userSteamId);
if (!string.IsNullOrWhiteSpace(persona))
{
await _userService.UpdateDisplayNameAsync(user.Id, persona);
user.DisplayName = persona;
}
}
var entity = await _entityService.FindOrCreateAsync(user.Id);
var userTicket = _authTokenService.GenerateUser(entity);
@@ -156,18 +139,8 @@ public class ClientController : Controller
[HttpPost("AddGenericID")]
[Produces(MediaTypeNames.Application.Json)]
[Authorize(AuthenticationSchemes = UserAuthenticationOptions.DefaultScheme)]
public async Task<IActionResult> AddGenericId(FAddGenericIDRequest request)
public IActionResult AddGenericId(FAddGenericIDRequest request)
{
// Persist the linked generic service id instead of dropping it (was a no-op stub).
var userId = User.FindAuthUserId();
if (request.GenericId != null && !string.IsNullOrEmpty(request.GenericId.ServiceName))
{
await _userDataService.UpdateAsync(userId, userId, new Dictionary<string, string>
{
["GenericId_" + request.GenericId.ServiceName] = request.GenericId.UserId ?? ""
});
}
return Ok(new ClientResponse<object>
{
Code = 200,
@@ -181,131 +154,19 @@ public class ClientController : Controller
[Authorize(AuthenticationSchemes = UserAuthenticationOptions.DefaultScheme)]
public IActionResult GetStoreItems(FGetStoreItems request)
{
// The vendors (Korolev / ICA / Osiris / QuickShop) and other stores are defined
// inside the Blueprints title data: each item lists a per-store entry under
// ItemShopsCraftingData keyed by the store id, with the currency price in its
// recipe ingredients. This endpoint was previously a stub returning {}, which is
// why every shop showed up empty. We build the store on the fly from that data.
var storeId = request.StoreId ?? "";
var store = new List<FStoreItem>();
try
{
var titleData = _titleDataService.Find(new List<string> { "Blueprints" });
var blueprints = JsonSerializer.Deserialize<Dictionary<string, TitleDataBlueprintInfo>>(titleData["Blueprints"]);
if (blueprints != null && !string.IsNullOrEmpty(storeId))
{
uint position = 0;
foreach (var (itemId, blueprint) in blueprints)
{
if (blueprint.ItemShopsCraftingData == null) continue;
if (!blueprint.ItemShopsCraftingData.TryGetValue(storeId, out var shopData)) continue;
if (shopData?.ItemRecipeIngredients == null) continue;
// Only currency ingredients form a purchasable price. Entries whose
// recipe is item-based (crafting) are handled by the crafting UI from
// the blueprint data the client already has, not by the buy store.
var prices = new Dictionary<string, uint>();
foreach (var ingredient in shopData.ItemRecipeIngredients)
{
var code = ingredient.Currency switch
{
"SoftCurrency" => "SC",
"Aurum" => "AU",
"InsuranceCurrency" => "IN",
_ => null,
};
if (code == null || ingredient.Amount < 0) continue;
prices[code] = prices.TryGetValue(code, out var current)
? current + (uint)ingredient.Amount
: (uint)ingredient.Amount;
}
if (prices.Count == 0) continue;
store.Add(new FStoreItem
{
ItemId = itemId,
VirtualCurrencyPrices = prices,
RealCurrencyPrices = new Dictionary<string, uint>(),
DisplayPosition = position++,
});
}
}
}
catch (Exception e)
{
_logger.LogWarning(e, "Failed to build store items for store {StoreId}", storeId);
}
// Cosmetic / premium stores are backed by the Vanities title data (not Blueprints):
// each vanity carries its price in StoreData.Amount, paid in Aurum. AurumShop lists
// the whole priced catalogue; the daily/weekly rotations (ids from RequestStore
// RotationData) show a deterministic slice so they change over time but stay stable
// within a period.
var isAurumShop = storeId == "AurumShop";
var isDailyShop = storeId.StartsWith("DailyShop_");
var isWeeklyShop = storeId.StartsWith("WeeklyShop_");
if (store.Count == 0 && (isAurumShop || isDailyShop || isWeeklyShop))
{
try
{
var vanityData = _titleDataService.Find(new List<string> { "Vanities" });
var vanities = JsonSerializer.Deserialize<List<TitleDataVanityInfo>>(vanityData["Vanities"]);
var priced = vanities?
.Where(v => v.StoreData != null && v.StoreData.Amount > 0)
.OrderBy(v => v.Name, StringComparer.Ordinal)
.ToList() ?? new List<TitleDataVanityInfo>();
IEnumerable<TitleDataVanityInfo> selection = priced;
if ((isDailyShop || isWeeklyShop) && priced.Count > 0)
{
var size = isWeeklyShop ? 12 : 6;
int.TryParse(storeId[(storeId.IndexOf('_') + 1)..], out var rotation);
var start = (int)(((long)rotation * size) % priced.Count);
selection = Enumerable.Range(0, Math.Min(size, priced.Count))
.Select(i => priced[(start + i) % priced.Count]);
}
uint position = 0;
foreach (var vanity in selection)
{
store.Add(new FStoreItem
{
ItemId = vanity.Name,
VirtualCurrencyPrices = new Dictionary<string, uint> { ["AU"] = (uint)vanity.StoreData.Amount },
RealCurrencyPrices = new Dictionary<string, uint>(),
DisplayPosition = position++,
});
}
}
catch (Exception e)
{
_logger.LogWarning(e, "Failed to build vanity store {StoreId}", storeId);
}
}
_logger.LogInformation("GetStoreItems '{StoreId}': {Count} item(s)", storeId, store.Count);
return Ok(new ClientResponse<FGetStoreItemsResult>
return Ok(new ClientResponse<object>
{
Code = 200,
Status = "OK",
Data = new FGetStoreItemsResult
{
StoreId = string.IsNullOrEmpty(storeId) ? null : storeId,
CatalogVersion = "StaticItems",
Store = store,
}
Data = new {}
});
}
[HttpPost("UpdateUserTitleDisplayName")]
[Produces(MediaTypeNames.Application.Json)]
[Authorize(AuthenticationSchemes = UserAuthenticationOptions.DefaultScheme)]
public async Task<IActionResult> UpdateUserTitleDisplayName(FUpdateUserTitleDisplayNameRequest request)
public IActionResult UpdateUserTitleDisplayName(FUpdateUserTitleDisplayNameRequest request)
{
// Persist the rename, otherwise it is echoed back but lost on next login.
var userId = User.FindAuthUserId();
await _userService.UpdateDisplayNameAsync(userId, request.DisplayName);
return Ok(new ClientResponse<FUpdateUserTitleDisplayNameResult>
{
Code = 200,
@@ -382,15 +243,13 @@ public class ClientController : Controller
public async Task<IActionResult> GetUserInventory(FGetUserInventoryRequest request)
{
var userId = User.FindAuthUserId();
var userData = await _userDataService.FindAsync(userId, userId, new List<string>{"Balance", "Inventory", "VanityItems", "CharacterVanity", "GlobalVanity"});
var userData = await _userDataService.FindAsync(userId, userId, new List<string>{"Balance", "Inventory", "VanityItems"});
var inventory = JsonSerializer.Deserialize<FYCustomItemInfo[]>(userData["Inventory"].Value);
// Per-user vanity ownership: the player owns the cosmetics they've bought (VanityItems)
// plus whatever is currently equipped (CharacterVanity / GlobalVanity), so the starter
// outfit stays owned. Previously the whole Vanities catalogue was returned as owned,
// which made every item in the Aurum shop show up as already "owned".
var ownedVanityIds = CollectOwnedVanityIds(userData);
var titleData = _titleDataService.Find(new List<string>{"Blueprints"});
// TODO: Per-user vanity
// var vanity = JsonSerializer.Deserialize<CustomVanityItem[]>(userData["VanityItems"].Value);
var titleData = _titleDataService.Find(new List<string>{"Blueprints", "Vanities"});
var blueprints = JsonSerializer.Deserialize<Dictionary<string, TitleDataBlueprintInfo>>(titleData["Blueprints"]);
var vanity = JsonSerializer.Deserialize<TitleDataVanityInfo[]>(titleData["Vanities"]);
List<FItemInstance> items = new List<FItemInstance>();
foreach (var item in inventory) {
if (!blueprints.ContainsKey(item.BaseItemId)) {
@@ -416,9 +275,9 @@ public class ClientController : Controller
});
}
foreach (var vanityId in ownedVanityIds) {
foreach (var item in vanity) {
items.Add(new FItemInstance {
ItemId = vanityId,
ItemId = item.Name,
// ItemInstanceId is not required since it's not a unique item.
ItemClass = "Vanity",
// PurchaseDate = DateTime.Now, // TODO
@@ -437,66 +296,12 @@ public class ClientController : Controller
Data = new FGetUserInventoryResult
{
Inventory = items,
VirtualCurrency = JsonSerializer.Deserialize<PlayerBalance>(userData["Balance"].Value),
VirtualCurrency = JsonSerializer.Deserialize<Dictionary<string, int>>(userData["Balance"].Value),
VirtualCurrencyRechargeTimes = new Dictionary<string, FVirtualCurrencyRechargeTime>()
}
});
}
// Collects the vanity ids a player actually owns: everything they've purchased
// (VanityItems) plus everything currently equipped (CharacterVanity slots and the
// GlobalVanity sprays / banner / emotes / drop pod). Parsed defensively with
// JsonDocument so malformed / partial data never breaks the inventory response.
private static HashSet<string> CollectOwnedVanityIds(Dictionary<string, FUserDataRecord> userData)
{
var owned = new HashSet<string>(StringComparer.Ordinal);
void AddId(string? id) { if (!string.IsNullOrEmpty(id)) owned.Add(id); }
if (userData.TryGetValue("VanityItems", out var viRec) && !string.IsNullOrWhiteSpace(viRec.Value))
{
try
{
using var doc = JsonDocument.Parse(viRec.Value);
if (doc.RootElement.ValueKind == JsonValueKind.Array)
foreach (var el in doc.RootElement.EnumerateArray())
if (el.ValueKind == JsonValueKind.Object && el.TryGetProperty("baseItemId", out var b))
AddId(b.GetString());
}
catch { /* ignore malformed VanityItems */ }
}
if (userData.TryGetValue("CharacterVanity", out var cvRec) && !string.IsNullOrWhiteSpace(cvRec.Value))
{
try
{
using var doc = JsonDocument.Parse(cvRec.Value);
if (doc.RootElement.ValueKind == JsonValueKind.Object)
foreach (var prop in doc.RootElement.EnumerateObject())
if (prop.Value.ValueKind == JsonValueKind.Object && prop.Value.TryGetProperty("id", out var id))
AddId(id.GetString());
}
catch { /* ignore malformed CharacterVanity */ }
}
if (userData.TryGetValue("GlobalVanity", out var gvRec) && !string.IsNullOrWhiteSpace(gvRec.Value))
{
try
{
using var doc = JsonDocument.Parse(gvRec.Value);
var root = doc.RootElement;
if (root.ValueKind == JsonValueKind.Object)
{
if (root.TryGetProperty("activeGlobalVanityIds", out var arr) && arr.ValueKind == JsonValueKind.Array)
foreach (var el in arr.EnumerateArray()) AddId(el.GetString());
if (root.TryGetProperty("droppodId", out var dp)) AddId(dp.GetString());
}
}
catch { /* ignore malformed GlobalVanity */ }
}
return owned;
}
[HttpPost("GetTitleData")]
[Produces(MediaTypeNames.Application.Json)]
[Authorize(AuthenticationSchemes = UserAuthenticationOptions.DefaultScheme)]
File diff suppressed because one or more lines are too long
+2 -21
View File
@@ -1,31 +1,12 @@
using Microsoft.AspNetCore.SignalR;
using Microsoft.AspNetCore.SignalR;
namespace Prospect.Server.Api.Hubs;
public class CycleHub : Hub
{
private readonly SignalRConnectionRegistry _registry;
public CycleHub(SignalRConnectionRegistry registry)
{
_registry = registry;
}
public override async Task OnConnectedAsync()
{
// The client connects with the SignalR URL returned by GetSignalRConnection, which
// carries a `uid` query param — use it to map this connection to the player so
// server-initiated messages can target a single player instead of everyone.
var uid = Context.GetHttpContext()?.Request.Query["uid"].ToString();
if (!string.IsNullOrEmpty(uid))
_registry.Add(uid, Context.ConnectionId);
Console.WriteLine("Connected {0} (uid={1})", Context.ConnectionId, uid);
Console.WriteLine("Connected {0}", Context.ConnectionId);
await base.OnConnectedAsync();
}
public override async Task OnDisconnectedAsync(Exception? exception)
{
_registry.Remove(Context.ConnectionId);
await base.OnDisconnectedAsync(exception);
}
}
@@ -1,29 +0,0 @@
using System.Collections.Concurrent;
namespace Prospect.Server.Api.Hubs;
// Maps players to their live SignalR connection so server-initiated messages (e.g. the
// matchmaking-success "travel to match" signal) can target a single player instead of being
// broadcast to everyone. The client's SignalR URL carries a `uid` query param (added by
// GetSignalRConnection); CycleHub records it on connect.
public class SignalRConnectionRegistry
{
private readonly ConcurrentDictionary<string, string> _userToConn = new();
private readonly ConcurrentDictionary<string, string> _connToUser = new();
public void Add(string userId, string connectionId)
{
if (string.IsNullOrEmpty(userId)) return;
_userToConn[userId] = connectionId;
_connToUser[connectionId] = userId;
}
public void Remove(string connectionId)
{
if (_connToUser.TryRemove(connectionId, out var userId))
_userToConn.TryRemove(userId, out _);
}
public string? GetConnection(string userId)
=> _userToConn.TryGetValue(userId, out var c) ? c : null;
}
@@ -9,14 +9,6 @@ public class RequestLoggerMiddleware
private readonly ILogger<RequestLoggerMiddleware> _logger;
private readonly RequestDelegate _next;
// Keywords used to surface social/squad/invite traffic while reverse-engineering the
// squad-invite flow (see SQUAD-EMULATION.md). Matched against the path AND the body
// (the CloudScript function name lives in the body of /Client/ExecuteFunction).
private static readonly string[] SocialKeywords =
{
"group", "party", "lobby", "squad", "invite", "friend", "social", "matchmak",
};
public RequestLoggerMiddleware(ILogger<RequestLoggerMiddleware> logger, RequestDelegate next)
{
_logger = logger;
@@ -40,27 +32,6 @@ public class RequestLoggerMiddleware
}
await _next(context);
// ── Capture pass (squad-invite RE, see SQUAD-EMULATION.md) ──────────────
// Runs AFTER the pipeline so we know whether the request was actually routed.
if (context.Request.Method == "POST")
{
var path = context.Request.Path.Value ?? "";
var haystack = (path + " " + body).ToLowerInvariant();
// Any POST that fell through to a 404 = an endpoint the emulator does NOT implement
// (e.g. a native PlayFab /Group/CreateGroup or /Lobby/* the client tried to call).
if (context.Response.StatusCode == 404)
{
_logger.LogWarning("[CAPTURE UNROUTED] {Method} {Url} -> 404 | Body {Body}",
context.Request.Method, context.Request.GetDisplayUrl(), body);
}
else if (SocialKeywords.Any(k => haystack.Contains(k)))
{
_logger.LogInformation("[CAPTURE SOCIAL] {Url} ({Status}) | Body {Body}",
context.Request.GetDisplayUrl(), context.Response.StatusCode, body);
}
}
}
private static async Task<string> RequestAsync(HttpRequest request)
@@ -1,39 +0,0 @@
using System.Text.Json.Serialization;
namespace Prospect.Server.Api.Models.Client.Data;
// PlayFab StoreItem: a single catalog item offered for sale in a store, with its prices.
public class FStoreItem
{
/// <summary>
/// [optional] Store specific custom data. The data only exists as part of this store; it is not transferred to item instances.
/// </summary>
[JsonPropertyName("CustomData")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public object? CustomData { get; set; }
/// <summary>
/// [optional] Intended display position for this item. Note that 0 is the first position and -1 is the last position.
/// </summary>
[JsonPropertyName("DisplayPosition")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public uint? DisplayPosition { get; set; }
/// <summary>
/// Unique identifier of the item as it exists in the catalog - note that this must exactly match the ItemId from the catalog.
/// </summary>
[JsonPropertyName("ItemId")]
public string ItemId { get; set; } = "";
/// <summary>
/// [optional] Override prices for this item for specific currencies.
/// </summary>
[JsonPropertyName("RealCurrencyPrices")]
public Dictionary<string, uint> RealCurrencyPrices { get; set; } = new();
/// <summary>
/// [optional] Override prices for this item in virtual currencies and "RM" (the base Real Money purchase price, in USD pennies).
/// </summary>
[JsonPropertyName("VirtualCurrencyPrices")]
public Dictionary<string, uint> VirtualCurrencyPrices { get; set; } = new();
}
@@ -1,19 +1,8 @@
using System.Text.Json.Serialization;
using System.Text.Json.Serialization;
using Prospect.Server.Api.Models.Client.Data;
namespace Prospect.Server.Api.Models.Client;
public class FGetStoreItems
{
/// <summary>
/// [optional] Catalog version to store items from. Use default catalog version if null.
/// </summary>
[JsonPropertyName("CatalogVersion")]
public string? CatalogVersion { get; set; }
/// <summary>
/// Unique identifier for the store which is being requested.
/// </summary>
[JsonPropertyName("StoreId")]
public string? StoreId { get; set; }
}
@@ -1,25 +0,0 @@
using System.Text.Json.Serialization;
using Prospect.Server.Api.Models.Client.Data;
namespace Prospect.Server.Api.Models.Client;
public class FGetStoreItemsResult
{
/// <summary>
/// [optional] The base catalog that this store is a part of.
/// </summary>
[JsonPropertyName("CatalogVersion")]
public string? CatalogVersion { get; set; }
/// <summary>
/// [optional] Array of items which can be purchased from this store.
/// </summary>
[JsonPropertyName("Store")]
public List<FStoreItem> Store { get; set; } = new();
/// <summary>
/// [optional] The ID of this store.
/// </summary>
[JsonPropertyName("StoreId")]
public string? StoreId { get; set; }
}
@@ -1,6 +1,5 @@
using System.Text.Json.Serialization;
using Prospect.Server.Api.Models.Client.Data;
using Prospect.Server.Api.Models.Data;
namespace Prospect.Server.Api.Models.Client;
@@ -18,7 +17,7 @@ public class FGetUserInventoryResult
/// </summary>
[JsonPropertyName("VirtualCurrency")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public PlayerBalance? VirtualCurrency { get; set; }
public Dictionary<string, int>? VirtualCurrency { get; set; }
/// <summary>
/// [optional] Array of virtual currency balance(s) belonging to the user.
@@ -1,25 +0,0 @@
using System.Text.Json.Serialization;
using Prospect.Server.Api.Services.CloudScript.Models.Data;
namespace Prospect.Server.Api.Models.Data;
public class PlayerBalance
{
[JsonPropertyName("AU")]
public int HardCurrency { get; set; }
[JsonPropertyName("SC")]
public int SoftCurrency { get; set; }
[JsonPropertyName("IN")]
public int InsuranceCurrency { get; set; }
}
public class JobBoardsData
{
[JsonPropertyName("lastBoardRefreshTimeUtc")]
public FYTimestamp LastBoardRefreshTimeUtc { get; set; }
[JsonPropertyName("boards")]
public List<FYFactionContractsData> Boards { get; set; }
}
@@ -15,8 +15,6 @@ public class TitleDataContractInfo {
public TitleDataContractInfoUnlockData UnlockData { get; set; }
[JsonPropertyName("IsMainContract")]
public bool IsMainContract { get; set; }
[JsonPropertyName("ContractDifficulty")]
public EYContractDifficulty ContractDifficulty { get; set; }
}
public class TitleDataContractInfoReward {
@@ -28,127 +26,16 @@ public class TitleDataContractInfoReward {
public class TitleDataContractInfoObjective {
[JsonPropertyName("Type")]
public EYContractObjectiveType Type { get; set; }
public int Type { get; set; }
[JsonPropertyName("MaxProgress")]
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 {
[JsonPropertyName("Level")]
public int Level { get; set; }
[JsonPropertyName("Contracts")]
public HashSet<string> Contracts { get; set; }
public string[] Contracts { get; set; }
}
public enum EYContractObjectiveType {
Invalid = 0,
Kills = 1,
OwnNumOfItem = 2,
DeadDrop = 3,
VisitArea = 4,
LootContainer = 5,
FactionLevel = 6,
CompletedMission = 7,
MAX = 8
}
public static class ContractObjectiveTypeExtensions {
// Objectives that can only be observed while a raid is running (kills, dead-drops,
// visiting an area, looting a container). The raid is hosted by the player's own
// client and there is no dedicated game server, so their progress is never reported
// to the backend (UpdatePlayerActiveContracts is never called). They are therefore
// auto-credited server-side, otherwise these contracts can never be completed.
// OwnNumOfItem is excluded (validated against the real stash) and so are the meta
// objectives FactionLevel / CompletedMission (validated from persisted player data).
public static bool IsRaidRuntime(this EYContractObjectiveType type) =>
type is EYContractObjectiveType.Kills
or EYContractObjectiveType.DeadDrop
or EYContractObjectiveType.VisitArea
or EYContractObjectiveType.LootContainer;
}
public enum EYContractDifficulty
{
Invalid = 0,
Easy = 1,
Medium = 2,
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
};
@@ -28,11 +28,11 @@ public class TitleDataBlueprintInfo {
[JsonPropertyName("ScrappingFactionProgressionIncrement")]
public int ScrappingFactionProgressionIncrement { get; set; }
[JsonPropertyName("UnlockData")]
public TitleDataItemUnlockData UnlockData { get; set; }
public object UnlockData { get; set; }
[JsonPropertyName("ModID")]
public int? ModID { get; set; }
[JsonPropertyName("AlienForgeCatalystToUpgradedItemMap")]
public TitleDataItemAlienForgeCatalystToUpgradedItemMap AlienForgeCatalystToUpgradedItemMap { get; set; }
public object? AlienForgeCatalystToUpgradedItemMap { get; set; }
[JsonPropertyName("Rarity")]
public int Rarity { get; set; }
[JsonPropertyName("Kind")]
@@ -61,22 +61,6 @@ public class TitleDataItemRecipeCostType {
public int Amount { get; set; }
}
public class TitleDataItemUnlockData {
[JsonPropertyName("FactionUnlockLevel")]
public int FactionUnlockLevel { get; set; }
[JsonPropertyName("ContractLockPurchase")]
public string ContractLockPurchase { get; set; }
[JsonPropertyName("ContractLockCrafting")]
public string ContractLockCrafting { get; set; }
}
public class TitleDataItemAlienForgeCatalystToUpgradedItemMap {
[JsonPropertyName("Catalyst")]
public string Catalyst { get; set; }
[JsonPropertyName("NextItem")]
public string NextItem { get; set; }
}
/*
enum class EYItemType : uint8_t
{
@@ -7,7 +7,7 @@
"ASPNETCORE_Kestrel__Certificates__Default__Path": ".\\certificate.pfx"
},
"dotnetRunMessages": true,
"applicationUrl": "https://127.0.0.1:8443;http://127.0.0.1:8000",
"applicationUrl": "https://2EA46.playfabapi.com:443",
"useSSL": true
},
"IIS Express": {
@@ -7,8 +7,7 @@
"ASPNETCORE_Kestrel__Certificates__Default__Path": ".\\certificate.pfx"
},
"dotnetRunMessages": true,
"applicationUrl": "https://127.0.0.1:8443;http://127.0.0.1:8000",
"useSSL": true
"applicationUrl": "https://2EA46.playfabapi.com"
},
"IIS Express": {
"commandName": "IISExpress",
@@ -4,23 +4,6 @@
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>true</ImplicitUsings>
<Nullable>enable</Nullable>
<Configurations>Debug;Release;Season 3 Release;Season 2 Release;Season 2 Debug;Season 3 Debug</Configurations>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Season 3 Debug|AnyCPU'">
<Optimize>False</Optimize>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Season 3 Release|AnyCPU'">
<Optimize>True</Optimize>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Season 2 Release|AnyCPU'">
<Optimize>True</Optimize>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
<Optimize>True</Optimize>
</PropertyGroup>
<ItemGroup>
@@ -34,10 +17,4 @@
<ProjectReference Include="..\Prospect.Steam\Prospect.Steam.csproj" />
</ItemGroup>
<ItemGroup>
<!-- Community-datamined id -> name/rarity/category lookup (TCF-Wiki), used by the
admin back-office to show readable item/vanity names instead of raw ids. -->
<Content Update="Data\gameref.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>
@@ -55,11 +55,12 @@ 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", "ContractsOneTimeCompleted"});
var userData = await _userDataService.FindAsync(userId, userId, new List<string>{"ContractsActive"});
var titleData = _titleDataService.Find(new List<string>{"Contracts"});
var contracts = JsonSerializer.Deserialize<Dictionary<string, TitleDataContractInfo>>(titleData["Contracts"]);
if (!contracts.TryGetValue(request.ContractID, out var contract)) {
var contract = contracts[request.ContractID];
if (contract == null) {
return new FYActivateContractResult
{
UserID = userId,
@@ -67,32 +68,14 @@ 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];
// 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.Kills && objective.KillConditions.KillTarget == EYKillTypeAction.Players) {
progress[i] = objective.MaxProgress;
}
}
var contractsActive = JsonSerializer.Deserialize<FYGetActiveContractsResult>(userData["ContractsActive"].Value);
var contractData = new FYActiveContractPlayerData {
ContractID = request.ContractID,
Progress = progress,
};
// TODO: Max number of active jobs
contractsActive.Contracts.Add(contractData);
await _userDataService.UpdateAsync(
userId, userId,
@@ -32,13 +32,18 @@ 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
};
}
}
@@ -1,6 +1,5 @@
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.Models.Data;
using Prospect.Server.Api.Services.UserData;
@@ -50,8 +49,6 @@ public class ClaimActiveContract : ICloudScriptFunction<FYClaimCompletedActiveCo
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly UserDataService _userDataService;
private readonly TitleDataService _titleDataService;
private readonly string[] difficulties = ["Easy", "Medium", "Hard"];
private readonly Random rnd = new Random();
public ClaimActiveContract(IHttpContextAccessor httpContextAccessor, UserDataService userDataService, TitleDataService titleDataService)
{
@@ -68,10 +65,11 @@ public class ClaimActiveContract : ICloudScriptFunction<FYClaimCompletedActiveCo
throw new CloudScriptException("CloudScript was not called within a http request");
}
var userId = context.User.FindAuthUserId();
var titleData = _titleDataService.Find(new List<string>{ "Contracts", "Blueprints", "Jobs", "LevelData" });
var titleData = _titleDataService.Find(new List<string>{"Contracts", "Blueprints"});
var contracts = JsonSerializer.Deserialize<Dictionary<string, TitleDataContractInfo>>(titleData["Contracts"]);
if (!contracts.TryGetValue(request.ContractID, out var contract)) {
var contract = contracts[request.ContractID];
if (contract == null) {
return new FYClaimCompletedActiveContractRewardsResult
{
UserID = userId,
@@ -81,26 +79,18 @@ public class ClaimActiveContract : ICloudScriptFunction<FYClaimCompletedActiveCo
}
var factionKey = "FactionProgression" + contract.Faction;
// Fortuna pass season XP is stored per season; the client derives the pass level
// from this key. No challenge system is emulated, so contracts are the XP source.
#if SEASON_3_RELEASE || SEASON_3_DEBUG
const string seasonXpKey = "FortunaPass3_SeasonXp";
#else
const string seasonXpKey = "FortunaPass2_SeasonXp";
#endif
var userData = await _userDataService.FindAsync(
userId, userId,
new List<string>{"ContractsActive", "ContractsOneTimeCompleted", "Balance", "Inventory", factionKey, "JobBoardsData", seasonXpKey }
new List<string>{"ContractsActive", "ContractsOneTimeCompleted", "Balance", "Inventory", factionKey}
);
var factionProgression = JsonSerializer.Deserialize<int>(userData[factionKey].Value);
var contractsActive = JsonSerializer.Deserialize<FYGetActiveContractsResult>(userData["ContractsActive"].Value);
var contractsCompleted = JsonSerializer.Deserialize<FYGetCompletedContractsResult>(userData["ContractsOneTimeCompleted"].Value);
var jobBoardsData = JsonSerializer.Deserialize<JobBoardsData>(userData["JobBoardsData"].Value);
var balance = JsonSerializer.Deserialize<PlayerBalance>(userData["Balance"].Value);
var balance = JsonSerializer.Deserialize<Dictionary<string, int>>(userData["Balance"].Value);
var inventory = JsonSerializer.Deserialize<List<FYCustomItemInfo>>(userData["Inventory"].Value);
var targetContractIdx = contractsActive.Contracts.FindIndex(item => item.ContractID == contract.Name);
var targetContractIdx = contractsActive.Contracts.FindIndex(item => item.ContractID == request.ContractID);
if (targetContractIdx == -1) {
return new FYClaimCompletedActiveContractRewardsResult
{
@@ -112,17 +102,14 @@ public class ClaimActiveContract : ICloudScriptFunction<FYClaimCompletedActiveCo
var blueprints = JsonSerializer.Deserialize<Dictionary<string, TitleDataBlueprintInfo>>(titleData["Blueprints"]);
var targetContract = contractsActive.Contracts[targetContractIdx];
List<FYCustomItemInfo> itemsUpdatedOrRemoved = [];
HashSet<string> deletedItemsIds = [];
// TODO: Optimize
for (var i = 0; i < contract.Objectives.Length; i++) {
var objective = contract.Objectives[i];
// TODO: Proper progress verification of all objectives
foreach (var objective in contract.Objectives) {
int remaining = objective.MaxProgress;
if (objective.Type == EYContractObjectiveType.OwnNumOfItem) {
// NOTE: The backend may perform item ownership validation
// since it has the information about the player's items.
foreach (var item in inventory) {
if (objective.Type == 2) {
for (var i = 0; i < inventory.Count; i++) {
var item = inventory[i];
if (item.BaseItemId != objective.ItemToOwn) {
continue;
}
@@ -138,18 +125,6 @@ public class ClaimActiveContract : ICloudScriptFunction<FYClaimCompletedActiveCo
break;
}
}
} else if (objective.Type.IsRaidRuntime()) {
// Raid-runtime objectives (kills, dead-drops, visited areas, looted
// containers) can't be tracked in a client-hosted raid — no dedicated
// game server reports them, so their progress is never saved. They are
// auto-credited on deploy (EnterMatchmakingMatch); accept them here too
// (safety net for a contract activated after deploy) so these contracts
// remain completable instead of being stuck on "Objective not met".
remaining = 0;
} else {
// Meta objectives (FactionLevel / CompletedMission): validated from the
// persisted progress. The objectives are mapped by array index.
remaining -= targetContract.Progress[i];
}
if (remaining > 0) {
return new FYClaimCompletedActiveContractRewardsResult
@@ -173,19 +148,17 @@ public class ClaimActiveContract : ICloudScriptFunction<FYClaimCompletedActiveCo
List<FYCurrencyItem> changedCurrencies = [];
foreach (var reward in contract.Rewards) {
if (reward.ItemID == "SoftCurrency") {
balance.SoftCurrency += reward.Amount;
changedCurrencies.Add(new FYCurrencyItem { CurrencyName = "SoftCurrency", Amount = balance.SoftCurrency });
balance["SC"] += reward.Amount;
changedCurrencies.Add(new FYCurrencyItem { CurrencyName = "SoftCurrency", Amount = balance["SC"] });
} else if (reward.ItemID == "Aurum") {
balance.HardCurrency += reward.Amount;
changedCurrencies.Add(new FYCurrencyItem { CurrencyName = "Aurum", Amount = balance.HardCurrency });
} else if (reward.ItemID == "InsuranceCurrency") {
balance.InsuranceCurrency += reward.Amount;
changedCurrencies.Add(new FYCurrencyItem { CurrencyName = "InsuranceCurrency", Amount = balance.InsuranceCurrency });
balance["AU"] += reward.Amount;
changedCurrencies.Add(new FYCurrencyItem { CurrencyName = "Aurum", Amount = balance["AU"] });
} else if (reward.ItemID == "InsuranceToken") {
balance["IN"] += reward.Amount;
changedCurrencies.Add(new FYCurrencyItem { CurrencyName = "InsuranceToken", Amount = balance["IN"] });
} else {
// TODO: Inventory limit check
var blueprintData = blueprints[reward.ItemID];
var remainingAmount = reward.Amount; // Quest rewards are given individually
var remainingAmount = reward.Amount * blueprintData.AmountPerPurchase;
// Populate incomplete stacks first
foreach (var item in newInventory) {
@@ -228,47 +201,14 @@ public class ClaimActiveContract : ICloudScriptFunction<FYClaimCompletedActiveCo
factionProgression += contract.ReputationIncrease;
contractsActive.Contracts.RemoveAt(targetContractIdx);
// Main contracts can be completed only once.
// Other contracts should be jobs.
var newContractIdOnBoard = "";
if (contract.IsMainContract) {
contractsCompleted.ContractsIDs.Add(contract.Name);
} else {
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<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 == difficulty);
var factionJobs = jobs[contract.Faction];
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.
newContractIdOnBoard = jobsAvailable[rnd.Next(jobsAvailable.Length)];
factionBoard.Contracts[idx].ContractId = newContractIdOnBoard;
contractsCompleted.ContractsIDs.Add(request.ContractID);
}
// 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];
// 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.Kills && objective.KillConditions.KillTarget == EYKillTypeAction.Players) {
progress[i] = objective.MaxProgress;
}
}
var newContract = new FYActiveContractPlayerData {
ContractID = contractIdToUnlock,
Progress = progress,
@@ -277,12 +217,6 @@ public class ClaimActiveContract : ICloudScriptFunction<FYClaimCompletedActiveCo
newContracts.Add(newContract);
}
// Grant Fortuna pass season XP for completing this contract and persist it, so the
// pass level (derived client-side from FortunaPass{season}_SeasonXp) actually moves.
// No exact rate ships in the data, so scale it to the contract's reputation value.
var seasonXp = int.TryParse(userData[seasonXpKey].Value, out var currentSeasonXp) ? currentSeasonXp : 0;
seasonXp += contract.ReputationIncrease * 10;
await _userDataService.UpdateAsync(
userId, userId,
new Dictionary<string, string>{
@@ -291,8 +225,6 @@ public class ClaimActiveContract : ICloudScriptFunction<FYClaimCompletedActiveCo
["Balance"] = JsonSerializer.Serialize(balance),
["Inventory"] = JsonSerializer.Serialize(newInventory),
[factionKey] = JsonSerializer.Serialize(factionProgression),
["JobBoardsData"] = JsonSerializer.Serialize(jobBoardsData),
[seasonXpKey] = JsonSerializer.Serialize(seasonXp),
}
);
@@ -300,18 +232,18 @@ public class ClaimActiveContract : ICloudScriptFunction<FYClaimCompletedActiveCo
{
UserID = userId,
Error = "",
ClaimedContractID = contract.Name,
ClaimedContractID = request.ContractID,
ContractsActivated = newContracts,
ItemsUpdatedOrRemoved = itemsUpdatedOrRemoved,
ChangedCurrencies = changedCurrencies.ToArray(),
ItemsGranted = itemsGranted,
NewContractIdOnBoard = newContractIdOnBoard,
NewContractIdOnBoard = "", // TODO: This should be a replacement contract apparently?
PlayerFactionProgressData = new FYPlayerFactionProgressData {
FactionID = contract.Faction,
CurrentProgression = factionProgression,
},
Status = 18, // EYClaimContractRewardsStatus::OK
UpdatedSeasonXp = seasonXp // new season XP total (client derives the pass level from it)
UpdatedSeasonXp = 0 // TODO: Probably a separate season XP rate configured by server?
};
}
}
@@ -1,6 +1,5 @@
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.Models.Data;
using Prospect.Server.Api.Services.UserData;
@@ -104,7 +103,7 @@ public class ClaimGeneratorIncomeClient : ICloudScriptFunction<FYClaimGeneratorI
};
}
var balance = JsonSerializer.Deserialize<PlayerBalance>(userData["Balance"].Value);
var balance = JsonSerializer.Deserialize<Dictionary<string, int>>(userData["Balance"].Value);
var inventory = JsonSerializer.Deserialize<List<FYCustomItemInfo>>(userData["Inventory"].Value);
var techTreeBonuses = JsonSerializer.Deserialize<CharacterTechTreeBonuses>(userData["CharacterTechTreeBonuses"].Value);
@@ -137,8 +136,8 @@ public class ClaimGeneratorIncomeClient : ICloudScriptFunction<FYClaimGeneratorI
};
}
balance.SoftCurrency += claimableAmount;
changedCurrencies = [new FYCurrencyItem { CurrencyName = "SoftCurrency", Amount = balance.SoftCurrency }];
balance["SC"] += claimableAmount;
changedCurrencies = [new FYCurrencyItem { CurrencyName = "SoftCurrency", Amount = balance["SC"] }];
break;
}
case "playerquarters_gen_aurum": {
@@ -163,8 +162,8 @@ public class ClaimGeneratorIncomeClient : ICloudScriptFunction<FYClaimGeneratorI
};
}
balance.HardCurrency += claimableAmount;
changedCurrencies = [new FYCurrencyItem { CurrencyName = "Aurum", Amount = balance.HardCurrency }];
balance["AU"] += claimableAmount;
changedCurrencies = [new FYCurrencyItem { CurrencyName = "Aurum", Amount = balance["AU"] }];
break;
}
case "playerquarters_gen_crate": {
@@ -191,8 +190,7 @@ public class ClaimGeneratorIncomeClient : ICloudScriptFunction<FYClaimGeneratorI
var blueprints = JsonSerializer.Deserialize<Dictionary<string, TitleDataBlueprintInfo>>(titleData["Blueprints"]);
var rewardsPool = dailyCrateRewards[crateTier - 1];
// TODO: Get rewardGrants from PassiveGenerators_CrateRewards_DT
var items = PickItems(rewardsPool, 5);
var items = PickItems(rewardsPool, 5); // TODO: Not sure how many items were granted
// TODO: Stackable? GrantedItems doesn't imply granted OR updated.
foreach (var item in items) {
var itemInfo = blueprints[item.Name];
@@ -1,8 +1,5 @@
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.UserData;
public class ClientsideFriendsImportRequest
{
@@ -21,33 +18,15 @@ public class ClientsideFriendsImportResponse
public class ClientsideFriendsImportFunction : ICloudScriptFunction<ClientsideFriendsImportRequest, ClientsideFriendsImportResponse>
{
private readonly ILogger<ClientsideFriendsImportFunction> _logger;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly UserDataService _userDataService;
public ClientsideFriendsImportFunction(ILogger<ClientsideFriendsImportFunction> logger, IHttpContextAccessor httpContextAccessor, UserDataService userDataService)
public ClientsideFriendsImportFunction(ILogger<ClientsideFriendsImportFunction> logger)
{
_logger = logger;
_httpContextAccessor = httpContextAccessor;
_userDataService = userDataService;
}
public async Task<ClientsideFriendsImportResponse> ExecuteAsync(ClientsideFriendsImportRequest request)
{
var context = _httpContextAccessor.HttpContext;
if (context == null)
{
throw new CloudScriptException("CloudScript was not called within a http request");
}
var userId = context.User.FindAuthUserId();
// Persist the platform friend ids the client imports (Steam ids) so GetFriendList
// can later resolve which of them actually play on this server.
var ids = request.UserIDs ?? Array.Empty<string>();
await _userDataService.UpdateAsync(userId, userId, new Dictionary<string, string>
{
["ImportedSteamFriends"] = JsonSerializer.Serialize(ids),
});
_logger.LogInformation("Imported {Count} {Platform} friend id(s) for user {User}", ids.Length, request.Platform, userId);
_logger.LogInformation("Processing friends import");
return new ClientsideFriendsImportResponse{};
}
@@ -1,154 +0,0 @@
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,
};
}
}
@@ -1,56 +0,0 @@
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.SignalR;
using Prospect.Server.Api.Hubs;
using Prospect.Server.Api.Services.CloudScript.Models;
// Closed Beta Function
namespace Prospect.Server.Api.Services.CloudScript.Functions;
[CloudScriptFunction("EnterMatchmaking")]
public class EnterMatchmakingFunction : ICloudScriptFunction<FYEnterMatchAzureFunction, FYEnterMatchAzureFunctionResult>
{
private readonly IHubContext<CycleHub> _hubContext;
public EnterMatchmakingFunction(IHubContext<CycleHub> hubContext)
{
_hubContext = hubContext;
}
public async Task<FYEnterMatchAzureFunctionResult> ExecuteAsync(FYEnterMatchAzureFunction request)
{
//await _hubContext.Clients.All.SendAsync("OnSquadMatchmakingSuccess", new OnSquadMatchmakingSuccessMessage {
// Success = true,
// SessionID = request.MapName, // TODO: Need to implement TryGetCompleteSquadInfo and pass squad info
// SquadID = request.SquadId,
//});
// Dedicated-server mode (2nd preprod): if GAMESERVER_ADDRESS ("host:port") is set, hand
// the client the real game server to travel to instead of the client-hosted station.
// Same image as preprod — only this env var flips the deployment type.
var gameServer = Environment.GetEnvironmentVariable("GAMESERVER_ADDRESS");
if (!string.IsNullOrEmpty(gameServer))
{
var parts = gameServer.Split(':');
return new FYEnterMatchAzureFunctionResult
{
Success = true,
ErrorMessage = "",
SingleplayerStation = false,
Address = parts[0],
Port = parts.Length > 1 && int.TryParse(parts[1], out var gsPort) ? gsPort : 7777,
MaintenanceMode = false,
};
}
return new FYEnterMatchAzureFunctionResult
{
Success = true,
ErrorMessage = "",
SingleplayerStation = true,
Address = request.MapName,
MaintenanceMode = false,
Port = 7777,
};
}
}
@@ -1,10 +1,7 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.SignalR;
using Prospect.Server.Api.Hubs;
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;
@@ -20,104 +17,20 @@ public class OnSquadMatchmakingSuccessMessage {
[CloudScriptFunction("EnterMatchmakingMatch")]
public class EnterMatchmakingMatchFunction : ICloudScriptFunction<FYEnterMatchAzureFunction, FYEnterMatchmakingResult>
{
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly IHubContext<CycleHub> _hubContext;
private readonly UserDataService _userDataService;
private readonly TitleDataService _titleDataService;
private readonly SignalRConnectionRegistry _registry;
public EnterMatchmakingMatchFunction(IHubContext<CycleHub> hubContext, IHttpContextAccessor httpContextAccessor, UserDataService userDataService, TitleDataService titleDataService, SignalRConnectionRegistry registry)
public EnterMatchmakingMatchFunction(IHubContext<CycleHub> hubContext)
{
_httpContextAccessor = httpContextAccessor;
_hubContext = hubContext;
_userDataService = userDataService;
_titleDataService = titleDataService;
_registry = registry;
}
public async Task<FYEnterMatchmakingResult> ExecuteAsync(FYEnterMatchAzureFunction 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 titleData = _titleDataService.Find(new List<string>{"Contracts"});
var contracts = JsonSerializer.Deserialize<Dictionary<string, TitleDataContractInfo>>(titleData["Contracts"]);
var userData = await _userDataService.FindAsync(
userId, userId,
new List<string>{"ContractsActive", "Inventory"}
);
var inventory = JsonSerializer.Deserialize<List<FYCustomItemInfo>>(userData["Inventory"].Value);
var contractsActive = JsonSerializer.Deserialize<FYGetActiveContractsResult>(userData["ContractsActive"].Value);
// Compute delivery quest progress before deploy.
// This ensures that the quest progress will be shown on the planet.
// The station doesn't seem to care about delivery quests progress and calculates progress
// based on actual items in stash. And so does the "ClaimActiveContract" function.
foreach (var contractActive in contractsActive.Contracts) {
if (!contracts.TryGetValue(contractActive.ContractID, out var contract)) {
continue;
}
for (var i = 0; i < contract.Objectives.Length; i++) {
if (i >= contractActive.Progress.Length) {
// Defensive: player progress array shorter than the objective list.
break;
}
var objective = contract.Objectives[i];
// Raid-runtime objectives (kills, dead-drops, visited areas, looted
// containers) are simulated inside the client-hosted raid; with no
// dedicated game server their progress is never reported to the backend,
// so they would never be saved. Auto-credit them on deploy so the
// objective persists and the station shows the contract as completable.
if (objective.Type.IsRaidRuntime()) {
contractActive.Progress[i] = objective.MaxProgress;
continue;
}
if (objective.Type != EYContractObjectiveType.OwnNumOfItem) {
continue;
}
int remaining = objective.MaxProgress;
foreach (var item in inventory) {
if (item.BaseItemId != objective.ItemToOwn) {
continue;
}
remaining -= item.Amount;
if (remaining <= 0) {
remaining = 0;
break;
}
}
contractActive.Progress[i] = objective.MaxProgress - remaining;
}
}
await _userDataService.UpdateAsync(
userId, userId,
new Dictionary<string, string>{
["ContractsActive"] = JsonSerializer.Serialize(contractsActive),
}
);
// Send the "matchmaking succeeded / travel to match" signal only to the deploying
// player (via their mapped SignalR connection) — broadcasting to Clients.All made
// every connected player travel too. Fall back to broadcast only if the connection
// isn't mapped yet.
// Dedicated-server mode (2nd preprod): point the client at the real game server address
// to travel to; otherwise keep the map name (client-hosted). Same image — env flips it.
var gameServer = Environment.GetEnvironmentVariable("GAMESERVER_ADDRESS");
var deployMessage = new OnSquadMatchmakingSuccessMessage {
await _hubContext.Clients.All.SendAsync("OnSquadMatchmakingSuccess", new OnSquadMatchmakingSuccessMessage {
Success = true,
SessionID = string.IsNullOrEmpty(gameServer) ? request.MapName : gameServer,
SessionID = request.MapName, // TODO: Need to implement TryGetCompleteSquadInfo and pass squad info
SquadID = request.SquadId,
};
var connectionId = _registry.GetConnection(userId);
if (connectionId != null)
await _hubContext.Clients.Client(connectionId).SendAsync("OnSquadMatchmakingSuccess", deployMessage);
else
await _hubContext.Clients.All.SendAsync("OnSquadMatchmakingSuccess", deployMessage);
});
return new FYEnterMatchmakingResult
{
@@ -1,107 +1,18 @@
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;
using Prospect.Server.Api.Services.CloudScript.Models;
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>
{
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)
public Task<FYEnterMatchmakingResult> ExecuteAsync(FYEnterMatchAzureFunction request)
{
_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
return Task.FromResult(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,8 +1,6 @@
using System.Text.Json;
using Prospect.Server.Api.Services.Auth.Extensions;
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;
@@ -10,15 +8,13 @@ namespace Prospect.Server.Api.Services.CloudScript.Functions;
public class GetCraftingInProgressData : ICloudScriptFunction<FYGetCraftingInProgressDataRequest, FYGetCraftingInProgressDataResult>
{
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly UserDataService _userDataService;
public GetCraftingInProgressData(IHttpContextAccessor httpContextAccessor, UserDataService userDataService)
public GetCraftingInProgressData(IHttpContextAccessor httpContextAccessor)
{
_httpContextAccessor = httpContextAccessor;
_userDataService = userDataService;
}
public async Task<FYGetCraftingInProgressDataResult> ExecuteAsync(FYGetCraftingInProgressDataRequest request)
public Task<FYGetCraftingInProgressDataResult> ExecuteAsync(FYGetCraftingInProgressDataRequest request)
{
var context = _httpContextAccessor.HttpContext;
if (context == null)
@@ -26,20 +22,11 @@ public class GetCraftingInProgressData : ICloudScriptFunction<FYGetCraftingInPro
throw new CloudScriptException("CloudScript was not called within a http request");
}
var userId = context.User.FindAuthUserId();
// Return the persisted in-progress craft (written by StartItemCraftingClient)
// instead of an empty stub, so the crafting timer survives a menu reopen / relog.
var userData = await _userDataService.FindAsync(userId, userId, new List<string> { "CraftingTimer__2022_05_12" });
// Fully qualified: a different FYItemCurrentlyBeingCrafted also exists in this namespace.
var craft = JsonSerializer.Deserialize<Models.Data.FYItemCurrentlyBeingCrafted>(userData["CraftingTimer__2022_05_12"].Value)
?? new Models.Data.FYItemCurrentlyBeingCrafted();
return new FYGetCraftingInProgressDataResult
return Task.FromResult(new FYGetCraftingInProgressDataResult
{
UserId = userId,
UserId = context.User.FindAuthUserId(),
Error = string.Empty,
ItemCurrentlyBeingCrafted = craft,
};
ItemCurrentlyBeingCrafted = {},
});
}
}
@@ -1,119 +1,15 @@
using System.Text.Json;
using Prospect.Server.Api.Services.Auth.Extensions;
using Prospect.Server.Api.Services.CloudScript.Models;
using Prospect.Server.Api.Services.Database;
using Prospect.Server.Api.Services.Database.Models;
using Prospect.Server.Api.Services.UserData;
using Prospect.Server.Api.Services.CloudScript.Models;
namespace Prospect.Server.Api.Services.CloudScript.Functions;
[CloudScriptFunction("GetFriendList")]
public class GetFriendList : ICloudScriptFunction<FYBaseSocialRequest, object?>
{
private readonly ILogger<GetFriendList> _logger;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly UserDataService _userDataService;
private readonly DbUserService _userService;
public GetFriendList(ILogger<GetFriendList> logger, IHttpContextAccessor httpContextAccessor, UserDataService userDataService, DbUserService userService)
public Task<object?> ExecuteAsync(FYBaseSocialRequest request)
{
_logger = logger;
_httpContextAccessor = httpContextAccessor;
_userDataService = userDataService;
_userService = userService;
}
public async Task<object?> ExecuteAsync(FYBaseSocialRequest request)
{
var context = _httpContextAccessor.HttpContext;
if (context == null)
return Task.FromResult<object?>(new
{
throw new CloudScriptException("CloudScript was not called within a http request");
}
var userId = context.User.FindAuthUserId();
// Imported Steam friends (persisted by ClientsideFriendsImport), resolved to players
// that actually exist on this private server.
var steamIds = new List<string>();
var userData = await _userDataService.FindAsync(userId, userId, new List<string> { "ImportedSteamFriends" });
if (userData.TryGetValue("ImportedSteamFriends", out var rec) && !string.IsNullOrWhiteSpace(rec.Value))
{
try { steamIds = JsonSerializer.Deserialize<List<string>>(rec.Value) ?? new List<string>(); }
catch { /* ignore malformed */ }
}
var friends = new List<object>();
if (steamIds.Count > 0)
{
var players = await _userService.FindManyAsync(PlayFabUserAuthType.Steam, steamIds);
var nowUtc = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
foreach (var player in players)
{
if (player.Id == userId) continue; // never list yourself
var steamId = player.Auth?.FirstOrDefault(a => a.Type == PlayFabUserAuthType.Steam)?.Key ?? "";
var displayName = string.IsNullOrWhiteSpace(player.DisplayName) ? "Prospector" : player.DisplayName;
// 0 = offline, 1 = online, 2 = in match. Online if seen in the last 3 minutes.
var onlineState = 0;
var presenceData = await _userDataService.FindAsync(player.Id, player.Id, new List<string> { "PresenceState" });
if (presenceData.TryGetValue("PresenceState", out var presenceRec) && !string.IsNullOrWhiteSpace(presenceRec.Value))
{
try
{
var presence = JsonSerializer.Deserialize<PlayerPresenceState>(presenceRec.Value);
if (presence != null && nowUtc - presence.LastSeenUtc <= 180)
{
onlineState = presence.InMatch ? 2 : 1;
}
}
catch { /* ignore malformed presence */ }
}
// The exact model the UE client binds is not documented, so expose the identity
// under every plausible field name (camelCase + PlayFab PascalCase) and both a
// flat and nested profile — whichever the client reads, the tile gets populated.
friends.Add(new
{
// identity
friendPlayFabId = player.Id,
FriendPlayFabId = player.Id,
playerId = player.Id,
PlayerId = player.Id,
playFabId = player.Id,
PlayFabId = player.Id,
// name
displayName,
DisplayName = displayName,
titleDisplayName = displayName,
TitleDisplayName = displayName,
name = displayName,
username = displayName,
Username = displayName,
// steam
steamId,
SteamId = steamId,
steamInfo = new { steamId, SteamId = steamId },
SteamInfo = new { SteamId = steamId, steamId },
// presence
onlineState,
OnlineState = onlineState,
isOnline = onlineState > 0,
inMatch = onlineState == 2,
presence = new { onlineState, state = onlineState },
avatarUrl = "",
// nested profiles
profile = new { playerId = player.Id, displayName, avatarUrl = "" },
Profile = new { PlayerId = player.Id, DisplayName = displayName, PlayerProfileModel = new { DisplayName = displayName } },
tags = Array.Empty<string>(),
Tags = Array.Empty<string>(),
});
}
}
// Return the list under both the camelCase and PlayFab-cased container keys.
var result = new { friends, Friends = friends, count = friends.Count };
_logger.LogInformation("GetFriendList for {User}: {Count} friend(s); payload={Json}", userId, friends.Count, JsonSerializer.Serialize(result));
return result;
});
}
}
@@ -1,7 +1,5 @@
using System.Text.Json;
using Prospect.Server.Api.Services.Auth.Extensions;
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;
@@ -9,53 +7,43 @@ namespace Prospect.Server.Api.Services.CloudScript.Functions;
public class GetPlayerSets : ICloudScriptFunction<FYGetPlayersSets, object?>
{
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly UserDataService _userDataService;
public GetPlayerSets(IHttpContextAccessor httpContextAccessor, UserDataService userDataService)
public GetPlayerSets(IHttpContextAccessor httpContextAccessor)
{
_httpContextAccessor = httpContextAccessor;
_userDataService = userDataService;
}
public async Task<object?> ExecuteAsync(FYGetPlayersSets request)
public Task<object?> ExecuteAsync(FYGetPlayersSets request)
{
var context = _httpContextAccessor.HttpContext;
if (context == null)
{
return null;
return Task.FromResult<object?>(null);
}
var userId = context.User.FindAuthUserId();
// Return the player's persisted loadout (written by CompleteInventoryUpdate /
// RequestUpdateStationInventory) instead of a blank set, so saved gear survives
// a menu reopen / relog. Same response shape as before, just populated.
var userData = await _userDataService.FindAsync(userId, userId, new List<string> { "LOADOUT" });
var loadout = JsonSerializer.Deserialize<LoadoutData>(userData["LOADOUT"].Value) ?? new LoadoutData();
return new
return Task.FromResult<object?>(new
{
success = true,
entries = new[]
entries = new []
{
new
{
setData = new
{
id = "",
userId,
userId = context.User.FindAuthUserId(),
kit = "",
shield = loadout.Shield ?? "",
helmet = loadout.Helmet ?? "",
weaponOne = loadout.WeaponOne ?? "",
weaponTwo = loadout.WeaponTwo ?? "",
bag = loadout.Bag ?? "",
bagItemsAsJsonStr = loadout.BagItemsAsJsonStr ?? "",
safeItemsAsJsonStr = loadout.SafeItemsAsJsonStr ?? ""
shield = "",
helmet = "",
weaponOne = "",
weaponTwo = "",
bag = "",
bagItemsAsJsonStr = "",
safeItemsAsJsonStr = ""
},
items = Array.Empty<object>()
}
}
};
});
}
}
@@ -8,8 +8,13 @@ public class GetPlayerStatisticsRequestsClientRequest
public string[] Statistics { get; set; }
}
public class GetPlayerStatisticsRequestsClientResponse
{
// TODO: Unknown structure
}
[CloudScriptFunction("GetPlayerStatisticsRequestsClient")]
public class GetPlayerStatisticsRequestsClientFunction : ICloudScriptFunction<GetPlayerStatisticsRequestsClientRequest, object>
public class GetPlayerStatisticsRequestsClientFunction : ICloudScriptFunction<GetPlayerStatisticsRequestsClientRequest, GetPlayerStatisticsRequestsClientResponse>
{
private readonly IHttpContextAccessor _httpContextAccessor;
@@ -18,7 +23,7 @@ public class GetPlayerStatisticsRequestsClientFunction : ICloudScriptFunction<Ge
_httpContextAccessor = httpContextAccessor;
}
public Task<object> ExecuteAsync(GetPlayerStatisticsRequestsClientRequest request)
public async Task<GetPlayerStatisticsRequestsClientResponse> ExecuteAsync(GetPlayerStatisticsRequestsClientRequest request)
{
var context = _httpContextAccessor.HttpContext;
if (context == null)
@@ -26,15 +31,8 @@ public class GetPlayerStatisticsRequestsClientFunction : ICloudScriptFunction<Ge
throw new CloudScriptException("CloudScript was not called within a http request");
}
// Return each requested statistic with a value so the career screen renders instead
// of staying blank. Raid stats (kills/deaths/evacs/damage/…) come from the
// client-hosted raid and aren't tracked server-side, so they report 0 for now.
var stats = new List<object>();
foreach (var name in request.Statistics ?? Array.Empty<string>())
return new GetPlayerStatisticsRequestsClientResponse
{
stats.Add(new { statisticName = name, value = 0 });
}
return Task.FromResult<object>(new { statistics = stats });
};
}
}
@@ -1,72 +1,16 @@
using System.Net;
using System.Net.Sockets;
using Microsoft.Extensions.Options;
using Prospect.Server.Api.Config;
using Prospect.Server.Api.Services.Auth.Extensions;
using Prospect.Server.Api.Services.CloudScript.Models;
using Prospect.Server.Api.Services.CloudScript.Models;
namespace Prospect.Server.Api.Services.CloudScript.Functions;
[CloudScriptFunction("GetSignalRConnection")]
public class GetSignalRConnection : ICloudScriptFunction<FYGetSignalRConnection, FYGetSignalRConnectionResult>
{
// LAN address of the homelab host. Clients on the local network get the SignalR URL on
// this IP (which their cert already validates via the IP/DNS SAN), while external clients
// keep the public domain from configuration. This matters because the real-time WebSocket
// (libwebsockets, incl. under Proton/Wine) can't reach the public domain from inside the
// LAN (hairpin NAT), which surfaced as "error code 5" right after the intro.
private const string LanHost = "192.168.1.136";
private readonly PlayFabSettings _settings;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly ILogger<GetSignalRConnection> _logger;
public GetSignalRConnection(IOptions<PlayFabSettings> settings, IHttpContextAccessor httpContextAccessor, ILogger<GetSignalRConnection> logger)
{
_settings = settings.Value;
_httpContextAccessor = httpContextAccessor;
_logger = logger;
}
public Task<FYGetSignalRConnectionResult> ExecuteAsync(FYGetSignalRConnection request)
{
// The game client connects to SignalR only over HTTPS.
var url = _settings.SignalRURL;
var remote = _httpContextAccessor.HttpContext?.Connection.RemoteIpAddress;
var lan = remote != null && IsLan(remote);
if (lan)
{
try
{
url = new UriBuilder(url) { Host = LanHost }.Uri.AbsoluteUri;
}
catch { /* keep the configured URL on any parsing issue */ }
}
// Tag the connection with the player id so the hub can map connection -> user and
// target server messages (deploy signal) instead of broadcasting to everyone.
var userId = _httpContextAccessor.HttpContext?.User.FindAuthUserId();
if (!string.IsNullOrEmpty(userId))
url += (url.Contains('?') ? "&" : "?") + "uid=" + Uri.EscapeDataString(userId);
_logger.LogInformation("GetSignalRConnection: remoteIp={Remote} lan={Lan} uid={Uid} -> {Url}", remote, lan, userId, url);
return Task.FromResult(new FYGetSignalRConnectionResult
{
Url = url,
AccessToken = _settings.SignalRAccessToken
Url = "https://2ea46.playfabapi.com/signalr/?hub=pubsub",
AccessToken = "TEST"
});
}
private static bool IsLan(IPAddress ip)
{
if (IPAddress.IsLoopback(ip)) return true;
if (ip.IsIPv4MappedToIPv6) ip = ip.MapToIPv4();
if (ip.AddressFamily != AddressFamily.InterNetwork) return false;
var b = ip.GetAddressBytes();
return b[0] == 10
|| (b[0] == 172 && b[1] >= 16 && b[1] <= 31)
|| (b[0] == 192 && b[1] == 168);
}
}
@@ -1,9 +1,7 @@
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.CloudScript.Functions;
using Prospect.Server.Api.Services.UserData;
public class PurchaseWeaponShopItemRequest
@@ -138,9 +136,9 @@ public class PurchaseWeaponShopItemFunction : ICloudScriptFunction<PurchaseWeapo
Error = "Invalid purchase amount",
};
}
var userData = await _userDataService.FindAsync(userId, userId, new List<string>{"Inventory", "Balance", "ContractsOneTimeCompleted"});
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 balance = JsonSerializer.Deserialize<Dictionary<string, int>>(userData["Balance"].Value);
var blueprintsData = _titleDataService.Find(new List<string>{"Blueprints"});
var blueprints = JsonSerializer.Deserialize<Dictionary<string, TitleDataBlueprintInfo>>(blueprintsData["Blueprints"]);
if (!blueprints.ContainsKey(request.BaseItemID)) {
@@ -154,23 +152,7 @@ public class PurchaseWeaponShopItemFunction : ICloudScriptFunction<PurchaseWeapo
var blueprintData = blueprints[request.BaseItemID];
// Contract unlock criteria: some items can only be purchased after completing a
// specific contract. The data uses the sentinel "None" (not an empty string) for
// items with no lock — treat that (and null/empty) as "no lock", otherwise EVERY
// item gets gated and nothing can be bought.
var contractLockPurchase = blueprintData.UnlockData?.ContractLockPurchase;
if (!string.IsNullOrEmpty(contractLockPurchase) && contractLockPurchase != "None")
{
var completed = JsonSerializer.Deserialize<FYGetCompletedContractsResult>(userData["ContractsOneTimeCompleted"].Value);
if (completed?.ContractsIDs == null || !completed.ContractsIDs.Contains(contractLockPurchase))
{
return new PurchaseWeaponShopItemResponse
{
UserID = userId,
Error = "Item is locked: complete the required contract first",
};
}
}
// TODO: Contract unlock criteria
// Validate price
var shopCraftingData = blueprintData.ItemShopsCraftingData[request.ShopItemBelongsTo];
@@ -183,15 +165,16 @@ public class PurchaseWeaponShopItemFunction : ICloudScriptFunction<PurchaseWeapo
if (ingredient.Currency == "SoftCurrency") {
var buyCost = request.PurchaseAmount * ingredient.Amount;
remaining -= buyCost;
balance.SoftCurrency -= buyCost;
changedCurrencies.Add(new FYCurrencyItem { CurrencyName = "SoftCurrency", Amount = balance.SoftCurrency });
balance["SC"] -= buyCost;
changedCurrencies.Add(new FYCurrencyItem { CurrencyName = "SoftCurrency", Amount = balance["SC"] });
} else if (ingredient.Currency == "Aurum") {
var buyCost = request.PurchaseAmount * ingredient.Amount;
remaining -= buyCost;
balance.HardCurrency -= buyCost;
changedCurrencies.Add(new FYCurrencyItem { CurrencyName = "Aurum", Amount = balance.HardCurrency });
balance["AU"] -= buyCost;
changedCurrencies.Add(new FYCurrencyItem { CurrencyName = "Aurum", Amount = balance["AU"] });
} else {
foreach (var item in inventory) {
for (var i = 0; i < inventory.Count; i++) {
var item = inventory[i];
if (item.BaseItemId != ingredient.Currency) {
continue;
}
@@ -226,7 +209,6 @@ public class PurchaseWeaponShopItemFunction : ICloudScriptFunction<PurchaseWeapo
}
}
// TODO: Inventory limit check
// TODO: Refactor into a helper function
var remainingAmount = request.PurchaseAmount * blueprintData.AmountPerPurchase;
@@ -1,6 +1,5 @@
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.UserData;
using Prospect.Server.Api.Utils;
@@ -66,20 +65,20 @@ public class RepairItem : ICloudScriptFunction<FYRepairItemRequest, FYRepairItem
var blueprints = JsonSerializer.Deserialize<Dictionary<string, TitleDataBlueprintInfo>>(titleData["Blueprints"]);
var blueprintData = blueprints[item.BaseItemId];
var balance = JsonSerializer.Deserialize<PlayerBalance>(userData["Balance"].Value);
var balance = JsonSerializer.Deserialize<Dictionary<string, int>>(userData["Balance"].Value);
var repairCost = blueprintData.RepairCostBase + (int)Math.Ceiling((1 - (float)item.Durability / blueprintData.DurabilityMax) * blueprintData.RepairCostMaxDurability);
var repairCost = MapValue.Map(item.Durability, blueprintData.DurabilityMax, 0, blueprintData.RepairCostBase, blueprintData.RepairCostMaxDurability);
if (item.Durability == 0) {
repairCost += blueprintData.RepairCostModifierBroken;
}
if (balance.SoftCurrency < repairCost) {
if (balance["SC"] < repairCost) {
return new FYRepairItemResult
{
UserID = userId,
Error = "Insufficient balance",
};
}
balance.SoftCurrency -= repairCost;
balance["SC"] -= repairCost;
item.Durability = blueprintData.DurabilityMax;
await _userDataService.UpdateAsync(
@@ -96,7 +95,7 @@ public class RepairItem : ICloudScriptFunction<FYRepairItemRequest, FYRepairItem
Error = "",
ChangedItems = [item],
ChangedCurrencies = [
new FYCurrencyItem { CurrencyName = "SoftCurrency", Amount = balance.SoftCurrency },
new FYCurrencyItem { CurrencyName = "SoftCurrency", Amount = balance["SC"] },
],
};
}
@@ -1,7 +1,4 @@
using System.Collections;
using System.Diagnostics.Contracts;
using System.Text.Json;
using Prospect.Server.Api.Models.Data;
using System.Text.Json;
using Prospect.Server.Api.Services.Auth.Extensions;
using Prospect.Server.Api.Services.CloudScript.Models;
using Prospect.Server.Api.Services.CloudScript.Models.Data;
@@ -14,16 +11,11 @@ public class RequestActiveObjectivesAndBoardsData : ICloudScriptFunction<FYQuery
{
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly UserDataService _userDataService;
private readonly TitleDataService _titleDataService;
private readonly string[] factions = ["Korolev", "Osiris", "ICA"]; // TODO: Order is important
private readonly string[] difficulties = ["Easy", "Medium", "Hard"];
private readonly Random rnd = new Random();
public RequestActiveObjectivesAndBoardsData(IHttpContextAccessor httpContextAccessor, UserDataService userDataService, TitleDataService titleDataService)
public RequestActiveObjectivesAndBoardsData(IHttpContextAccessor httpContextAccessor, UserDataService userDataService)
{
_httpContextAccessor = httpContextAccessor;
_userDataService = userDataService;
_titleDataService = titleDataService;
}
public async Task<FYGetPlayerContractsResult> ExecuteAsync(FYQueryFactionProgressionRequest request)
@@ -34,65 +26,10 @@ public class RequestActiveObjectivesAndBoardsData : ICloudScriptFunction<FYQuery
return new FYGetPlayerContractsResult{};
}
var userId = context.User.FindAuthUserId();
var userData = await _userDataService.FindAsync(userId, userId, new List<string>{ "ContractsActive", "JobBoardsData", "FactionProgressionKorolev", "FactionProgressionICA", "FactionProgressionOsiris" });
var userData = await _userDataService.FindAsync(userId, userId, new List<string>{"ContractsActive"});
var contractsActive = JsonSerializer.Deserialize<FYGetActiveContractsResult>(userData["ContractsActive"].Value);
var jobBoardsData = JsonSerializer.Deserialize<JobBoardsData>(userData["JobBoardsData"].Value);
var now = (int)DateTimeOffset.UtcNow.ToUnixTimeSeconds();
var lastBoardRefreshTimeUtc = jobBoardsData.LastBoardRefreshTimeUtc;
var boards = jobBoardsData.Boards;
// The boards are updated once in 3 hours
if (now >= lastBoardRefreshTimeUtc.Seconds + 60 * 60 * 3) {
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 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 parts = contract.ContractID.Split('-');
if (parts[0] != "NEW") {
continue;
}
var difficulty = parts[1];
var faction = parts[2] == "KOR" ? "Korolev" : parts[2];
currentQuests[faction][difficulty] = true;
}
for (var i = 0; i < factions.Length; i++) {
var faction = factions[i];
var factionProgression = JsonSerializer.Deserialize<int>(userData[$"FactionProgression{faction}"].Value);
var levels = levelsData[faction];
var level = levels.Length - Array.FindIndex(levels, (int xp) => {
return factionProgression >= xp;
});
for (var j = 0; j < difficulties.Length; j++) {
var difficulty = difficulties[j];
if (currentQuests[faction][difficulty]) {
continue;
}
var factionJobs = jobs[faction];
var jobsAvailable = Array.FindAll(factionJobs[difficulty], (string jobId) => {
return level >= contracts[jobId].UnlockData.Level;
});
string jobId = jobsAvailable.Length > 0 ? jobsAvailable[rnd.Next(jobsAvailable.Length)] : factionJobs[difficulty][0];
boards[i].Contracts[j] = new FYFactionContractData { ContractId = jobId };
}
}
lastBoardRefreshTimeUtc.Seconds = now;
await _userDataService.UpdateAsync(
userId, userId,
new Dictionary<string, string> { ["JobBoardsData"] = JsonSerializer.Serialize(jobBoardsData) }
);
}
// TODO: Get player's contract boards and refresh
return new FYGetPlayerContractsResult
{
@@ -101,10 +38,72 @@ public class RequestActiveObjectivesAndBoardsData : ICloudScriptFunction<FYQuery
ActiveContracts = contractsActive.Contracts,
FactionsContracts = new FYFactionsContractsData
{
Boards = boards,
LastBoardRefreshTimeUtc = lastBoardRefreshTimeUtc
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()
}
},
RefreshHours24UtcFromBackend = 12 // Doesn't seem to work
RefreshHours24UtcFromBackend = 12
};
}
}
@@ -1,6 +1,5 @@
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;
@@ -10,15 +9,6 @@ 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")]
@@ -44,15 +34,11 @@ 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, TitleDataService titleDataService, ILogger<RequestClaimFortunaPassRewardsFunction> logger)
public RequestClaimFortunaPassRewardsFunction(IHttpContextAccessor httpContextAccessor, UserDataService userDataService)
{
_httpContextAccessor = httpContextAccessor;
_userDataService = userDataService;
_titleDataService = titleDataService;
_logger = logger;
}
public async Task<RequestClaimFortunaPassRewardsResponse> ExecuteAsync(RequestClaimFortunaPassRewardsRequest request)
@@ -63,103 +49,28 @@ public class RequestClaimFortunaPassRewardsFunction : ICloudScriptFunction<Reque
throw new CloudScriptException("CloudScript was not called within a http request");
}
var userId = context.User.FindAuthUserId();
// 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 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);
_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);
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
}
);
return new RequestClaimFortunaPassRewardsResponse
{
UserId = userId,
ErrorType = 3, // EYFortunaPassToastReponseType::Ok
Error = "",
GrantedItems = grantedItems.ToArray(),
ChangedCurrencies = changedCurrencies.ToArray(),
GrantedItems = [],
ChangedCurrencies = [],
};
}
}
@@ -1,7 +1,5 @@
using System.Text.Json;
using Prospect.Server.Api.Services.Auth.Extensions;
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;
@@ -9,43 +7,42 @@ 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, UserDataService userDataService)
public RequestFactionProgression(IHttpContextAccessor httpContextAccessor)
{
_httpContextAccessor = httpContextAccessor;
_userDataService = userDataService;
}
public async Task<object?> ExecuteAsync(FYQueryFactionProgressionRequest request)
public Task<object?> ExecuteAsync(FYQueryFactionProgressionRequest request)
{
var context = _httpContextAccessor.HttpContext;
if (context == null)
{
return null;
return Task.FromResult<object?>(null);
}
var userId = context.User.FindAuthUserId();
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
return Task.FromResult<object?>(new
{
factions = new[]
factions = new []
{
new { factionId = "ICA", currentProgression = Progression("ICA") },
new { factionId = "Korolev", currentProgression = Progression("Korolev") },
new { factionId = "Osiris", currentProgression = Progression("Osiris") },
new
{
factionId = "ICA",
currentProgression = 0
},
new
{
factionId = "Korolev",
currentProgression = 0
},
new
{
factionId = "Osiris",
currentProgression = 0
},
},
userId,
userId = context.User.FindAuthUserId(),
error = ""
};
});
}
}
@@ -1,6 +1,5 @@
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.UserData;
@@ -55,7 +54,7 @@ public class RequestMissionCompleted : ICloudScriptFunction<FYRequestMissionComp
var rewards = JsonSerializer.Deserialize<Dictionary<string, TitleDataMissionRewardInfo>>(titleData["OnboardingRewardMissions"]);
var blueprints = JsonSerializer.Deserialize<Dictionary<string, TitleDataBlueprintInfo>>(titleData["Blueprints"]);
var missions = JsonSerializer.Deserialize<Dictionary<string, TitleDataMissionInfo>>(titleData["OnboardingMissions"]);
var balance = JsonSerializer.Deserialize<PlayerBalance>(userData["Balance"].Value);
var balance = JsonSerializer.Deserialize<Dictionary<string, int>>(userData["Balance"].Value);
var currentMission = missions[onboardingMission.CurrentMissionID];
var missionRewardId = currentMission.OnboardingRewards.RowName;
@@ -69,16 +68,15 @@ public class RequestMissionCompleted : ICloudScriptFunction<FYRequestMissionComp
var missionRewards = rewards[missionRewardId].RewardEntries;
foreach (var reward in missionRewards) {
if (reward.RewardRowHandle.RowName == "SoftCurrency") {
balance.SoftCurrency += reward.RewardAmount;
changedCurrencies.Add(new FYCurrencyItem { CurrencyName = "SoftCurrency", Amount = balance.SoftCurrency });
balance["SC"] += reward.RewardAmount;
changedCurrencies.Add(new FYCurrencyItem { CurrencyName = "SoftCurrency", Amount = balance["SC"] });
} else if (reward.RewardRowHandle.RowName == "Aurum") {
balance.HardCurrency += reward.RewardAmount;
changedCurrencies.Add(new FYCurrencyItem { CurrencyName = "Aurum", Amount = balance.HardCurrency });
} else if (reward.RewardRowHandle.RowName == "InsuranceCurrency") {
balance.InsuranceCurrency += reward.RewardAmount;
changedCurrencies.Add(new FYCurrencyItem { CurrencyName = "InsuranceCurrency", Amount = balance.InsuranceCurrency });
balance["AU"] += reward.RewardAmount;
changedCurrencies.Add(new FYCurrencyItem { CurrencyName = "Aurum", Amount = balance["AU"] });
} else if (reward.RewardRowHandle.RowName == "InsuranceToken") {
balance["IN"] += reward.RewardAmount;
changedCurrencies.Add(new FYCurrencyItem { CurrencyName = "InsuranceToken", Amount = balance["IN"] });
} else {
// TODO: Inventory limit check
var blueprintData = blueprints[reward.RewardRowHandle.RowName];
var remainingAmount = reward.RewardAmount * blueprintData.AmountPerPurchase;
while (remainingAmount > 0) {
@@ -1,119 +1,100 @@
using System.Text.Json;
using Prospect.Server.Api.Models.Data;
using Prospect.Server.Api.Services.Auth.Extensions;
using Prospect.Server.Api.Services.Auth.Extensions;
using Prospect.Server.Api.Services.CloudScript.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>
{
// Board slots are ordered Easy / Medium / Hard (same convention as ClaimActiveContract).
private static readonly string[] Difficulties = ["Easy", "Medium", "Hard"];
private const int RefreshHours = 12;
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)
public RequestPlayerContracts(IHttpContextAccessor httpContextAccessor)
{
_httpContextAccessor = httpContextAccessor;
_userDataService = userDataService;
_titleDataService = titleDataService;
}
public async Task<FYGetPlayerContractsResult> ExecuteAsync(FYGetPlayerContractsRequest request)
public 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();
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))
return Task.FromResult(new FYGetPlayerContractsResult
{
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,
UserId = context.User.FindAuthUserId(),
Error = null,
ActiveContracts = contractsActive.Contracts,
ActiveContracts = new List<FYActiveContractPlayerData>(),
FactionsContracts = new FYFactionsContractsData
{
Boards = jobBoardsData.Boards,
LastBoardRefreshTimeUtc = jobBoardsData.LastBoardRefreshTimeUtc ?? new FYTimestamp { Seconds = now },
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()
}
},
RefreshHours24UtcFromBackend = RefreshHours,
};
RefreshHours24UtcFromBackend = 12
});
}
}
@@ -35,11 +35,6 @@ 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;
@@ -48,33 +43,18 @@ public class RequestStoreRotationDataFunction : ICloudScriptFunction<RequestStor
throw new CloudScriptException("CloudScript was not called within a http request");
}
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}";
// TODO: Get and save store rotation data
return new RequestStoreRotationDataResponse
{
Error = "",
DailyStoreData = new StoreRotationData {
StoreID = dailyStoreId,
ExpirationData = dailyExpiration
StoreID = "DailyShop",
ExpirationData = DateTime.UtcNow.AddDays(1)
},
WeeklyStoreData = new StoreRotationData {
StoreID = weeklyStoreId,
ExpirationData = weeklyExpiration
StoreID = "WeeklyShop",
ExpirationData = DateTime.UtcNow.AddDays(1)
}
};
}
@@ -1,47 +0,0 @@
using System.Text.Json.Serialization;
using Prospect.Server.Api.Services.Auth.Extensions;
using Prospect.Server.Api.Services.CloudScript;
public class RequestUpdateAvailableFreeLoadoutRequest
{
}
public class RequestUpdateAvailableFreeLoadoutResponse
{
[JsonPropertyName("userId")]
public string UserId { get; set; }
[JsonPropertyName("error")]
public string Error { get; set; }
[JsonPropertyName("newRandomSeed")]
public int NewRandomSeed { get; set; }
}
[CloudScriptFunction("RequestUpdateAvailableFreeLoadout")]
public class RequestUpdateAvailableFreeLoadoutFunction : ICloudScriptFunction<RequestUpdateAvailableFreeLoadoutRequest, RequestUpdateAvailableFreeLoadoutResponse>
{
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly ILogger<RequestUpdateAvailableFreeLoadoutFunction> _logger;
public RequestUpdateAvailableFreeLoadoutFunction(ILogger<RequestUpdateAvailableFreeLoadoutFunction> logger, IHttpContextAccessor httpContextAccessor)
{
_logger = logger;
_httpContextAccessor = httpContextAccessor;
}
public async Task<RequestUpdateAvailableFreeLoadoutResponse> ExecuteAsync(RequestUpdateAvailableFreeLoadoutRequest 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 new RequestUpdateAvailableFreeLoadoutResponse
{
UserId = userId,
Error = "",
NewRandomSeed = 0,
};
}
}
@@ -9,9 +9,9 @@ public class RequestUpdateSeasonWipeDataRequest
public class RequestUpdateSeasonWipeDataResponse
{
[JsonPropertyName("userId")]
public string UserId { get; set; }
public string UserId;
[JsonPropertyName("error")]
public string Error { get; set; }
public string Error;
}
[CloudScriptFunction("RequestUpdateSeasonWipeData")]
@@ -76,13 +76,6 @@ public class RequestUpdateStationInventoryFunction : ICloudScriptFunction<Reques
var inventory = JsonSerializer.Deserialize<List<FYCustomItemInfo>>(userData["Inventory"].Value);
// 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) {
if (!request.ItemsToRemove.Contains(item.ItemId)) {
@@ -90,6 +83,7 @@ public class RequestUpdateStationInventoryFunction : ICloudScriptFunction<Reques
}
}
// TODO: Check deleted items to see if other stacks/mods were updated correctly
foreach (var item in request.ItemsToUpdateAmount) {
var inventoryItem = newInventory.Find(i => i.ItemId == item.ItemId);
if (inventoryItem == null) {
@@ -99,7 +93,6 @@ public class RequestUpdateStationInventoryFunction : ICloudScriptFunction<Reques
inventoryItem.ModData = item.ModData;
inventoryItem.PrimaryVanityId = item.PrimaryVanityId;
inventoryItem.SecondaryVanityId = item.SecondaryVanityId;
inventoryItem.Durability = item.Durability;
}
// TODO: Check updated items and validate new items
@@ -6,7 +6,7 @@ public class RegionObject
[JsonPropertyName("region")]
public string Region { get; set; }
[JsonPropertyName("ping")]
public long Ping { get; set; } // client sends 2147483648 (int.MaxValue+1) for "no ping" -> must be long or deserialization 500s
public int Ping { get; set; }
[JsonPropertyName("instanceType")]
public int InstanceType { get; set; }
}
@@ -1,6 +1,5 @@
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;
@@ -34,7 +33,7 @@ public class SellItemsClientResponse
[JsonPropertyName("scrappedItemIds")]
public HashSet<string> ScrappedItemIDs { get; set; }
[JsonPropertyName("changedItems")]
public List<FYCustomItemInfo> ChangedItems { get; set; }
public FYCustomItemInfo[] ChangedItems { get; set; }
[JsonPropertyName("changedCurrencies")]
public FYCurrencyItem[] ChangedCurrencies { get; set; }
[JsonPropertyName("playerFactionProgressionData")]
@@ -72,67 +71,40 @@ public class SellItemsClientFunction : ICloudScriptFunction<SellItemsClientReque
throw new CloudScriptException("CloudScript was not called within a http request");
}
var userId = context.User.FindAuthUserId();
var progressionFactionKey = $"FactionProgression{request.FactionID}";
var progressionFactionKey = "FactionProgression" + request.FactionID;
var userData = await _userDataService.FindAsync(userId, userId, new List<string>{"Inventory", "Balance", progressionFactionKey});
var inventory = JsonSerializer.Deserialize<List<FYCustomItemInfo>>(userData["Inventory"].Value);
var balance = JsonSerializer.Deserialize<PlayerBalance>(userData["Balance"].Value);
var balanceData = JsonSerializer.Deserialize<Dictionary<string, int>>(userData["Balance"].Value);
var factionProgression = JsonSerializer.Deserialize<int>(userData[progressionFactionKey].Value);
var blueprintsData = _titleDataService.Find(new List<string>{"Blueprints"});
var blueprints = JsonSerializer.Deserialize<Dictionary<string, TitleDataBlueprintInfo>>(blueprintsData["Blueprints"]);
// TODO: Optimize
// TODO: Check deleted items to see if other stacks/mods were updated correctly
// Process inventory update before selling since a player may split item stack.
// This will result in creating a new item and updating amount of existing item.
// NOTE: Stacking items back doesn't seem to work, at least in single-player station. Bug?
// var newInventory = new List<FYCustomItemInfo>(inventory.Count);
// foreach (var item in inventory) {
// if (!request.InventoryUpdateData.ItemsToRemove.Contains(item.ItemId)) {
// newInventory.Add(item);
// }
// }
var changedItems = new List<FYCustomItemInfo>();
foreach (var item in request.InventoryUpdateData.ItemsToUpdateAmount) {
var inventoryItem = inventory.Find(i => i.ItemId == item.ItemId);
if (inventoryItem == null) {
continue;
}
inventoryItem.Amount = item.Amount;
// NOTE: Vanity and mod data cannot be managed in sell menu
changedItems.Add(item);
}
foreach (var item in request.InventoryUpdateData.ItemsToAdd) {
inventory.Add(item);
}
// Then process selling. The game client provides item IDs for newly added items.
var playerBalance = balanceData["SC"];
HashSet<string> scrappedItemIds = [];
foreach (var itemId in request.IDs) {
var inventoryItem = inventory.Find(i => i.ItemId == itemId);
if (inventoryItem == null) {
var inventoryItemIdx = inventory.FindIndex(i => i.ItemId == itemId);
if (inventoryItemIdx == -1) {
continue;
}
var inventoryItem = inventory[inventoryItemIdx];
var blueprintData = blueprints[inventoryItem.BaseItemId];
// NOTE: Ammo and consumables have reverse price mapping.
if (blueprintData.Kind == "Ammo" || blueprintData.Kind == "Ability") {
factionProgression += (int)((float)blueprintData.OverrideScrappingReputation / blueprintData.MaxAmountPerStack * inventoryItem.Amount);
balance.SoftCurrency += (int)((float)blueprintData.OverrideScrappingReturns / blueprintData.MaxAmountPerStack * inventoryItem.Amount);
} else if (blueprintData.Kind == "Armor") {
factionProgression += (int)Math.Max(
(float)inventoryItem.Durability / blueprintData.DurabilityMax * blueprintData.OverrideScrappingReputation,
blueprintData.OverrideScrappingReputation * blueprintData.DurabilityBrokenScrappingReturnModifier
);
balance.SoftCurrency += (int)Math.Max(
(float)inventoryItem.Durability / blueprintData.DurabilityMax * blueprintData.OverrideScrappingReturns,
blueprintData.OverrideScrappingReturns * blueprintData.DurabilityBrokenScrappingReturnModifier
);
// TODO: Probably better to decide based on item kind instead
if (inventoryItem.Durability == -1) {
factionProgression += blueprintData.OverrideScrappingReputation * inventoryItem.Amount;
playerBalance += blueprintData.OverrideScrappingReturns * inventoryItem.Amount;
} else {
factionProgression += blueprintData.OverrideScrappingReputation * inventoryItem.Amount;
balance.SoftCurrency += blueprintData.OverrideScrappingReturns * inventoryItem.Amount;
// Items that have durability are not stackable so it's always 1 item
factionProgression += MapValue.Map(
inventoryItem.Durability,
blueprintData.DurabilityMax, 0,
blueprintData.OverrideScrappingReputation, (int)(blueprintData.OverrideScrappingReputation * blueprintData.DurabilityBrokenScrappingReturnModifier)
);
playerBalance += MapValue.Map(
inventoryItem.Durability,
blueprintData.DurabilityMax, 0,
blueprintData.OverrideScrappingReturns, (int)(blueprintData.OverrideScrappingReturns * blueprintData.DurabilityBrokenScrappingReturnModifier)
);
}
scrappedItemIds.Add(itemId);
@@ -146,11 +118,13 @@ public class SellItemsClientFunction : ICloudScriptFunction<SellItemsClientReque
}
}
balanceData["SC"] = playerBalance;
await _userDataService.UpdateAsync(
userId, userId,
new Dictionary<string, string>{
["Inventory"] = JsonSerializer.Serialize(newInventory),
["Balance"] = JsonSerializer.Serialize(balance),
["Balance"] = JsonSerializer.Serialize(balanceData),
[progressionFactionKey] = JsonSerializer.Serialize(factionProgression),
}
);
@@ -161,14 +135,14 @@ public class SellItemsClientFunction : ICloudScriptFunction<SellItemsClientReque
Error = "",
ChangedCurrencies = [new FYCurrencyItem {
CurrencyName = "SoftCurrency",
Amount = balance.SoftCurrency,
Amount = playerBalance,
}],
PlayerFactionProgressionData = new FYPlayerFactionProgressData {
FactionID = request.FactionID,
CurrentProgression = factionProgression,
},
ScrappedItemIDs = scrappedItemIds,
ChangedItems = changedItems,
ChangedItems = [], // TODO: Changed items from InventoryUpdateData
};
}
}
@@ -47,18 +47,10 @@ public class SetCharacterVanityArchetype : ICloudScriptFunction<FYSetActiveChara
var genderSuffix = request.ArcheTypeId.EndsWith("G02") ? "M" : "F";
returnVanity.ArchetypeID = request.ArcheTypeId;
returnVanity.HeadItem.ID = request.ArcheTypeId + "_Head01";
#if SEASON_2_RELEASE || SEASON_2_DEBUG
returnVanity.BootsItem.ID = $"StarterOutfit01_Boots_{genderSuffix}";
returnVanity.ChestItem.ID = $"StarterOutfit01_Chest_{genderSuffix}";
returnVanity.GloveItem.ID = $"StarterOutfit01_Gloves_{genderSuffix}";
#elif SEASON_3_RELEASE || SEASON_3_DEBUG
returnVanity.BootsItem.ID = "StarterOutfit01_Boots";
returnVanity.ChestItem.ID = "StarterOutfit01_Chest";
returnVanity.GloveItem.ID = "StarterOutfit01_Gloves";
#else
#error Unsupported build type
#endif
returnVanity.BaseSuitItem.ID = $"StarterOutfit01{genderSuffix}_BaseSuit";
returnVanity.BootsItem.ID = "StarterOutfit01_Boots_" + genderSuffix;
returnVanity.ChestItem.ID = "StarterOutfit01_Chest_" + genderSuffix;
returnVanity.GloveItem.ID = "StarterOutfit01_Gloves_" + genderSuffix;
returnVanity.BaseSuitItem.ID = "StarterOutfit01" + genderSuffix + "_BaseSuit";
returnVanity.BodyType = request.ArcheTypeId.EndsWith("G02") ? 1 : 2;
await _userDataService.UpdateAsync(
userId, userId,
@@ -1,7 +1,8 @@
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.Models;
using Prospect.Server.Api.Services.CloudScript.Models.Data;
using Prospect.Server.Api.Services.UserData;
using Prospect.Server.Api.Utils;
@@ -60,7 +61,7 @@ public class SkipItemCraftingClient : ICloudScriptFunction<FYSkipItemCraftingCli
new List<string>{"CraftingTimer__2022_05_12", "Balance", "Inventory"}
);
var balance = JsonSerializer.Deserialize<PlayerBalance>(userData["Balance"].Value);
var balance = JsonSerializer.Deserialize<Dictionary<string, int>>(userData["Balance"].Value);
var inventory = JsonSerializer.Deserialize<List<FYCustomItemInfo>>(userData["Inventory"].Value);
var craftingTimer = JsonSerializer.Deserialize<FYItemCurrentlyBeingCrafted>(userData["CraftingTimer__2022_05_12"].Value);
@@ -77,24 +78,24 @@ public class SkipItemCraftingClient : ICloudScriptFunction<FYSkipItemCraftingCli
FYCurrencyItem[] changedCurrency;
if (request.UseOptionalCosts) {
var remaining = MapValue.Map(now, craftStartTime, craftEndTime, shopCraftingData.SkipOptionalCraftingMaxCost, 1);
if (balance.SoftCurrency < remaining) {
if (balance["SC"] < remaining) {
return new FYSkipItemCraftingClientResult {
UserID = userId,
Error = "Insufficient balance",
};
}
balance.SoftCurrency -= remaining;
changedCurrency = [new FYCurrencyItem { CurrencyName = "SoftCurrency", Amount = balance.SoftCurrency }];
balance["SC"] -= remaining;
changedCurrency = [new FYCurrencyItem { CurrencyName = "SoftCurrency", Amount = balance["SC"] }];
} else {
var remaining = MapValue.Map(now, craftStartTime, craftEndTime, shopCraftingData.SkipCraftingMaxCost, 1);
if (balance.HardCurrency < remaining) {
if (balance["AU"] < remaining) {
return new FYSkipItemCraftingClientResult {
UserID = userId,
Error = "Insufficient balance",
};
}
balance.HardCurrency -= remaining;
changedCurrency = [new FYCurrencyItem { CurrencyName = "Aurum", Amount = balance.HardCurrency }];
balance["AU"] -= remaining;
changedCurrency = [new FYCurrencyItem { CurrencyName = "Aurum", Amount = balance["AU"] }];
}
List<FYCustomItemInfo> itemsGrantedOrUpdated = [];
@@ -1,6 +1,5 @@
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.Models.Data;
using Prospect.Server.Api.Services.UserData;
@@ -70,7 +69,7 @@ public class SkipPlayerQuarterUpgradeClient : ICloudScriptFunction<FYSkipPlayerQ
}
var titleData = _titleDataService.Find(new List<string>{"PlayerQuarters"});
var balance = JsonSerializer.Deserialize<PlayerBalance>(userData["Balance"].Value);
var balance = JsonSerializer.Deserialize<Dictionary<string, int>>(userData["Balance"].Value);
var playerQuartersData = JsonSerializer.Deserialize<TitleDataPlayerQuartersInfo[]>(titleData["PlayerQuarters"]);
var nextQuartersLevel = playerQuartersData[userPlayerQuarters.Level - 1]; // Quarters level starts from 1
@@ -80,24 +79,24 @@ public class SkipPlayerQuarterUpgradeClient : ICloudScriptFunction<FYSkipPlayerQ
FYCurrencyItem[] changedCurrency;
if (request.UseOptionalCosts) {
var remaining = MapValue.Map(now, craftStartTime, craftEndTime, nextQuartersLevel.OptionalRushCosts, 1);
if (balance.SoftCurrency < remaining) {
if (balance["SC"] < remaining) {
return new FYSkipPlayerQuarterUpgradeClientResult {
UserID = userId,
Error = "Insufficient balance",
};
}
balance.SoftCurrency -= remaining;
changedCurrency = [new FYCurrencyItem { CurrencyName = "SoftCurrency", Amount = balance.SoftCurrency }];
balance["SC"] -= remaining;
changedCurrency = [new FYCurrencyItem { CurrencyName = "SoftCurrency", Amount = balance["SC"] }];
} else {
var remaining = MapValue.Map(now, craftStartTime, craftEndTime, nextQuartersLevel.InitialRushCosts, 1);
if (balance.HardCurrency < remaining) {
if (balance["AU"] < remaining) {
return new FYSkipPlayerQuarterUpgradeClientResult {
UserID = userId,
Error = "Insufficient balance",
};
}
balance.HardCurrency -= remaining;
changedCurrency = [new FYCurrencyItem { CurrencyName = "Aurum", Amount = balance.HardCurrency }];
balance["AU"] -= remaining;
changedCurrency = [new FYCurrencyItem { CurrencyName = "Aurum", Amount = balance["AU"] }];
}
var upgradeStartedTime = new FYTimestamp {
@@ -1,6 +1,5 @@
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.UserData;
using Prospect.Server.Api.Utils;
@@ -57,7 +56,7 @@ public class SkipTechTreeNodeUpgradeClient : ICloudScriptFunction<FYSkipTechTree
new List<string>{"TechTreeNodeData", "Balance", "CharacterTechTreeBonuses"}
);
var balance = JsonSerializer.Deserialize<PlayerBalance>(userData["Balance"].Value);
var balance = JsonSerializer.Deserialize<Dictionary<string, int>>(userData["Balance"].Value);
var userTechTree = JsonSerializer.Deserialize<UserTechTreeNodeData>(userData["TechTreeNodeData"].Value);
var currentNode = userTechTree.NodeInProgress;
@@ -82,24 +81,24 @@ public class SkipTechTreeNodeUpgradeClient : ICloudScriptFunction<FYSkipTechTree
FYCurrencyItem[] changedCurrency;
if (request.UseOptionalCosts) {
var remaining = MapValue.Map(now, craftStartTime, craftEndTime, nodeLevelInfo.OptionalRushCosts, 1);
if (balance.SoftCurrency < remaining) {
if (balance["SC"] < remaining) {
return new FYSkipTechTreeNodeUpgradeClientResult {
UserID = userId,
Error = "Insufficient balance",
};
}
balance.SoftCurrency -= remaining;
changedCurrency = [new FYCurrencyItem { CurrencyName = "SoftCurrency", Amount = balance.SoftCurrency }];
balance["SC"] -= remaining;
changedCurrency = [new FYCurrencyItem { CurrencyName = "SoftCurrency", Amount = balance["SC"] }];
} else {
var remaining = MapValue.Map(now, craftStartTime, craftEndTime, nodeLevelInfo.InitialRushCosts, 1);
if (balance.HardCurrency < remaining) {
if (balance["AU"] < remaining) {
return new FYSkipTechTreeNodeUpgradeClientResult {
UserID = userId,
Error = "Insufficient balance",
};
}
balance.HardCurrency -= remaining;
changedCurrency = [new FYCurrencyItem { CurrencyName = "Aurum", Amount = balance.HardCurrency }];
balance["AU"] -= remaining;
changedCurrency = [new FYCurrencyItem { CurrencyName = "Aurum", Amount = balance["AU"] }];
}
userTechTree.TotalUpgrades++;
@@ -1,9 +1,6 @@
using System.Text.Json.Serialization;
using Prospect.Server.Api.Services.CloudScript;
using Prospect.Server.Api.Services.Auth.Extensions;
using Prospect.Server.Api.Services.Database;
using Prospect.Server.Api.Services.Database.Models;
using Prospect.Server.Api.Services.Squad;
using Microsoft.AspNetCore.SignalR;
using Prospect.Server.Api.Hubs;
@@ -31,16 +28,12 @@ public class SquadMemberReadyForMatchFunction : ICloudScriptFunction<SquadMember
private readonly ILogger<SquadMemberReadyForMatchFunction> _logger;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly IHubContext<CycleHub> _hubContext;
private readonly SquadService _squadService;
private readonly DbUserService _userService;
public SquadMemberReadyForMatchFunction(ILogger<SquadMemberReadyForMatchFunction> logger, IHttpContextAccessor httpContextAccessor, IHubContext<CycleHub> hubContext, SquadService squadService, DbUserService userService)
public SquadMemberReadyForMatchFunction(ILogger<SquadMemberReadyForMatchFunction> logger, IHttpContextAccessor httpContextAccessor, IHubContext<CycleHub> hubContext)
{
_httpContextAccessor = httpContextAccessor;
_logger = logger;
_hubContext = hubContext;
_squadService = squadService;
_userService = userService;
}
public async Task<SquadMemberReadyForMatchResponse> ExecuteAsync(SquadMemberReadyForMatchRequest request)
@@ -51,25 +44,27 @@ public class SquadMemberReadyForMatchFunction : ICloudScriptFunction<SquadMember
throw new CloudScriptException("CloudScript was not called within a http request");
}
var userId = context.User.FindAuthUserId();
var squadId = string.IsNullOrEmpty(request.SquadID) ? userId : request.SquadID;
// Register the caller into the squad (party is formed client-side; each member's client
// calls this with the shared squadId) and record their readiness / selected map.
var displayName = (await _userService.FindByIdAsync(userId))?.DisplayName ?? "";
_squadService.JoinOrCreate(squadId, userId, displayName);
var ready = request.matchmakingSettings?.isReadyForMatch ?? false;
_squadService.SetReady(squadId, userId, ready, request.matchmakingSettings?.selectedMapName);
var squad = _squadService.Get(squadId)!;
var isSquadReady = _squadService.IsSquadReady(squadId);
_logger.LogInformation("SquadMemberReadyForMatch: {User} ready={Ready} in squad {Squad} ({Count} member(s), squadReady={SquadReady})",
userId, ready, squadId, squad.Members.Count, isSquadReady);
return new SquadMemberReadyForMatchResponse
{
Result = 0, // EYSquadActionResult::OK
Squad = _squadService.ToClientSquad(squad),
IsSquadReadyForMatch = isSquadReady,
Squad = new FYPlayFabSquad {
SquadID = "100", // TODO
// Members = [
// new FYPlayFabSquadMember {
// Profile = new FYPlayFabPlayerProfile {
// PlayerId = userId,
// },
// onlineState = 0, // EYUserState::IN_STATION
// matchmakingSettings = new FYUserMatchmakingSettings {
// isReadyForMatch = true,
// isSecretLeader = true,
// selectedMapName = "Map01",
// }
// }
// ],
},
IsSquadReadyForMatch = true,
};
}
}
@@ -1,8 +1,5 @@
using System.Text.Json.Serialization;
using Prospect.Server.Api.Services.Auth.Extensions;
using System.Text.Json.Serialization;
using Prospect.Server.Api.Services.CloudScript;
using Prospect.Server.Api.Services.Squad;
public class SquadMemberSelectedMapRequest
{
[JsonPropertyName("squadId")]
@@ -20,26 +17,15 @@ public class SquadMemberSelectedMapResponse
public class SquadMemberSelectedMapFunction : ICloudScriptFunction<SquadMemberSelectedMapRequest, SquadMemberSelectedMapResponse>
{
private readonly ILogger<SquadMemberSelectedMapFunction> _logger;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly SquadService _squadService;
public SquadMemberSelectedMapFunction(ILogger<SquadMemberSelectedMapFunction> logger, IHttpContextAccessor httpContextAccessor, SquadService squadService)
public SquadMemberSelectedMapFunction(ILogger<SquadMemberSelectedMapFunction> logger)
{
_logger = logger;
_httpContextAccessor = httpContextAccessor;
_squadService = squadService;
}
public Task<SquadMemberSelectedMapResponse> ExecuteAsync(SquadMemberSelectedMapRequest request)
public async Task<SquadMemberSelectedMapResponse> ExecuteAsync(SquadMemberSelectedMapRequest request)
{
var context = _httpContextAccessor.HttpContext;
if (context != null && !string.IsNullOrEmpty(request.SquadID))
{
var userId = context.User.FindAuthUserId();
_squadService.JoinOrCreate(request.SquadID, userId, "");
_squadService.SetMap(request.SquadID, userId, request.SelectedMapName ?? "");
_logger.LogInformation("SquadMemberSelectedMap: {User} -> {Map} (squad {Squad})", userId, request.SelectedMapName, request.SquadID);
}
return Task.FromResult(new SquadMemberSelectedMapResponse { });
return new SquadMemberSelectedMapResponse
{};
}
}
@@ -1,6 +1,5 @@
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.Models.Data;
using Prospect.Server.Api.Services.UserData;
@@ -71,9 +70,8 @@ public class StartItemCraftingClient : ICloudScriptFunction<FYStartItemCraftingC
}
// TODO: Contract unlock criteria
// TODO: Inventory limit check
var balance = JsonSerializer.Deserialize<PlayerBalance>(userData["Balance"].Value);
var balance = JsonSerializer.Deserialize<Dictionary<string, int>>(userData["Balance"].Value);
var inventory = JsonSerializer.Deserialize<List<FYCustomItemInfo>>(userData["Inventory"].Value);
var blueprints = JsonSerializer.Deserialize<Dictionary<string, TitleDataBlueprintInfo>>(titleData["Blueprints"]);
@@ -90,14 +88,15 @@ public class StartItemCraftingClient : ICloudScriptFunction<FYStartItemCraftingC
int remaining = ingredient.Amount;
if (ingredient.Currency == "SoftCurrency") {
remaining -= ingredient.Amount;
balance.SoftCurrency -= ingredient.Amount;
changedCurrencies.Add(new FYCurrencyItem { CurrencyName = "SoftCurrency", Amount = balance.SoftCurrency });
balance["SC"] -= ingredient.Amount;
changedCurrencies.Add(new FYCurrencyItem { CurrencyName = "SoftCurrency", Amount = balance["SC"] });
} else if (ingredient.Currency == "Aurum") {
remaining -= ingredient.Amount;
balance.HardCurrency -= ingredient.Amount;
changedCurrencies.Add(new FYCurrencyItem { CurrencyName = "Aurum", Amount = balance.HardCurrency });
balance["AU"] -= ingredient.Amount;
changedCurrencies.Add(new FYCurrencyItem { CurrencyName = "Aurum", Amount = balance["AU"] });
} else {
foreach (var item in inventory) {
for (var i = 0; i < inventory.Count; i++) {
var item = inventory[i];
if (item.BaseItemId != ingredient.Currency) {
continue;
}
@@ -1,6 +1,5 @@
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.Models.Data;
using Prospect.Server.Api.Services.UserData;
@@ -67,7 +66,7 @@ public class StartPlayerQuarterUpgradeClient : ICloudScriptFunction<FYStartPlaye
}
var titleData = _titleDataService.Find(new List<string>{"PlayerQuarters"});
var balance = JsonSerializer.Deserialize<PlayerBalance>(userData["Balance"].Value);
var balance = JsonSerializer.Deserialize<Dictionary<string, int>>(userData["Balance"].Value);
var inventory = JsonSerializer.Deserialize<List<FYCustomItemInfo>>(userData["Inventory"].Value);
var playerQuartersData = JsonSerializer.Deserialize<TitleDataPlayerQuartersInfo[]>(titleData["PlayerQuarters"]);
// Current level contains the information about next level
@@ -83,14 +82,15 @@ public class StartPlayerQuarterUpgradeClient : ICloudScriptFunction<FYStartPlaye
int remaining = ingredient.Amount;
if (ingredient.Currency == "SoftCurrency") {
remaining -= ingredient.Amount;
balance.SoftCurrency -= ingredient.Amount;
changedCurrencies.Add(new FYCurrencyItem { CurrencyName = "SoftCurrency", Amount = balance.SoftCurrency });
balance["SC"] -= ingredient.Amount;
changedCurrencies.Add(new FYCurrencyItem { CurrencyName = "SoftCurrency", Amount = balance["SC"] });
} else if (ingredient.Currency == "Aurum") {
remaining -= ingredient.Amount;
balance.HardCurrency -= ingredient.Amount;
changedCurrencies.Add(new FYCurrencyItem { CurrencyName = "Aurum", Amount = balance.HardCurrency });
balance["AU"] -= ingredient.Amount;
changedCurrencies.Add(new FYCurrencyItem { CurrencyName = "Aurum", Amount = balance["AU"] });
} else {
foreach (var item in inventory) {
for (var i = 0; i < inventory.Count; i++) {
var item = inventory[i];
if (item.BaseItemId != ingredient.Currency) {
continue;
}
@@ -135,7 +135,7 @@ public class StartPlayerQuarterUpgradeClient : ICloudScriptFunction<FYStartPlaye
userId, userId,
new Dictionary<string, string>{
["Balance"] = JsonSerializer.Serialize(balance),
["Inventory"] = JsonSerializer.Serialize(newInventory),
["Inventory"] = JsonSerializer.Serialize(inventory),
["PlayerQuartersLevel"] = JsonSerializer.Serialize(userPlayerQuarters),
}
);
@@ -1,6 +1,5 @@
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.Models.Data;
using Prospect.Server.Api.Services.UserData;
@@ -54,7 +53,7 @@ public class StartTechTreeNodeUpgradeClient : ICloudScriptFunction<FYStartTechTr
var userData = await _userDataService.FindAsync(
userId, userId,
new List<string>{"TechTreeNodeData", "Balance", "Inventory", "PlayerQuartersLevel"}
new List<string>{"TechTreeNodeData", "Balance", "Inventory"}
);
var userTechTree = JsonSerializer.Deserialize<UserTechTreeNodeData>(userData["TechTreeNodeData"].Value);
@@ -68,7 +67,7 @@ public class StartTechTreeNodeUpgradeClient : ICloudScriptFunction<FYStartTechTr
};
}
var balance = JsonSerializer.Deserialize<PlayerBalance>(userData["Balance"].Value);
var balance = JsonSerializer.Deserialize<Dictionary<string, int>>(userData["Balance"].Value);
var inventory = JsonSerializer.Deserialize<List<FYCustomItemInfo>>(userData["Inventory"].Value);
var titleData = _titleDataService.Find(new List<string>{"TechTreeNodes"});
@@ -79,15 +78,7 @@ public class StartTechTreeNodeUpgradeClient : ICloudScriptFunction<FYStartTechTr
var techTreeNodeData = techTreeNodes[request.NodeID];
var nextTreeNodeLevel = techTreeNodeData.PerkLevels[currentLevel]; // Tech tree node levels start from 0
// 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}",
};
}
// TODO: Dependency check
// Validate price
// TODO: Refactor
@@ -98,14 +89,15 @@ public class StartTechTreeNodeUpgradeClient : ICloudScriptFunction<FYStartTechTr
int remaining = ingredient.Amount;
if (ingredient.Currency == "SoftCurrency") {
remaining -= ingredient.Amount;
balance.SoftCurrency -= ingredient.Amount;
changedCurrencies.Add(new FYCurrencyItem { CurrencyName = "SoftCurrency", Amount = balance.SoftCurrency });
balance["SC"] -= ingredient.Amount;
changedCurrencies.Add(new FYCurrencyItem { CurrencyName = "SoftCurrency", Amount = balance["SC"] });
} else if (ingredient.Currency == "Aurum") {
remaining -= ingredient.Amount;
balance.HardCurrency -= ingredient.Amount;
changedCurrencies.Add(new FYCurrencyItem { CurrencyName = "Aurum", Amount = balance.HardCurrency });
balance["AU"] -= ingredient.Amount;
changedCurrencies.Add(new FYCurrencyItem { CurrencyName = "Aurum", Amount = balance["AU"] });
} else {
foreach (var item in inventory) {
for (var i = 0; i < inventory.Count; i++) {
var item = inventory[i];
if (item.BaseItemId != ingredient.Currency) {
continue;
}
@@ -1,8 +1,5 @@
using System.Text.Json.Serialization;
using Prospect.Server.Api.Services.Auth.Extensions;
using Prospect.Server.Api.Services.CloudScript;
using Prospect.Server.Api.Services.Database;
using Prospect.Server.Api.Services.Squad;
public class TryGetCompleteSquadInfoRequest
{
@@ -54,30 +51,26 @@ public class TryGetCompleteSquadInfoFunction : ICloudScriptFunction<TryGetComple
{
private readonly ILogger<TryGetCompleteSquadInfoFunction> _logger;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly SquadService _squadService;
public TryGetCompleteSquadInfoFunction(ILogger<TryGetCompleteSquadInfoFunction> logger, IHttpContextAccessor httpContextAccessor, SquadService squadService)
public TryGetCompleteSquadInfoFunction(ILogger<TryGetCompleteSquadInfoFunction> logger, IHttpContextAccessor httpContextAccessor)
{
_httpContextAccessor = httpContextAccessor;
_logger = logger;
_squadService = squadService;
}
public Task<FYPlayFabSquad> ExecuteAsync(TryGetCompleteSquadInfoRequest request)
public async Task<FYPlayFabSquad> ExecuteAsync(TryGetCompleteSquadInfoRequest 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 squad = _squadService.GetByUser(userId);
if (squad == null)
// var userId = context.User.FindAuthUserId();
return new FYPlayFabSquad
{
// Not in a squad yet — return an empty squad (same as the previous stub).
return Task.FromResult(new FYPlayFabSquad());
}
_logger.LogInformation("TryGetCompleteSquadInfo: user {User} in squad {Squad} ({Count} member(s))", userId, squad.SquadId, squad.Members.Count);
return Task.FromResult(_squadService.ToClientSquad(squad));
// SquadID = "100",
// Members = [],
};
}
}
@@ -1,62 +0,0 @@
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,51 +1,12 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Prospect.Server.Api.Services.Auth.Extensions;
using Prospect.Server.Api.Services.CloudScript.Models;
using Prospect.Server.Api.Services.UserData;
using Prospect.Server.Api.Services.CloudScript.Models;
namespace Prospect.Server.Api.Services.CloudScript.Functions;
// Persisted presence, read back by GetFriendList to show friends' online / in-match state.
public class PlayerPresenceState
{
[JsonPropertyName("inMatch")]
public bool InMatch { get; set; }
[JsonPropertyName("lastSeenUtc")]
public long LastSeenUtc { get; set; }
}
[CloudScriptFunction("UpdatePlayerPresenceState")]
public class UpdatePlayerPresenceState : ICloudScriptFunction<FYUpdatePlayerPresenceStateRequest, object>
{
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly UserDataService _userDataService;
public UpdatePlayerPresenceState(IHttpContextAccessor httpContextAccessor, UserDataService userDataService)
public Task<object> ExecuteAsync(FYUpdatePlayerPresenceStateRequest request)
{
_httpContextAccessor = httpContextAccessor;
_userDataService = userDataService;
}
public async Task<object> ExecuteAsync(FYUpdatePlayerPresenceStateRequest request)
{
var context = _httpContextAccessor.HttpContext;
if (context == null)
{
return new { };
}
var userId = context.User.FindAuthUserId();
var state = new PlayerPresenceState
{
InMatch = request.InMatch,
LastSeenUtc = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
};
await _userDataService.UpdateAsync(
userId, userId,
new Dictionary<string, string> { ["PresenceState"] = JsonSerializer.Serialize(state) }
);
return new { };
return Task.FromResult<object>(new { });
}
}
@@ -1,14 +1,7 @@
using System.Text.Json.Serialization;
namespace Prospect.Server.Api.Services.CloudScript.Models.Data;
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
{
[JsonPropertyName("insuranceId")]
public string InsuranceId { get; set; }
[JsonPropertyName("itemIds")]
public List<string> ItemIds { get; set; }
// m_insuranceId
// m_itemIds
}
@@ -29,30 +29,3 @@ public class FYEnterMatchAzureFunction
[JsonPropertyName("loginNonce")]
public string LoginNonce { get; set; }
}
public class FYEnterMatchAzureFunctionCB
{
[JsonPropertyName("optimalRegion")]
public string? OptimalRegion { get; set; }
[JsonPropertyName("isMatch")]
public bool IsMatch { get; set; }
[JsonPropertyName("bypassMaintenanceMode")]
public bool BypassMaintenanceMode { get; set; }
[JsonPropertyName("debugOption")]
public string? DebugOption { get; set; }
[JsonPropertyName("mapName")]
public string? MapName { get; set; }
[JsonPropertyName("squad_id")]
public string? SquadId { get; set; }
[JsonPropertyName("purchaseInsuranceRequest")]
public FYPurchaseInventoryInsuranceRequest PurchaseInventoryInsurances { get; set; }
[JsonPropertyName("loginNonce")]
public string LoginNonce { get; set; }
}
@@ -21,30 +21,3 @@ public class FYEnterMatchmakingResult
[JsonPropertyName("errorMessage")]
public string ErrorMessage { get; set; }
}
public class FYEnterMatchAzureFunctionResult
{
[JsonPropertyName("success")]
public bool Success { get; set; }
[JsonPropertyName("address")]
public string? Address { get; set; }
[JsonPropertyName("errorMessage")]
public string? ErrorMessage { get; set; }
[JsonPropertyName("port")]
public int Port { get; set; }
[JsonPropertyName("sharedIndex")]
public int ShardIndex { get; set; }
[JsonPropertyName("singleplayerStation")]
public bool SingleplayerStation { get; set; }
[JsonPropertyName("maintenanceMode")]
public bool MaintenanceMode { get; set; }
[JsonPropertyName("sessionTimeJoinDelay")]
public float SessionTimeJoinDelay { get; set; }
}
@@ -16,12 +16,6 @@ public class DbEntityService : BaseDbService<PlayFabEntity>
return await Collection.Find(user => user.UserId == userId).SingleOrDefaultAsync();
}
// Admin back-office: list every entity (title_player_account).
public async Task<List<PlayFabEntity>> GetAllAsync()
{
return await Collection.Find(_ => true).ToListAsync();
}
private async Task<PlayFabEntity> CreateAsync(string userId)
{
var user = new PlayFabEntity
@@ -46,19 +46,6 @@ public class DbUserDataService : BaseDbService<PlayFabUserData>
return await query.ToCursorAsync();
}
// Admin back-office: every key/value stored for a player.
public async Task<List<PlayFabUserData>> GetAllForUserAsync(string playFabId)
{
return await Collection.Find(data => data.PlayFabId == playFabId)
.SortBy(data => data.Key).ToListAsync();
}
// Admin back-office: total stored key/value documents.
public async Task<long> CountAsync()
{
return await Collection.CountDocumentsAsync(_ => true);
}
public async Task UpdateValueAsync(string dataId, string value)
{
var update = Builders<PlayFabUserData>.Update
@@ -18,26 +18,6 @@ public class DbUserService : BaseDbService<PlayFabUser>
auth.Key == key)).FirstOrDefaultAsync();
}
// Resolve a player by its PlayFab id (used to display squad member names).
public async Task<PlayFabUser> FindByIdAsync(string id)
{
return await Collection.Find(user => user.Id == id).FirstOrDefaultAsync();
}
// Admin back-office: list every player.
public async Task<List<PlayFabUser>> GetAllAsync()
{
return await Collection.Find(_ => true).ToListAsync();
}
// Persist a display-name change (UpdateUserTitleDisplayName). Without this the rename
// is echoed back to the client but lost on next login.
public async Task UpdateDisplayNameAsync(string id, string displayName)
{
var update = Builders<PlayFabUser>.Update.Set(user => user.DisplayName, displayName);
await Collection.UpdateOneAsync(user => user.Id == id, update);
}
private async Task<PlayFabUser> CreateAsync(PlayFabUserAuthType type, string key)
{
var user = new PlayFabUser
@@ -63,18 +43,4 @@ public class DbUserService : BaseDbService<PlayFabUser>
return await FindAsync(type, key) ??
await CreateAsync(type, key);
}
// Resolve several auth keys (e.g. a batch of Steam IDs) to the players that exist on
// this server in a single query. Used by the friends list to keep only the imported
// Steam friends who have actually played here.
public async Task<List<PlayFabUser>> FindManyAsync(PlayFabUserAuthType type, IReadOnlyCollection<string> keys)
{
if (keys == null || keys.Count == 0)
{
return new List<PlayFabUser>();
}
var wanted = new HashSet<string>(keys);
return await Collection.Find(user => user.Auth.Any(auth =>
auth.Type == type && wanted.Contains(auth.Key))).ToListAsync();
}
}
@@ -1,49 +0,0 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Prospect.Server.Api.Services.GameRef;
// One reference entry: readable name, rarity, category. Sourced from community-datamined
// data (TCF-Wiki), bundled as Data/gameref.json. Compact keys n/r/c to keep the file small.
public class GameRefEntry
{
[JsonPropertyName("n")] public string Name { get; set; } = "";
[JsonPropertyName("r")] public string Rarity { get; set; } = "";
[JsonPropertyName("c")] public string Category { get; set; } = "";
}
// Resolves internal item / vanity ids to readable names for the admin back-office.
public class GameRefService
{
private readonly Dictionary<string, GameRefEntry> _ref = new(StringComparer.OrdinalIgnoreCase);
public GameRefService(ILogger<GameRefService> logger)
{
try
{
var path = Path.Combine(AppContext.BaseDirectory, "Data", "gameref.json");
if (File.Exists(path))
{
var data = JsonSerializer.Deserialize<Dictionary<string, GameRefEntry>>(File.ReadAllText(path));
if (data != null)
{
foreach (var kv in data)
{
_ref[kv.Key] = kv.Value;
}
}
}
logger.LogInformation("GameRef loaded {Count} entries", _ref.Count);
}
catch (Exception e)
{
logger.LogWarning(e, "GameRef load failed");
}
}
public GameRefEntry? Get(string? id) => id != null && _ref.TryGetValue(id, out var e) ? e : null;
public string Name(string? id) => Get(id)?.Name ?? id ?? "";
public IReadOnlyDictionary<string, GameRefEntry> All => _ref;
}
@@ -1,99 +0,0 @@
using System.Collections.Concurrent;
namespace Prospect.Server.Api.Services.Squad;
// In-memory squad/lobby state. Squads are keyed by the squadId the client provides on the
// SquadMember* calls (the client forms the party and shares a squadId; each member's client
// then calls the backend with it). A member is (lazily) registered into that squad on any
// squad call, and the current squad for a user is tracked so TryGetCompleteSquadInfo — which
// carries no squadId — can still return it. State is process-local (fine for a small server).
public class SquadMemberInfo
{
public string UserId { get; set; } = "";
public string DisplayName { get; set; } = "";
public int OnlineState { get; set; }
public bool IsReady { get; set; }
public string SelectedMap { get; set; } = "";
public bool IsLeader { get; set; }
}
public class SquadInfo
{
public string SquadId { get; set; } = "";
public ConcurrentDictionary<string, SquadMemberInfo> Members { get; } = new();
}
public class SquadService
{
private readonly ConcurrentDictionary<string, SquadInfo> _squads = new();
private readonly ConcurrentDictionary<string, string> _userToSquad = new();
public SquadInfo JoinOrCreate(string squadId, string userId, string displayName)
{
var squad = _squads.GetOrAdd(squadId, id => new SquadInfo { SquadId = id });
var isFirst = squad.Members.IsEmpty;
var member = squad.Members.GetOrAdd(userId, uid => new SquadMemberInfo { UserId = uid, IsLeader = isFirst });
if (!string.IsNullOrEmpty(displayName)) member.DisplayName = displayName;
_userToSquad[userId] = squadId;
return squad;
}
public SquadInfo? GetByUser(string userId)
=> _userToSquad.TryGetValue(userId, out var sid) && _squads.TryGetValue(sid, out var sq) ? sq : null;
public SquadInfo? Get(string squadId)
=> _squads.TryGetValue(squadId, out var sq) ? sq : null;
public void SetReady(string squadId, string userId, bool ready, string? selectedMap)
{
if (_squads.TryGetValue(squadId, out var sq) && sq.Members.TryGetValue(userId, out var m))
{
m.IsReady = ready;
if (!string.IsNullOrEmpty(selectedMap)) m.SelectedMap = selectedMap;
}
}
public void SetMap(string squadId, string userId, string map)
{
if (_squads.TryGetValue(squadId, out var sq) && sq.Members.TryGetValue(userId, out var m))
m.SelectedMap = map;
}
public bool IsSquadReady(string squadId)
=> _squads.TryGetValue(squadId, out var sq) && !sq.Members.IsEmpty && sq.Members.Values.All(m => m.IsReady);
public void Leave(string userId)
{
if (_userToSquad.TryRemove(userId, out var sid) && _squads.TryGetValue(sid, out var sq))
{
sq.Members.TryRemove(userId, out _);
if (sq.Members.IsEmpty) _squads.TryRemove(sid, out _);
}
}
// Build the client-facing squad object (models live in the global namespace).
public FYPlayFabSquad ToClientSquad(SquadInfo squad)
{
return new FYPlayFabSquad
{
SquadID = squad.SquadId,
Members = squad.Members.Values.Select(m => new FYPlayFabSquadMember
{
Profile = new FYPlayFabPlayerProfile
{
PlayerId = m.UserId,
DisplayName = m.DisplayName,
AvatarUrl = "",
},
onlineState = m.OnlineState,
matchmakingSettings = new FYUserMatchmakingSettings
{
isReadyForMatch = m.IsReady,
selectedMapName = m.SelectedMap,
isSecretLeader = m.IsLeader,
},
mapRowNamesUnlocked = Array.Empty<string>(),
}).ToArray(),
};
}
}
@@ -1,55 +0,0 @@
using System.Text.Json;
namespace Prospect.Server.Api.Services.Steam;
// Resolves a Steam persona (display) name from a SteamID64 via the Steam Web API.
// PlayFab does this server-side too; our emulator only gets the SteamID from the auth
// ticket, so the real name needs an API key. Inert (no-op) when no key is configured.
public class SteamWebApiService
{
private static readonly HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(5) };
private readonly string? _apiKey;
private readonly ILogger<SteamWebApiService> _logger;
public SteamWebApiService(IConfiguration configuration, ILogger<SteamWebApiService> logger)
{
_apiKey = configuration["SteamWebApiKey"];
_logger = logger;
}
public bool Enabled => !string.IsNullOrWhiteSpace(_apiKey);
public async Task<string?> GetPersonaNameAsync(string steamId64)
{
if (!Enabled)
{
return null;
}
try
{
var url = $"https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v2/?key={_apiKey}&steamids={steamId64}";
var json = await Http.GetStringAsync(url);
using var doc = JsonDocument.Parse(json);
if (doc.RootElement.TryGetProperty("response", out var response) &&
response.TryGetProperty("players", out var players) &&
players.ValueKind == JsonValueKind.Array)
{
foreach (var player in players.EnumerateArray())
{
if (player.TryGetProperty("personaname", out var name))
{
return name.GetString();
}
}
}
}
catch (Exception e)
{
_logger.LogWarning(e, "Steam GetPlayerSummaries failed for {SteamId}", steamId64);
}
return null;
}
}
File diff suppressed because one or more lines are too long
@@ -1,9 +1,6 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Prospect.Server.Api.Models.Client.Data;
using Prospect.Server.Api.Models.Data;
using Prospect.Server.Api.Services.CloudScript.Functions;
using Prospect.Server.Api.Services.CloudScript.Models.Data;
using Prospect.Server.Api.Services.Database;
namespace Prospect.Server.Api.Services.UserData;
@@ -73,53 +70,35 @@ public class UserDataService
};
}
var defaultVanity = new FYCharacterVanity {
UserID = playFabId,
ArchetypeID = "E01_G02",
SlotIndex = 0,
BaseSuitItem = new FYVanityMaterialItem {
ID = "StarterOutfit01M_BaseSuit",
MaterialIndex = 0,
},
HeadItem = new FYVanityMaterialItem {
ID = "E01_G02_Head01",
MaterialIndex = 0,
},
MeleeWeaponItem = new FYVanityMaterialItem {
ID = "Melee_Omega",
MaterialIndex = 0,
},
BodyType = 1,
};
#if SEASON_2_RELEASE || SEASON_2_DEBUG
defaultVanity.BootsItem = new FYVanityMaterialItem {
ID = "StarterOutfit01_Boots_M",
MaterialIndex = 0,
};
defaultVanity.ChestItem = new FYVanityMaterialItem {
ID = "StarterOutfit01_Chest_M",
MaterialIndex = 0,
};
defaultVanity.GloveItem = new FYVanityMaterialItem {
ID = "StarterOutfit01_Gloves_M",
MaterialIndex = 0,
};
#elif SEASON_3_RELEASE || SEASON_3_DEBUG
defaultVanity.BootsItem = new FYVanityMaterialItem {
ID = "StarterOutfit01_Boots",
MaterialIndex = 0,
};
defaultVanity.ChestItem = new FYVanityMaterialItem {
ID = "StarterOutfit01_Chest",
MaterialIndex = 0,
};
defaultVanity.GloveItem = new FYVanityMaterialItem {
ID = "StarterOutfit01_Gloves",
MaterialIndex = 0,
};
#else
#error Unsupported build type
#endif
var blueprints = JsonSerializer.Deserialize<Dictionary<string, TitleDataBlueprintInfo>>(titleData["Blueprints"]);
// TODO: For testing purposes
List<FYCustomItemInfo> inventory = new List<FYCustomItemInfo>();
foreach (var dict in blueprints)
{
var blueprint = dict.Value;
if (blueprint.Kind != "Material")
{
continue;
}
for (int i = 0; i < 3; i++)
{
inventory.Add(new FYCustomItemInfo {
Amount = 10000,
BaseItemId = blueprint.Name,
Durability = blueprint.DurabilityMax,
ItemId = Guid.NewGuid().ToString(),
Insurance = "",
InsuranceOwnerPlayfabId = "",
ModData = new FYModItems { M = [] },
Origin = new FYItemOriginBackend { G = "", P = "", T = "" },
InsuredAttachmentId = "",
PrimaryVanityId = 0,
SecondaryVanityId = 0,
RolledPerks = [],
});
}
}
// TODO: Proper objects.
var defaultData = new Dictionary<string, (bool isPublic, string value)>
@@ -171,27 +150,10 @@ public class UserDataService
// Used to optimize bonuses calculation and further validations without getting the user data and computing bonuses in runtime.
["CharacterTechTreeBonuses"] = (false, "{\"aurumCap\":0,\"aurumRate\":0,\"kmarksCap\":0,\"kmarksRate\":0,\"crateTier\":0,\"stashSize\":0,\"safePocketSize\":0,\"upgradeSpeed\":1.0}"),
["GlobalVanity"] = (false, $"{{\"activeGlobalVanityIds\":[\"Season03_Spray_13\",\"Banner_MIne\",\"Emote_Chill_01\",\"\",\"\",\"\",\"\",\"\"], \"droppodId\": \"VDP_Omega02\"}}"),
["CharacterVanity"] = (false, JsonSerializer.Serialize(defaultVanity)),
["Inventory"] = (false, "[]"),
["JobBoardsData"] = (false, JsonSerializer.Serialize(
new JobBoardsData {
LastBoardRefreshTimeUtc = new CloudScript.Models.Data.FYTimestamp { Seconds = 0 },
Boards = [
new FYFactionContractsData {
FactionId = "Korolev",
Contracts = [ new FYFactionContractData { }, new FYFactionContractData { }, new FYFactionContractData { }]
},
new FYFactionContractsData {
FactionId = "Osiris",
Contracts = [ new FYFactionContractData { }, new FYFactionContractData { }, new FYFactionContractData { }]
},
new FYFactionContractsData {
FactionId = "ICA",
Contracts = [ new FYFactionContractData { }, new FYFactionContractData { }, new FYFactionContractData { }]
}
]
}
)),
// NOTE: Default CharacterVanity is based on Season 2 IDs.
["CharacterVanity"] = (false, $"{{\"userId\":\"{playFabId}\",\"head_item\":{{\"id\":\"E01_G02_Head01\",\"materialIndex\":0}},\"boots_item\":{{\"id\":\"StarterOutfit01_Boots_M\",\"materialIndex\":0}},\"chest_item\":{{\"id\":\"StarterOutfit01_Chest_M\",\"materialIndex\":0}},\"glove_item\":{{\"id\":\"StarterOutfit01_Gloves_M\",\"materialIndex\":0}},\"base_suit_item\":{{\"id\":\"StarterOutfit01M_BaseSuit\",\"materialIndex\":0}},\"melee_weapon_item\":{{\"id\":\"Melee_Omega\",\"materialIndex\":0}},\"body_type\":1,\"archetype_id\":\"E01_G02\",\"slot_index\":0}}"),
// ["Inventory"] = (false, "[]"),
["Inventory"] = (false, JsonSerializer.Serialize(inventory)),
["VanityItems"] = (false, "[]"),
["RetentionBonus"] = (false, "{\"claimedAll\":false,\"daysClaimed\":0,\"lastClaimTime\":{\"seconds\":0}}"),
["Balance"] = (false, "{\"IN\": 0,\"AU\": 800,\"SC\": 30000}"),
@@ -233,6 +195,9 @@ public class UserDataService
}
var other = currentUserId != requestUserId;
if (other) {
Console.WriteLine("aboba");
}
var result = new Dictionary<string, FUserDataRecord>();
if (keys != null && keys.Count > 0)
+1 -7
View File
@@ -30,17 +30,11 @@ public class Startup
services.AddSingleton<UserDataService>();
services.AddSingleton<TitleDataService>();
services.AddSingleton<Prospect.Server.Api.Services.Steam.SteamWebApiService>();
services.AddSingleton<Prospect.Server.Api.Services.GameRef.GameRefService>();
services.AddSingleton<DbUserService>();
services.AddSingleton<DbEntityService>();
services.AddSingleton<DbUserDataService>();
services.AddSingleton<Prospect.Server.Api.Services.Squad.SquadService>();
services.AddSingleton<Prospect.Server.Api.Hubs.SignalRConnectionRegistry>();
services.AddHostedService<QosService>();
services.AddSingleton<QosServer>();
@@ -71,7 +65,7 @@ public class Startup
app.UseDeveloperExceptionPage();
}
// app.UseHttpsRedirection();
app.UseHttpsRedirection();
app.UseSerilogRequestLogging();
app.UseMiddleware<RequestLoggerMiddleware>();
app.UseRouting();
+6 -3
View File
@@ -8,11 +8,14 @@
},
"PlayFabSettings": {
"PublisherId": "850902E5B40508ED",
"TitleId": "2EA46",
"SignalRURL": "https://127.0.0.1:8443/signalr/?hub=pubsub",
"SignalRAccessToken": "TEST"
"TitleId": "2EA46"
},
"Kestrel": {
"Endpoints": {
"Https": {
"Url": "https://2EA46.playfabapi.com"
}
},
"Certificates": {
"Default": {
"Path": ".\\certificate.pfx"
@@ -5,7 +5,6 @@
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Configurations>Debug;Release;Season 3 Release;Season 2 Release;Season 2 Debug;Season 3 Debug</Configurations>
</PropertyGroup>
<ItemGroup>
-1
View File
@@ -3,7 +3,6 @@
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>true</ImplicitUsings>
<Configurations>Debug;Release;Season 3 Release;Season 2 Release;Season 2 Debug;Season 3 Debug</Configurations>
</PropertyGroup>
</Project>
@@ -4,7 +4,6 @@
<TargetFramework>netstandard2.0</TargetFramework>
<LangVersion>9.0</LangVersion>
<IsRoslynComponent>true</IsRoslynComponent>
<Configurations>Debug;Release;Season 3 Release;Season 2 Release;Season 2 Debug;Season 3 Debug</Configurations>
</PropertyGroup>
<ItemGroup>
@@ -5,8 +5,6 @@
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<Configurations>Debug;Release;Season 3 Release;Season 2 Release;Season 2 Debug;Season 3 Debug</Configurations>
</PropertyGroup>
<ItemGroup>
@@ -5,7 +5,6 @@
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<Configurations>Debug;Release;Season 3 Release;Season 2 Release;Season 2 Debug;Season 3 Debug</Configurations>
</PropertyGroup>
<PropertyGroup>
+1 -185
View File
@@ -1,7 +1,7 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.12.35514.174
VisualStudioVersion = 17.12.35514.174 d17.12
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Prospect.Server.Api", "Prospect.Server.Api\Prospect.Server.Api.csproj", "{A539B020-8BEC-497F-9176-DC2665D927A1}"
EndProject
@@ -21,26 +21,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Prospect.Unreal.Tests", "Pr
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Prospect.Unreal.Generator", "Prospect.Unreal.Generator\Prospect.Unreal.Generator.csproj", "{71E3E262-49C5-4B6C-9670-D0741CEDB63B}"
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
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Prospect.Client.Config", "Prospect.Client.Config\Prospect.Client.Config.csproj", "{6DECEB07-996B-4EBC-927C-13E170161107}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|x64 = Release|x64
Release|x86 = Release|x86
Season 2 Debug|x64 = Season 2 Debug|x64
Season 2 Debug|x86 = Season 2 Debug|x86
Season 2 Release|x64 = Season 2 Release|x64
Season 2 Release|x86 = Season 2 Release|x86
Season 3 Debug|x64 = Season 3 Debug|x64
Season 3 Debug|x86 = Season 3 Debug|x86
Season 3 Release|x64 = Season 3 Release|x64
Season 3 Release|x86 = Season 3 Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{A539B020-8BEC-497F-9176-DC2665D927A1}.Debug|x64.ActiveCfg = Debug|Any CPU
@@ -51,22 +37,6 @@ Global
{A539B020-8BEC-497F-9176-DC2665D927A1}.Release|x64.Build.0 = Release|Any CPU
{A539B020-8BEC-497F-9176-DC2665D927A1}.Release|x86.ActiveCfg = Release|Any CPU
{A539B020-8BEC-497F-9176-DC2665D927A1}.Release|x86.Build.0 = Release|Any CPU
{A539B020-8BEC-497F-9176-DC2665D927A1}.Season 2 Debug|x64.ActiveCfg = Season 2 Debug|Any CPU
{A539B020-8BEC-497F-9176-DC2665D927A1}.Season 2 Debug|x64.Build.0 = Season 2 Debug|Any CPU
{A539B020-8BEC-497F-9176-DC2665D927A1}.Season 2 Debug|x86.ActiveCfg = Season 2 Debug|Any CPU
{A539B020-8BEC-497F-9176-DC2665D927A1}.Season 2 Debug|x86.Build.0 = Season 2 Debug|Any CPU
{A539B020-8BEC-497F-9176-DC2665D927A1}.Season 2 Release|x64.ActiveCfg = Season 2 Release|Any CPU
{A539B020-8BEC-497F-9176-DC2665D927A1}.Season 2 Release|x64.Build.0 = Season 2 Release|Any CPU
{A539B020-8BEC-497F-9176-DC2665D927A1}.Season 2 Release|x86.ActiveCfg = Season 2 Release|Any CPU
{A539B020-8BEC-497F-9176-DC2665D927A1}.Season 2 Release|x86.Build.0 = Season 2 Release|Any CPU
{A539B020-8BEC-497F-9176-DC2665D927A1}.Season 3 Debug|x64.ActiveCfg = Season 3 Debug|Any CPU
{A539B020-8BEC-497F-9176-DC2665D927A1}.Season 3 Debug|x64.Build.0 = Season 3 Debug|Any CPU
{A539B020-8BEC-497F-9176-DC2665D927A1}.Season 3 Debug|x86.ActiveCfg = Season 3 Debug|Any CPU
{A539B020-8BEC-497F-9176-DC2665D927A1}.Season 3 Debug|x86.Build.0 = Season 3 Debug|Any CPU
{A539B020-8BEC-497F-9176-DC2665D927A1}.Season 3 Release|x64.ActiveCfg = Season 3 Release|Any CPU
{A539B020-8BEC-497F-9176-DC2665D927A1}.Season 3 Release|x64.Build.0 = Season 3 Release|Any CPU
{A539B020-8BEC-497F-9176-DC2665D927A1}.Season 3 Release|x86.ActiveCfg = Season 3 Release|Any CPU
{A539B020-8BEC-497F-9176-DC2665D927A1}.Season 3 Release|x86.Build.0 = Season 3 Release|Any CPU
{E65720EB-7ED5-4D81-BE06-1DFFD57C1C47}.Debug|x64.ActiveCfg = Debug|Any CPU
{E65720EB-7ED5-4D81-BE06-1DFFD57C1C47}.Debug|x64.Build.0 = Debug|Any CPU
{E65720EB-7ED5-4D81-BE06-1DFFD57C1C47}.Debug|x86.ActiveCfg = Debug|Any CPU
@@ -75,22 +45,6 @@ Global
{E65720EB-7ED5-4D81-BE06-1DFFD57C1C47}.Release|x64.Build.0 = Release|Any CPU
{E65720EB-7ED5-4D81-BE06-1DFFD57C1C47}.Release|x86.ActiveCfg = Release|Any CPU
{E65720EB-7ED5-4D81-BE06-1DFFD57C1C47}.Release|x86.Build.0 = Release|Any CPU
{E65720EB-7ED5-4D81-BE06-1DFFD57C1C47}.Season 2 Debug|x64.ActiveCfg = Season 2 Debug|Any CPU
{E65720EB-7ED5-4D81-BE06-1DFFD57C1C47}.Season 2 Debug|x64.Build.0 = Season 2 Debug|Any CPU
{E65720EB-7ED5-4D81-BE06-1DFFD57C1C47}.Season 2 Debug|x86.ActiveCfg = Season 2 Debug|Any CPU
{E65720EB-7ED5-4D81-BE06-1DFFD57C1C47}.Season 2 Debug|x86.Build.0 = Season 2 Debug|Any CPU
{E65720EB-7ED5-4D81-BE06-1DFFD57C1C47}.Season 2 Release|x64.ActiveCfg = Season 2 Release|Any CPU
{E65720EB-7ED5-4D81-BE06-1DFFD57C1C47}.Season 2 Release|x64.Build.0 = Season 2 Release|Any CPU
{E65720EB-7ED5-4D81-BE06-1DFFD57C1C47}.Season 2 Release|x86.ActiveCfg = Season 2 Release|Any CPU
{E65720EB-7ED5-4D81-BE06-1DFFD57C1C47}.Season 2 Release|x86.Build.0 = Season 2 Release|Any CPU
{E65720EB-7ED5-4D81-BE06-1DFFD57C1C47}.Season 3 Debug|x64.ActiveCfg = Season 3 Debug|Any CPU
{E65720EB-7ED5-4D81-BE06-1DFFD57C1C47}.Season 3 Debug|x64.Build.0 = Season 3 Debug|Any CPU
{E65720EB-7ED5-4D81-BE06-1DFFD57C1C47}.Season 3 Debug|x86.ActiveCfg = Season 3 Debug|Any CPU
{E65720EB-7ED5-4D81-BE06-1DFFD57C1C47}.Season 3 Debug|x86.Build.0 = Season 3 Debug|Any CPU
{E65720EB-7ED5-4D81-BE06-1DFFD57C1C47}.Season 3 Release|x64.ActiveCfg = Season 3 Release|Any CPU
{E65720EB-7ED5-4D81-BE06-1DFFD57C1C47}.Season 3 Release|x64.Build.0 = Season 3 Release|Any CPU
{E65720EB-7ED5-4D81-BE06-1DFFD57C1C47}.Season 3 Release|x86.ActiveCfg = Season 3 Release|Any CPU
{E65720EB-7ED5-4D81-BE06-1DFFD57C1C47}.Season 3 Release|x86.Build.0 = Season 3 Release|Any CPU
{DE064B5C-5E9A-44FB-8DB0-5E61DD8ED4F6}.Debug|x64.ActiveCfg = Debug|Any CPU
{DE064B5C-5E9A-44FB-8DB0-5E61DD8ED4F6}.Debug|x64.Build.0 = Debug|Any CPU
{DE064B5C-5E9A-44FB-8DB0-5E61DD8ED4F6}.Debug|x86.ActiveCfg = Debug|Any CPU
@@ -99,22 +53,6 @@ Global
{DE064B5C-5E9A-44FB-8DB0-5E61DD8ED4F6}.Release|x64.Build.0 = Release|Any CPU
{DE064B5C-5E9A-44FB-8DB0-5E61DD8ED4F6}.Release|x86.ActiveCfg = Release|Any CPU
{DE064B5C-5E9A-44FB-8DB0-5E61DD8ED4F6}.Release|x86.Build.0 = Release|Any CPU
{DE064B5C-5E9A-44FB-8DB0-5E61DD8ED4F6}.Season 2 Debug|x64.ActiveCfg = Season 2 Debug|Any CPU
{DE064B5C-5E9A-44FB-8DB0-5E61DD8ED4F6}.Season 2 Debug|x64.Build.0 = Season 2 Debug|Any CPU
{DE064B5C-5E9A-44FB-8DB0-5E61DD8ED4F6}.Season 2 Debug|x86.ActiveCfg = Season 2 Debug|Any CPU
{DE064B5C-5E9A-44FB-8DB0-5E61DD8ED4F6}.Season 2 Debug|x86.Build.0 = Season 2 Debug|Any CPU
{DE064B5C-5E9A-44FB-8DB0-5E61DD8ED4F6}.Season 2 Release|x64.ActiveCfg = Season 2 Release|Any CPU
{DE064B5C-5E9A-44FB-8DB0-5E61DD8ED4F6}.Season 2 Release|x64.Build.0 = Season 2 Release|Any CPU
{DE064B5C-5E9A-44FB-8DB0-5E61DD8ED4F6}.Season 2 Release|x86.ActiveCfg = Season 2 Release|Any CPU
{DE064B5C-5E9A-44FB-8DB0-5E61DD8ED4F6}.Season 2 Release|x86.Build.0 = Season 2 Release|Any CPU
{DE064B5C-5E9A-44FB-8DB0-5E61DD8ED4F6}.Season 3 Debug|x64.ActiveCfg = Season 3 Debug|Any CPU
{DE064B5C-5E9A-44FB-8DB0-5E61DD8ED4F6}.Season 3 Debug|x64.Build.0 = Season 3 Debug|Any CPU
{DE064B5C-5E9A-44FB-8DB0-5E61DD8ED4F6}.Season 3 Debug|x86.ActiveCfg = Season 3 Debug|Any CPU
{DE064B5C-5E9A-44FB-8DB0-5E61DD8ED4F6}.Season 3 Debug|x86.Build.0 = Season 3 Debug|Any CPU
{DE064B5C-5E9A-44FB-8DB0-5E61DD8ED4F6}.Season 3 Release|x64.ActiveCfg = Season 3 Release|Any CPU
{DE064B5C-5E9A-44FB-8DB0-5E61DD8ED4F6}.Season 3 Release|x64.Build.0 = Season 3 Release|Any CPU
{DE064B5C-5E9A-44FB-8DB0-5E61DD8ED4F6}.Season 3 Release|x86.ActiveCfg = Season 3 Release|Any CPU
{DE064B5C-5E9A-44FB-8DB0-5E61DD8ED4F6}.Season 3 Release|x86.Build.0 = Season 3 Release|Any CPU
{380DD490-8F01-4025-91D7-74C289AA5B75}.Debug|x64.ActiveCfg = Debug|Any CPU
{380DD490-8F01-4025-91D7-74C289AA5B75}.Debug|x64.Build.0 = Debug|Any CPU
{380DD490-8F01-4025-91D7-74C289AA5B75}.Debug|x86.ActiveCfg = Debug|Any CPU
@@ -123,22 +61,6 @@ Global
{380DD490-8F01-4025-91D7-74C289AA5B75}.Release|x64.Build.0 = Release|Any CPU
{380DD490-8F01-4025-91D7-74C289AA5B75}.Release|x86.ActiveCfg = Release|Any CPU
{380DD490-8F01-4025-91D7-74C289AA5B75}.Release|x86.Build.0 = Release|Any CPU
{380DD490-8F01-4025-91D7-74C289AA5B75}.Season 2 Debug|x64.ActiveCfg = Season 2 Debug|Any CPU
{380DD490-8F01-4025-91D7-74C289AA5B75}.Season 2 Debug|x64.Build.0 = Season 2 Debug|Any CPU
{380DD490-8F01-4025-91D7-74C289AA5B75}.Season 2 Debug|x86.ActiveCfg = Season 2 Debug|Any CPU
{380DD490-8F01-4025-91D7-74C289AA5B75}.Season 2 Debug|x86.Build.0 = Season 2 Debug|Any CPU
{380DD490-8F01-4025-91D7-74C289AA5B75}.Season 2 Release|x64.ActiveCfg = Season 2 Release|Any CPU
{380DD490-8F01-4025-91D7-74C289AA5B75}.Season 2 Release|x64.Build.0 = Season 2 Release|Any CPU
{380DD490-8F01-4025-91D7-74C289AA5B75}.Season 2 Release|x86.ActiveCfg = Season 2 Release|Any CPU
{380DD490-8F01-4025-91D7-74C289AA5B75}.Season 2 Release|x86.Build.0 = Season 2 Release|Any CPU
{380DD490-8F01-4025-91D7-74C289AA5B75}.Season 3 Debug|x64.ActiveCfg = Season 3 Debug|Any CPU
{380DD490-8F01-4025-91D7-74C289AA5B75}.Season 3 Debug|x64.Build.0 = Season 3 Debug|Any CPU
{380DD490-8F01-4025-91D7-74C289AA5B75}.Season 3 Debug|x86.ActiveCfg = Season 3 Debug|Any CPU
{380DD490-8F01-4025-91D7-74C289AA5B75}.Season 3 Debug|x86.Build.0 = Season 3 Debug|Any CPU
{380DD490-8F01-4025-91D7-74C289AA5B75}.Season 3 Release|x64.ActiveCfg = Season 3 Release|Any CPU
{380DD490-8F01-4025-91D7-74C289AA5B75}.Season 3 Release|x64.Build.0 = Season 3 Release|Any CPU
{380DD490-8F01-4025-91D7-74C289AA5B75}.Season 3 Release|x86.ActiveCfg = Season 3 Release|Any CPU
{380DD490-8F01-4025-91D7-74C289AA5B75}.Season 3 Release|x86.Build.0 = Season 3 Release|Any CPU
{14DAE1A4-6F3F-43C0-AC17-9DCDE420AF34}.Debug|x64.ActiveCfg = Debug|Any CPU
{14DAE1A4-6F3F-43C0-AC17-9DCDE420AF34}.Debug|x64.Build.0 = Debug|Any CPU
{14DAE1A4-6F3F-43C0-AC17-9DCDE420AF34}.Debug|x86.ActiveCfg = Debug|Any CPU
@@ -147,22 +69,6 @@ Global
{14DAE1A4-6F3F-43C0-AC17-9DCDE420AF34}.Release|x64.Build.0 = Release|Any CPU
{14DAE1A4-6F3F-43C0-AC17-9DCDE420AF34}.Release|x86.ActiveCfg = Release|Any CPU
{14DAE1A4-6F3F-43C0-AC17-9DCDE420AF34}.Release|x86.Build.0 = Release|Any CPU
{14DAE1A4-6F3F-43C0-AC17-9DCDE420AF34}.Season 2 Debug|x64.ActiveCfg = Season 2 Debug|Any CPU
{14DAE1A4-6F3F-43C0-AC17-9DCDE420AF34}.Season 2 Debug|x64.Build.0 = Season 2 Debug|Any CPU
{14DAE1A4-6F3F-43C0-AC17-9DCDE420AF34}.Season 2 Debug|x86.ActiveCfg = Season 2 Debug|Any CPU
{14DAE1A4-6F3F-43C0-AC17-9DCDE420AF34}.Season 2 Debug|x86.Build.0 = Season 2 Debug|Any CPU
{14DAE1A4-6F3F-43C0-AC17-9DCDE420AF34}.Season 2 Release|x64.ActiveCfg = Season 2 Release|Any CPU
{14DAE1A4-6F3F-43C0-AC17-9DCDE420AF34}.Season 2 Release|x64.Build.0 = Season 2 Release|Any CPU
{14DAE1A4-6F3F-43C0-AC17-9DCDE420AF34}.Season 2 Release|x86.ActiveCfg = Season 2 Release|Any CPU
{14DAE1A4-6F3F-43C0-AC17-9DCDE420AF34}.Season 2 Release|x86.Build.0 = Season 2 Release|Any CPU
{14DAE1A4-6F3F-43C0-AC17-9DCDE420AF34}.Season 3 Debug|x64.ActiveCfg = Season 3 Debug|Any CPU
{14DAE1A4-6F3F-43C0-AC17-9DCDE420AF34}.Season 3 Debug|x64.Build.0 = Season 3 Debug|Any CPU
{14DAE1A4-6F3F-43C0-AC17-9DCDE420AF34}.Season 3 Debug|x86.ActiveCfg = Season 3 Debug|Any CPU
{14DAE1A4-6F3F-43C0-AC17-9DCDE420AF34}.Season 3 Debug|x86.Build.0 = Season 3 Debug|Any CPU
{14DAE1A4-6F3F-43C0-AC17-9DCDE420AF34}.Season 3 Release|x64.ActiveCfg = Season 3 Release|Any CPU
{14DAE1A4-6F3F-43C0-AC17-9DCDE420AF34}.Season 3 Release|x64.Build.0 = Season 3 Release|Any CPU
{14DAE1A4-6F3F-43C0-AC17-9DCDE420AF34}.Season 3 Release|x86.ActiveCfg = Season 3 Release|Any CPU
{14DAE1A4-6F3F-43C0-AC17-9DCDE420AF34}.Season 3 Release|x86.Build.0 = Season 3 Release|Any CPU
{71E3E262-49C5-4B6C-9670-D0741CEDB63B}.Debug|x64.ActiveCfg = Debug|Any CPU
{71E3E262-49C5-4B6C-9670-D0741CEDB63B}.Debug|x64.Build.0 = Debug|Any CPU
{71E3E262-49C5-4B6C-9670-D0741CEDB63B}.Debug|x86.ActiveCfg = Debug|Any CPU
@@ -171,94 +77,6 @@ Global
{71E3E262-49C5-4B6C-9670-D0741CEDB63B}.Release|x64.Build.0 = Release|Any CPU
{71E3E262-49C5-4B6C-9670-D0741CEDB63B}.Release|x86.ActiveCfg = Release|Any CPU
{71E3E262-49C5-4B6C-9670-D0741CEDB63B}.Release|x86.Build.0 = Release|Any CPU
{71E3E262-49C5-4B6C-9670-D0741CEDB63B}.Season 2 Debug|x64.ActiveCfg = Season 2 Debug|Any CPU
{71E3E262-49C5-4B6C-9670-D0741CEDB63B}.Season 2 Debug|x64.Build.0 = Season 2 Debug|Any CPU
{71E3E262-49C5-4B6C-9670-D0741CEDB63B}.Season 2 Debug|x86.ActiveCfg = Season 2 Debug|Any CPU
{71E3E262-49C5-4B6C-9670-D0741CEDB63B}.Season 2 Debug|x86.Build.0 = Season 2 Debug|Any CPU
{71E3E262-49C5-4B6C-9670-D0741CEDB63B}.Season 2 Release|x64.ActiveCfg = Season 2 Release|Any CPU
{71E3E262-49C5-4B6C-9670-D0741CEDB63B}.Season 2 Release|x64.Build.0 = Season 2 Release|Any CPU
{71E3E262-49C5-4B6C-9670-D0741CEDB63B}.Season 2 Release|x86.ActiveCfg = Season 2 Release|Any CPU
{71E3E262-49C5-4B6C-9670-D0741CEDB63B}.Season 2 Release|x86.Build.0 = Season 2 Release|Any CPU
{71E3E262-49C5-4B6C-9670-D0741CEDB63B}.Season 3 Debug|x64.ActiveCfg = Season 3 Debug|Any CPU
{71E3E262-49C5-4B6C-9670-D0741CEDB63B}.Season 3 Debug|x64.Build.0 = Season 3 Debug|Any CPU
{71E3E262-49C5-4B6C-9670-D0741CEDB63B}.Season 3 Debug|x86.ActiveCfg = Season 3 Debug|Any CPU
{71E3E262-49C5-4B6C-9670-D0741CEDB63B}.Season 3 Debug|x86.Build.0 = Season 3 Debug|Any CPU
{71E3E262-49C5-4B6C-9670-D0741CEDB63B}.Season 3 Release|x64.ActiveCfg = Season 3 Release|Any CPU
{71E3E262-49C5-4B6C-9670-D0741CEDB63B}.Season 3 Release|x64.Build.0 = Season 3 Release|Any CPU
{71E3E262-49C5-4B6C-9670-D0741CEDB63B}.Season 3 Release|x86.ActiveCfg = Season 3 Release|Any CPU
{71E3E262-49C5-4B6C-9670-D0741CEDB63B}.Season 3 Release|x86.Build.0 = Season 3 Release|Any CPU
{04B1D963-2BCB-4887-9D6D-04CB3366A348}.Debug|x64.ActiveCfg = Debug|x64
{04B1D963-2BCB-4887-9D6D-04CB3366A348}.Debug|x64.Build.0 = Debug|x64
{04B1D963-2BCB-4887-9D6D-04CB3366A348}.Debug|x86.ActiveCfg = Debug|Win32
{04B1D963-2BCB-4887-9D6D-04CB3366A348}.Debug|x86.Build.0 = Debug|Win32
{04B1D963-2BCB-4887-9D6D-04CB3366A348}.Release|x64.ActiveCfg = Release|x64
{04B1D963-2BCB-4887-9D6D-04CB3366A348}.Release|x64.Build.0 = Release|x64
{04B1D963-2BCB-4887-9D6D-04CB3366A348}.Release|x86.ActiveCfg = Release|Win32
{04B1D963-2BCB-4887-9D6D-04CB3366A348}.Release|x86.Build.0 = Release|Win32
{04B1D963-2BCB-4887-9D6D-04CB3366A348}.Season 2 Debug|x64.ActiveCfg = Debug|x64
{04B1D963-2BCB-4887-9D6D-04CB3366A348}.Season 2 Debug|x64.Build.0 = Debug|x64
{04B1D963-2BCB-4887-9D6D-04CB3366A348}.Season 2 Debug|x86.ActiveCfg = Debug|Win32
{04B1D963-2BCB-4887-9D6D-04CB3366A348}.Season 2 Debug|x86.Build.0 = Debug|Win32
{04B1D963-2BCB-4887-9D6D-04CB3366A348}.Season 2 Release|x64.ActiveCfg = Release|x64
{04B1D963-2BCB-4887-9D6D-04CB3366A348}.Season 2 Release|x64.Build.0 = Release|x64
{04B1D963-2BCB-4887-9D6D-04CB3366A348}.Season 2 Release|x86.ActiveCfg = Release|Win32
{04B1D963-2BCB-4887-9D6D-04CB3366A348}.Season 2 Release|x86.Build.0 = Release|Win32
{04B1D963-2BCB-4887-9D6D-04CB3366A348}.Season 3 Debug|x64.ActiveCfg = Debug|x64
{04B1D963-2BCB-4887-9D6D-04CB3366A348}.Season 3 Debug|x64.Build.0 = Debug|x64
{04B1D963-2BCB-4887-9D6D-04CB3366A348}.Season 3 Debug|x86.ActiveCfg = Debug|Win32
{04B1D963-2BCB-4887-9D6D-04CB3366A348}.Season 3 Debug|x86.Build.0 = Debug|Win32
{04B1D963-2BCB-4887-9D6D-04CB3366A348}.Season 3 Release|x64.ActiveCfg = Release|x64
{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
{6DECEB07-996B-4EBC-927C-13E170161107}.Debug|x64.ActiveCfg = Debug|Any CPU
{6DECEB07-996B-4EBC-927C-13E170161107}.Debug|x64.Build.0 = Debug|Any CPU
{6DECEB07-996B-4EBC-927C-13E170161107}.Debug|x86.ActiveCfg = Debug|Any CPU
{6DECEB07-996B-4EBC-927C-13E170161107}.Debug|x86.Build.0 = Debug|Any CPU
{6DECEB07-996B-4EBC-927C-13E170161107}.Release|x64.ActiveCfg = Release|Any CPU
{6DECEB07-996B-4EBC-927C-13E170161107}.Release|x64.Build.0 = Release|Any CPU
{6DECEB07-996B-4EBC-927C-13E170161107}.Release|x86.ActiveCfg = Release|Any CPU
{6DECEB07-996B-4EBC-927C-13E170161107}.Release|x86.Build.0 = Release|Any CPU
{6DECEB07-996B-4EBC-927C-13E170161107}.Season 2 Debug|x64.ActiveCfg = Season 2 Debug|Any CPU
{6DECEB07-996B-4EBC-927C-13E170161107}.Season 2 Debug|x64.Build.0 = Season 2 Debug|Any CPU
{6DECEB07-996B-4EBC-927C-13E170161107}.Season 2 Debug|x86.ActiveCfg = Season 2 Debug|Any CPU
{6DECEB07-996B-4EBC-927C-13E170161107}.Season 2 Debug|x86.Build.0 = Season 2 Debug|Any CPU
{6DECEB07-996B-4EBC-927C-13E170161107}.Season 2 Release|x64.ActiveCfg = Season 2 Release|Any CPU
{6DECEB07-996B-4EBC-927C-13E170161107}.Season 2 Release|x64.Build.0 = Season 2 Release|Any CPU
{6DECEB07-996B-4EBC-927C-13E170161107}.Season 2 Release|x86.ActiveCfg = Season 2 Release|Any CPU
{6DECEB07-996B-4EBC-927C-13E170161107}.Season 2 Release|x86.Build.0 = Season 2 Release|Any CPU
{6DECEB07-996B-4EBC-927C-13E170161107}.Season 3 Debug|x64.ActiveCfg = Season 3 Debug|Any CPU
{6DECEB07-996B-4EBC-927C-13E170161107}.Season 3 Debug|x64.Build.0 = Season 3 Debug|Any CPU
{6DECEB07-996B-4EBC-927C-13E170161107}.Season 3 Debug|x86.ActiveCfg = Season 3 Debug|Any CPU
{6DECEB07-996B-4EBC-927C-13E170161107}.Season 3 Debug|x86.Build.0 = Season 3 Debug|Any CPU
{6DECEB07-996B-4EBC-927C-13E170161107}.Season 3 Release|x64.ActiveCfg = Season 3 Release|Any CPU
{6DECEB07-996B-4EBC-927C-13E170161107}.Season 3 Release|x64.Build.0 = Season 3 Release|Any CPU
{6DECEB07-996B-4EBC-927C-13E170161107}.Season 3 Release|x86.ActiveCfg = Season 3 Release|Any CPU
{6DECEB07-996B-4EBC-927C-13E170161107}.Season 3 Release|x86.Build.0 = Season 3 Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -270,8 +88,6 @@ Global
{380DD490-8F01-4025-91D7-74C289AA5B75} = {76364BAA-BC9D-4A92-AA2A-8914FAE22834}
{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}

Some files were not shown because too many files have changed in this diff Show More